Skip to main content

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.

Back on the cluster since September 2026

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

ToolOpen SourceFree TierMonthly Cost
NotionNoLimitedFrom $10/user
ObsidianNoYesFree local; $4/mo for Sync

Self Hosted

ToolOpen SourceFull FeaturesNotes
Standard NotesYesLimitedE2E encrypted; several features paywalled
OutlineYesYesAlready deployed; a structured wiki, not a capture inbox

Installation​

Architecture​

  • Deployment: Single memos deployment in the memos namespace, Recreate strategy
  • Image: neosmemo/memos, digest-pinned via Renovate
  • Database: CNPG PostgreSQL cluster from the shared component (MEMOS_DRIVER=postgres, DSN from the cnpg-app secret)
  • Storage: Longhorn PVC (memos-data, 2Gi, longhorn-encrypted) on /var/opt/memos for attachments and thumbnails
  • Networking: ClusterIP service on port 5230, HTTPRoute via the public gateway on memos.web.kueber.eu; MEMOS_INSTANCE_URL names 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_dump via the backups-cnpg PreBackupPod
  • 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 as 10001 to 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 the memos-config ConfigMap, the Keycloak provider and NOTIFICATION (SMTP via the shared allinkl account) rendered at pod start from workspace-outputs and 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 off public behind 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 SecurityPolicy would block). Keycloak client memos (realm kueber.eu, redirect https://memos.web.kueber.eu/auth/callback) is gated by the usual role-check browser flow; memos.user is mapped to the family group.
  • Everything file-managed, nothing clicked — Memos loads memos-idp-*.json and memos-instance-setting-*.json from /etc/secrets at start (strict validation, a bad file aborts startup, the matching UI pages turn read-only). GENERAL and STORAGE settings are configmap-config.yaml; the Keycloak provider file and the NOTIFICATION (SMTP) file are rendered by the render-config init container into a memory-backed emptyDir, the former from the client secret the TofuWorkspace minted (workspace-outputs, see workspace/patch-oidc.yaml), the latter from the synced allinkl-smtp-credentials Secret (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/admin as the recovery path.
  • Attachments on the PVC — the STORAGE file pins LOCAL (the Memos default is DATABASE, 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 app database (MEMOS_DSN from cnpg-app), uploads and thumbnails on the 2Gi memos-data Longhorn volume. The component default of 2Gi for the database is plenty for text.
  • Runs as 10001 — the image declares USER root only 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_URL set from day one — 0.31 initialises its ACCESS policy 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​

  1. 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.
  2. The Keycloak provider that was added through the UI before the switch to file-managed config has a different uid than the file-backed keycloak one, so it shows up twice: delete the stored one under Settings → SSO. The file-backed entry cannot be edited or deleted there.
  3. Memos carry their own visibility (private, protected, public). Since 0.31 the ACCESS instance setting in PRIVATE mode 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).
Kubernetes Metadata
  • 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