Memos
A privacy-first lightweight note-taking service.
Memos is an open-source, self-hosted note-taking app built around quick capture: short Markdown notes in a timeline, tags, attachments, and per-memo visibility from private to public link. The web app works well on a phone and there are native mobile clients that talk to the same API with an access token. Self-hosting keeps the notes private and the service free.
An earlier Memos deployment was retired in the August 2026 app restructure. This one is a fresh install: public gateway, Keycloak login, Postgres, and k8up backups from the start.
Alternatives considered
Cloud Hosted
| Tool | Open Source | Free Tier | Monthly Cost |
|---|---|---|---|
| Notion | No | Limited | From $10/user |
| Obsidian | No | Yes | Free local; $4/mo for Sync |
Self Hosted
| Tool | Open Source | Full Features | Notes |
|---|---|---|---|
| Standard Notes | Yes | Limited | E2E encrypted; several features paywalled |
| Outline | Yes | Yes | Already deployed; a structured wiki, not a capture inbox |
Installation
Architecture
- Deployment: Single
memosdeployment in thememosnamespace,Recreatestrategy - Image:
neosmemo/memos, digest-pinned via Renovate - Database: CNPG PostgreSQL cluster from the shared component (
MEMOS_DRIVER=postgres, DSN from thecnpg-appsecret) - Storage: Longhorn PVC (
memos-data, 2Gi,longhorn-encrypted) on/var/opt/memosfor attachments and thumbnails - Networking: ClusterIP service on port 5230, HTTPRoute via the public gateway on
memos.web.kueber.eu;MEMOS_INSTANCE_URLnames the canonical URL
Security
- Runs as
10001,runAsNonRoot: true,readOnlyRootFilesystem: true,allowPrivilegeEscalation: false, capabilities dropped - Longhorn PVC and CNPG volumes encrypted at rest via SOPS-managed keys
- Ingress only from the public gateway's Envoy pods; no internet egress
Updates
Managed by Renovate. Image is digest-pinned. The deployment runs 0.31, which changed the access model (ACCESS instance setting, seeded from MEMOS_INSTANCE_URL on first start), replaced Shortcuts with Views and dropped the RSS feeds.
Data Management
- Database: CNPG cluster,
pg_dumpvia thebackups-cnpgPreBackupPod - PVC:
memos-data(2Gi, Longhorn-encrypted) - Backups: k8up
Schedules to Hetzner and Backblaze (workload-talos-memos-*) via restic, covering the PVC and the database dump; the backup job runs as10001to match the file owner
User Management
OIDC login via Keycloak, client memos in the kueber.eu realm with the role-gated browser flow; memos.user is mapped to the family group. The provider is deployment-managed: the app's workspace creates the client (spec.oidc) and the init container renders memos-idp-keycloak.json with the minted secret from workspace-outputs, mounted under /etc/secrets, read once at start and read-only in the admin UI. Registration stays enabled so a first SSO login can create its account (Keycloak decides who gets that far); password sign-in and sign-up are disabled for regular users, hosts keep /auth/admin. Each memo carries its own visibility; the instance-wide ACCESS setting in PRIVATE mode is what closes the explore page and public links to anonymous visitors. It is not among the file-managed settings yet, so the instance runs on the value seeded at the 0.31 upgrade.
Configuration Management
- Runtime settings via
MEMOS_*env vars in the base Deployment and the talos patch; the database DSN from the CNPG-generated secret - Instance settings are deployment-managed JSON files under
/etc/secrets: GENERAL and STORAGE from thememos-configConfigMap, the Keycloak provider and NOTIFICATION (SMTP via the shared allinkl account) rendered at pod start fromworkspace-outputsand the synced credentials. Memos validates them strictly and will not start on a bad file; the corresponding admin pages are read-only. Only the BASIC and TAGS settings cannot be managed this way.
Usage
Capture from the web app or a mobile client (the token is minted under Settings → Access tokens). Tag inline with #tag, attach images or files, and set visibility per memo — private by default, workspace-visible for shared notes, public for a link. Search spans all memos and tags.
Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/memos/talos/README.md.
Cluster Deployment
Memos — Talos cluster
Deviations from defaults
- Public gateway (
memos.web.kueber.eu) — notes are captured from phones and other networks, so the route hangs offpublicbehind the realm-wide DE geo allowlist; the ingress policy admits only that gateway's Envoy pods. - OIDC lives in the app, not at the gateway — Memos issues its own sessions and access tokens (the mobile clients authenticate with a token, which a gateway
SecurityPolicywould block). Keycloak clientmemos(realmkueber.eu, redirecthttps://memos.web.kueber.eu/auth/callback) is gated by the usual role-check browser flow;memos.useris mapped to thefamilygroup. - Everything file-managed, nothing clicked — Memos loads
memos-idp-*.jsonandmemos-instance-setting-*.jsonfrom/etc/secretsat start (strict validation, a bad file aborts startup, the matching UI pages turn read-only). GENERAL and STORAGE settings areconfigmap-config.yaml; the Keycloak provider file and the NOTIFICATION (SMTP) file are rendered by therender-configinit container into a memory-backed emptyDir, the former from the client secret the TofuWorkspace minted (workspace-outputs, seeworkspace/patch-oidc.yaml), the latter from the syncedallinkl-smtp-credentialsSecret (email component), so neither secret lands in a committed file. BASIC and TAGS cannot be file-managed; MEMO_RELATED keeps defaults. - Registration on, password auth off — a first SSO login must be able to create its local account (the SSO path honours disallow user registration), and Keycloak's role gate already decides who gets that far. Disallow password auth removes the password sign-up and sign-in forms for regular users; hosts and admins are exempt and keep
/auth/adminas the recovery path. - Attachments on the PVC — the STORAGE file pins
LOCAL(the Memos default isDATABASE, i.e. uploads as Postgres blobs) with a 32 MB cap. - Postgres via the cnpg component, PVC only for attachments — memo text sits in the
appdatabase (MEMOS_DSNfromcnpg-app), uploads and thumbnails on the 2Gimemos-dataLonghorn volume. The component default of 2Gi for the database is plenty for text. - Runs as 10001 — the image declares
USER rootonly so its entrypoint can chown legacy data directories before dropping to 10001; started as 10001 it skips that and execs the binary. The k8up backup job runs as the same uid (backups/patch-schedule.yaml) to read the attachment files. MEMOS_INSTANCE_URLset from day one — 0.31 initialises itsACCESSpolicy once from this URL on first start and never re-derives it.- No internet egress — the only outbound call is the token exchange with Keycloak, which the baseline gateway policy already allows. Link previews and webhooks would need an explicit egress rule.
Bootstrap
- The host account exists (first sign-up, done 2026-09-17). On a fresh instance the first sign-up creates it regardless of the settings files.
- The Keycloak provider that was added through the UI before the switch to file-managed config has a different uid than the file-backed
keycloakone, so it shows up twice: delete the stored one under Settings → SSO. The file-backed entry cannot be edited or deleted there. - Memos carry their own visibility (private, protected, public). Since 0.31 the
ACCESSinstance setting inPRIVATEmode requires sign-in for everything; it is not file-managed yet, so add it as another settings file here to pin it. Until then keep every user's default visibility on private (Settings → Preferences).
- Image:
neosmemo/memos:0.31.0@sha256:24c2707ddd8fbd2ceaa7a61ecab86a53cdcde640b24ce572b73da14b00b5785f
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: memos
name: memos
namespace: memos
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: memos
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: memos
spec:
automountServiceAccountToken: false
containers:
- env:
- name: MEMOS_INSTANCE_URL
value: https://memos.web.kueber.eu
- name: MEMOS_DRIVER
value: postgres
- name: MEMOS_DSN
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
- name: MEMOS_MODE
value: prod
- name: MEMOS_PORT
value: '5230'
image: neosmemo/memos:0.31.0@sha256:24c2707ddd8fbd2ceaa7a61ecab86a53cdcde640b24ce572b73da14b00b5785f
livenessProbe:
httpGet:
path: /
port: 5230
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
name: memos
ports:
- containerPort: 5230
name: web
protocol: TCP
readinessProbe:
httpGet:
path: /
port: 5230
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
resources:
limits:
memory: 128Mi
requests:
cpu: 25m
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /var/opt/memos
name: memos-data
- mountPath: /etc/secrets
name: config
readOnly: true
initContainers:
- command:
- sh
- -ec
- >
cp /config-static/*.json /etc/secrets/
esc() { printf '%s' "$1" | sed 's/[\\"]/\\&/g'; }
printf
'{"key":"NOTIFICATION","notificationSetting":{"email":{"enabled":true,"smtpHost":"%s","smtpPort":%s,"smtpUsername":"%s","smtpPassword":"%s","fromEmail":"%s","fromName":"Memos","useTls":true}}}\n'
\
"$(esc "$SMTP_HOST")" "$SMTP_PORT" "$(esc "$SMTP_USERNAME")" "$(esc "$SMTP_PASSWORD")" "$(esc "$SMTP_FROM")" \
> /etc/secrets/memos-instance-setting-notification.json
printf
'{"uid":"keycloak","name":"Keycloak","type":"OAUTH2","identifierFilter":"","config":{"oauth2Config":{"clientId":"memos","clientSecret":"%s","authUrl":"https://keycloak.web.kueber.eu/realms/kueber.eu/protocol/openid-connect/auth","tokenUrl":"https://keycloak.web.kueber.eu/realms/kueber.eu/protocol/openid-connect/token","userInfoUrl":"https://keycloak.web.kueber.eu/realms/kueber.eu/protocol/openid-connect/userinfo","scopes":["openid","profile","email"],"fieldMapping":{"identifier":"preferred_username","displayName":"name","email":"email"}}}}\n'
\
"$(esc "$OIDC_CLIENT_SECRET")" \
> /etc/secrets/memos-idp-keycloak.json
env:
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
key: client-secret
name: workspace-outputs
- name: SMTP_HOST
valueFrom:
secretKeyRef:
key: host
name: allinkl-smtp-credentials
- name: SMTP_PORT
valueFrom:
secretKeyRef:
key: port
name: allinkl-smtp-credentials
- name: SMTP_USERNAME
valueFrom:
secretKeyRef:
key: username
name: allinkl-smtp-credentials
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: allinkl-smtp-credentials
- name: SMTP_FROM
valueFrom:
secretKeyRef:
key: from
name: allinkl-smtp-credentials
image: neosmemo/memos:0.31.0@sha256:24c2707ddd8fbd2ceaa7a61ecab86a53cdcde640b24ce572b73da14b00b5785f
name: render-config
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /config-static
name: config-static
readOnly: true
- mountPath: /etc/secrets
name: config
priorityClassName: tier-2
securityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 10001
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
volumes:
- name: memos-data
persistentVolumeClaim:
claimName: memos-data
- name: config-static
projected:
sources:
- configMap:
name: memos-config
- emptyDir:
medium: Memory
name: config