From 4b801e34445352c8df1a03a554630b929863f1c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 22:33:56 -0700 Subject: [PATCH 1/4] feat(notesnook): migrate self-hosted Notesnook to k3s Adds apps/notesnook (namespace, config, mongo rs0 StatefulSet, minio, identity/sync/sse/monograph deployments + Traefik/cert-manager ingresses) and argocd/notesnook.yaml. Secret applied out-of-band (only .example committed). Includes design spec + implementation plan under docs/superpowers. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/notesnook/attachments-ingress.yaml | 23 + apps/notesnook/db-init-job.yaml | 27 + apps/notesnook/db-service.yaml | 13 + apps/notesnook/db-statefulset.yaml | 42 + apps/notesnook/identity-deployment.yaml | 39 + apps/notesnook/identity-ingress.yaml | 23 + apps/notesnook/identity-service.yaml | 10 + apps/notesnook/kustomization.yaml | 26 + apps/notesnook/minio-bucket-job.yaml | 26 + apps/notesnook/minio-deployment.yaml | 42 + apps/notesnook/minio-pvc.yaml | 10 + apps/notesnook/minio-service.yaml | 11 + apps/notesnook/monograph-deployment.yaml | 37 + apps/notesnook/monograph-ingress.yaml | 21 + apps/notesnook/monograph-service.yaml | 10 + apps/notesnook/namespace.yaml | 4 + apps/notesnook/notesnook-config.yaml | 29 + apps/notesnook/notesnook-secret.yaml.example | 19 + apps/notesnook/sse-deployment.yaml | 34 + apps/notesnook/sse-ingress.yaml | 21 + apps/notesnook/sse-service.yaml | 10 + apps/notesnook/sync-deployment.yaml | 39 + apps/notesnook/sync-ingress.yaml | 28 + apps/notesnook/sync-service.yaml | 10 + argocd/notesnook.yaml | 27 + .../2026-07-15-notesnook-k8s-migration.md | 1192 +++++++++++++++++ ...26-07-15-notesnook-k8s-migration-design.md | 222 +++ 27 files changed, 1995 insertions(+) create mode 100644 apps/notesnook/attachments-ingress.yaml create mode 100644 apps/notesnook/db-init-job.yaml create mode 100644 apps/notesnook/db-service.yaml create mode 100644 apps/notesnook/db-statefulset.yaml create mode 100644 apps/notesnook/identity-deployment.yaml create mode 100644 apps/notesnook/identity-ingress.yaml create mode 100644 apps/notesnook/identity-service.yaml create mode 100644 apps/notesnook/kustomization.yaml create mode 100644 apps/notesnook/minio-bucket-job.yaml create mode 100644 apps/notesnook/minio-deployment.yaml create mode 100644 apps/notesnook/minio-pvc.yaml create mode 100644 apps/notesnook/minio-service.yaml create mode 100644 apps/notesnook/monograph-deployment.yaml create mode 100644 apps/notesnook/monograph-ingress.yaml create mode 100644 apps/notesnook/monograph-service.yaml create mode 100644 apps/notesnook/namespace.yaml create mode 100644 apps/notesnook/notesnook-config.yaml create mode 100644 apps/notesnook/notesnook-secret.yaml.example create mode 100644 apps/notesnook/sse-deployment.yaml create mode 100644 apps/notesnook/sse-ingress.yaml create mode 100644 apps/notesnook/sse-service.yaml create mode 100644 apps/notesnook/sync-deployment.yaml create mode 100644 apps/notesnook/sync-ingress.yaml create mode 100644 apps/notesnook/sync-service.yaml create mode 100644 argocd/notesnook.yaml create mode 100644 docs/superpowers/plans/2026-07-15-notesnook-k8s-migration.md create mode 100644 docs/superpowers/specs/2026-07-15-notesnook-k8s-migration-design.md diff --git a/apps/notesnook/attachments-ingress.yaml b/apps/notesnook/attachments-ingress.yaml new file mode 100644 index 0000000..04de70e --- /dev/null +++ b/apps/notesnook/attachments-ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-attachments + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: attachments.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: notesnook-s3 + port: { number: 9000 } + tls: + - hosts: ["attachments.notes.aleshym.co"] + secretName: notesnook-attachments-tls diff --git a/apps/notesnook/db-init-job.yaml b/apps/notesnook/db-init-job.yaml new file mode 100644 index 0000000..bf01207 --- /dev/null +++ b/apps/notesnook/db-init-job.yaml @@ -0,0 +1,27 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: notesnook-db-init + namespace: notesnook + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 20 + template: + spec: + restartPolicy: OnFailure + containers: + - name: rs-init + image: mongo:7.0.12 + command: ["bash", "-c"] + args: + - | + until mongosh mongodb://notesnook-db:27017 --quiet --eval 'db.runCommand("ping").ok' ; do + echo "waiting for mongod..."; sleep 3; + done + mongosh mongodb://notesnook-db:27017 --quiet --eval ' + try { rs.status() } + catch (e) { rs.initiate({_id:"rs0", members:[{_id:0, host:"notesnook-db:27017"}]}) } + ' + echo "replica set ready" diff --git a/apps/notesnook/db-service.yaml b/apps/notesnook/db-service.yaml new file mode 100644 index 0000000..2d8cc6f --- /dev/null +++ b/apps/notesnook/db-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-db + namespace: notesnook +spec: + clusterIP: None + selector: + app: notesnook-db + ports: + - name: mongo + port: 27017 + targetPort: 27017 diff --git a/apps/notesnook/db-statefulset.yaml b/apps/notesnook/db-statefulset.yaml new file mode 100644 index 0000000..5ad82d0 --- /dev/null +++ b/apps/notesnook/db-statefulset.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: notesnook-db + namespace: notesnook +spec: + serviceName: notesnook-db + replicas: 1 + selector: + matchLabels: + app: notesnook-db + template: + metadata: + labels: + app: notesnook-db + spec: + containers: + - name: mongod + image: mongo:7.0.12 + args: ["--replSet", "rs0", "--bind_ip_all"] + ports: + - containerPort: 27017 + name: mongo + volumeMounts: + - name: dbdata + mountPath: /data/db + readinessProbe: + exec: + command: ["mongosh", "--quiet", "--eval", "db.runCommand('ping').ok"] + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: { cpu: "100m", memory: "256Mi" } + limits: { cpu: "1000m", memory: "1Gi" } + volumeClaimTemplates: + - metadata: + name: dbdata + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi diff --git a/apps/notesnook/identity-deployment.yaml b/apps/notesnook/identity-deployment.yaml new file mode 100644 index 0000000..0937543 --- /dev/null +++ b/apps/notesnook/identity-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-identity + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-identity + template: + metadata: + labels: + app: notesnook-identity + spec: + containers: + - name: identity + image: streetwriters/identity:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + env: + - name: MONGODB_CONNECTION_STRING + value: "mongodb://notesnook-db:27017/identity?replSet=rs0" + - name: MONGODB_DATABASE_NAME + value: "identity" + ports: + - { containerPort: 8264, name: http } + readinessProbe: + httpGet: { path: /health, port: 8264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 8264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } diff --git a/apps/notesnook/identity-ingress.yaml b/apps/notesnook/identity-ingress.yaml new file mode 100644 index 0000000..cb1f636 --- /dev/null +++ b/apps/notesnook/identity-ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-auth + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: auth.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: notesnook-identity + port: { name: http } + tls: + - hosts: ["auth.notes.aleshym.co"] + secretName: notesnook-auth-tls diff --git a/apps/notesnook/identity-service.yaml b/apps/notesnook/identity-service.yaml new file mode 100644 index 0000000..3a10bd6 --- /dev/null +++ b/apps/notesnook/identity-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-identity + namespace: notesnook +spec: + selector: + app: notesnook-identity + ports: + - { name: http, port: 8264, targetPort: 8264 } diff --git a/apps/notesnook/kustomization.yaml b/apps/notesnook/kustomization.yaml new file mode 100644 index 0000000..a33ee61 --- /dev/null +++ b/apps/notesnook/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: notesnook +resources: + - namespace.yaml + - notesnook-config.yaml + - db-service.yaml + - db-statefulset.yaml + - db-init-job.yaml + - minio-pvc.yaml + - minio-deployment.yaml + - minio-service.yaml + - minio-bucket-job.yaml + - identity-deployment.yaml + - identity-service.yaml + - identity-ingress.yaml + - sync-deployment.yaml + - sync-service.yaml + - sync-ingress.yaml + - sse-deployment.yaml + - sse-service.yaml + - sse-ingress.yaml + - monograph-deployment.yaml + - monograph-service.yaml + - monograph-ingress.yaml + - attachments-ingress.yaml diff --git a/apps/notesnook/minio-bucket-job.yaml b/apps/notesnook/minio-bucket-job.yaml new file mode 100644 index 0000000..7e0ace5 --- /dev/null +++ b/apps/notesnook/minio-bucket-job.yaml @@ -0,0 +1,26 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: notesnook-s3-setup + namespace: notesnook + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 20 + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: minio/mc:RELEASE.2024-07-26T13-08-44Z + envFrom: + - secretRef: { name: notesnook-secret } + command: ["/bin/bash", "-c"] + args: + - | + until mc alias set minio http://notesnook-s3:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do + sleep 2; + done + mc mb --ignore-existing minio/attachments + echo "bucket ready" diff --git a/apps/notesnook/minio-deployment.yaml b/apps/notesnook/minio-deployment.yaml new file mode 100644 index 0000000..c64443c --- /dev/null +++ b/apps/notesnook/minio-deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-s3 + namespace: notesnook +spec: + replicas: 1 + strategy: { type: Recreate } + selector: + matchLabels: + app: notesnook-s3 + template: + metadata: + labels: + app: notesnook-s3 + spec: + containers: + - name: minio + image: minio/minio:RELEASE.2024-07-29T22-14-52Z + args: ["server", "/data/s3", "--console-address", ":9090"] + envFrom: + - secretRef: { name: notesnook-secret } + env: + - name: MINIO_BROWSER + value: "on" + ports: + - { containerPort: 9000, name: api } + - { containerPort: 9090, name: console } + volumeMounts: + - name: s3data + mountPath: /data/s3 + readinessProbe: + tcpSocket: { port: 9000 } + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + volumes: + - name: s3data + persistentVolumeClaim: + claimName: notesnook-s3-data diff --git a/apps/notesnook/minio-pvc.yaml b/apps/notesnook/minio-pvc.yaml new file mode 100644 index 0000000..976e27b --- /dev/null +++ b/apps/notesnook/minio-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: notesnook-s3-data + namespace: notesnook +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi diff --git a/apps/notesnook/minio-service.yaml b/apps/notesnook/minio-service.yaml new file mode 100644 index 0000000..08c04c0 --- /dev/null +++ b/apps/notesnook/minio-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-s3 + namespace: notesnook +spec: + selector: + app: notesnook-s3 + ports: + - { name: api, port: 9000, targetPort: 9000 } + - { name: console, port: 9090, targetPort: 9090 } diff --git a/apps/notesnook/monograph-deployment.yaml b/apps/notesnook/monograph-deployment.yaml new file mode 100644 index 0000000..f799008 --- /dev/null +++ b/apps/notesnook/monograph-deployment.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-monograph + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-monograph + template: + metadata: + labels: + app: notesnook-monograph + spec: + containers: + - name: monograph + image: streetwriters/monograph:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + env: + - name: HOST + value: "0.0.0.0" + - name: API_HOST + value: "http://notesnook-sync:5264" + - name: PUBLIC_URL + value: "https://monograph.notes.aleshym.co" + ports: + - { containerPort: 3000, name: http } + readinessProbe: + httpGet: { path: /, port: 3000 } + initialDelaySeconds: 20 + periodSeconds: 15 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } diff --git a/apps/notesnook/monograph-ingress.yaml b/apps/notesnook/monograph-ingress.yaml new file mode 100644 index 0000000..13b3723 --- /dev/null +++ b/apps/notesnook/monograph-ingress.yaml @@ -0,0 +1,21 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-monograph + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: monograph.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-monograph, port: { name: http } } + tls: + - hosts: ["monograph.notes.aleshym.co"] + secretName: notesnook-monograph-tls diff --git a/apps/notesnook/monograph-service.yaml b/apps/notesnook/monograph-service.yaml new file mode 100644 index 0000000..3082314 --- /dev/null +++ b/apps/notesnook/monograph-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-monograph + namespace: notesnook +spec: + selector: + app: notesnook-monograph + ports: + - { name: http, port: 3000, targetPort: 3000 } diff --git a/apps/notesnook/namespace.yaml b/apps/notesnook/namespace.yaml new file mode 100644 index 0000000..abd9fc9 --- /dev/null +++ b/apps/notesnook/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: notesnook diff --git a/apps/notesnook/notesnook-config.yaml b/apps/notesnook/notesnook-config.yaml new file mode 100644 index 0000000..4563773 --- /dev/null +++ b/apps/notesnook/notesnook-config.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: notesnook-config + namespace: notesnook +data: + INSTANCE_NAME: "aleshym-notesnook" + DISABLE_SIGNUPS: "true" + NOTESNOOK_APP_PUBLIC_URL: "https://app.notesnook.com" + AUTH_SERVER_PUBLIC_URL: "https://auth.notes.aleshym.co" + MONOGRAPH_PUBLIC_URL: "https://monograph.notes.aleshym.co" + ATTACHMENTS_SERVER_PUBLIC_URL: "https://attachments.notes.aleshym.co" + NOTESNOOK_CORS_ORIGINS: "https://app.notesnook.com,https://notes.aleshym.co" + KNOWN_PROXIES: "" + SELF_HOSTED: "1" + NOTESNOOK_SERVER_HOST: "notesnook-sync" + NOTESNOOK_SERVER_PORT: "5264" + IDENTITY_SERVER_HOST: "notesnook-identity" + IDENTITY_SERVER_PORT: "8264" + SSE_SERVER_HOST: "notesnook-sse" + SSE_SERVER_PORT: "7264" + IDENTITY_SERVER_URL: "https://auth.notes.aleshym.co" + NOTESNOOK_APP_HOST: "https://app.notesnook.com" + S3_INTERNAL_SERVICE_URL: "http://notesnook-s3:9000" + S3_INTERNAL_BUCKET_NAME: "attachments" + S3_SERVICE_URL: "https://attachments.notes.aleshym.co" + S3_BUCKET_NAME: "attachments" + S3_REGION: "us-east-1" + MINIO_BROWSER: "on" diff --git a/apps/notesnook/notesnook-secret.yaml.example b/apps/notesnook/notesnook-secret.yaml.example new file mode 100644 index 0000000..e492e11 --- /dev/null +++ b/apps/notesnook/notesnook-secret.yaml.example @@ -0,0 +1,19 @@ +# DO NOT COMMIT THE REAL VERSION. Copy to notesnook-secret.yaml (gitignored), +# fill real values from the old stack's .env, then: +# kubectl apply -f apps/notesnook/notesnook-secret.yaml +# ArgoCD ignores this Secret's /data (see argocd/notesnook.yaml). +apiVersion: v1 +kind: Secret +metadata: + name: notesnook-secret + namespace: notesnook +stringData: + NOTESNOOK_API_SECRET: "CHANGE_ME" + SMTP_USERNAME: "funman300@gmail.com" + SMTP_PASSWORD: "CHANGE_ME" + SMTP_HOST: "smtp.gmail.com" + SMTP_PORT: "587" + MINIO_ROOT_USER: "notesnook" + MINIO_ROOT_PASSWORD: "CHANGE_ME" + S3_ACCESS_KEY_ID: "notesnook" + S3_ACCESS_KEY: "CHANGE_ME" diff --git a/apps/notesnook/sse-deployment.yaml b/apps/notesnook/sse-deployment.yaml new file mode 100644 index 0000000..8709098 --- /dev/null +++ b/apps/notesnook/sse-deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-sse + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-sse + template: + metadata: + labels: + app: notesnook-sse + spec: + containers: + - name: sse + image: streetwriters/sse:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + ports: + - { containerPort: 7264, name: http } + readinessProbe: + httpGet: { path: /health, port: 7264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 7264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "256Mi" } diff --git a/apps/notesnook/sse-ingress.yaml b/apps/notesnook/sse-ingress.yaml new file mode 100644 index 0000000..6258db3 --- /dev/null +++ b/apps/notesnook/sse-ingress.yaml @@ -0,0 +1,21 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-sse + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: events.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sse, port: { name: http } } + tls: + - hosts: ["events.notes.aleshym.co"] + secretName: notesnook-sse-tls diff --git a/apps/notesnook/sse-service.yaml b/apps/notesnook/sse-service.yaml new file mode 100644 index 0000000..537db8f --- /dev/null +++ b/apps/notesnook/sse-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-sse + namespace: notesnook +spec: + selector: + app: notesnook-sse + ports: + - { name: http, port: 7264, targetPort: 7264 } diff --git a/apps/notesnook/sync-deployment.yaml b/apps/notesnook/sync-deployment.yaml new file mode 100644 index 0000000..35cb102 --- /dev/null +++ b/apps/notesnook/sync-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-sync + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-sync + template: + metadata: + labels: + app: notesnook-sync + spec: + containers: + - name: sync + image: streetwriters/notesnook-sync:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + env: + - name: MONGODB_CONNECTION_STRING + value: "mongodb://notesnook-db:27017/?replSet=rs0" + - name: MONGODB_DATABASE_NAME + value: "notesnook" + ports: + - { containerPort: 5264, name: http } + readinessProbe: + httpGet: { path: /health, port: 5264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 5264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "1000m", memory: "512Mi" } diff --git a/apps/notesnook/sync-ingress.yaml b/apps/notesnook/sync-ingress.yaml new file mode 100644 index 0000000..8772fbe --- /dev/null +++ b/apps/notesnook/sync-ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-sync + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: sync.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sync, port: { name: http } } + - host: notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sync, port: { name: http } } + tls: + - hosts: ["sync.notes.aleshym.co", "notes.aleshym.co"] + secretName: notesnook-sync-tls diff --git a/apps/notesnook/sync-service.yaml b/apps/notesnook/sync-service.yaml new file mode 100644 index 0000000..2815d1d --- /dev/null +++ b/apps/notesnook/sync-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: notesnook-sync + namespace: notesnook +spec: + selector: + app: notesnook-sync + ports: + - { name: http, port: 5264, targetPort: 5264 } diff --git a/argocd/notesnook.yaml b/argocd/notesnook.yaml new file mode 100644 index 0000000..d5f03f6 --- /dev/null +++ b/argocd/notesnook.yaml @@ -0,0 +1,27 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: notesnook + namespace: argocd +spec: + project: default + source: + repoURL: https://git.aleshym.co/funman300/k3s-homelab.git + targetRevision: main + path: apps/notesnook + destination: + server: https://kubernetes.default.svc + namespace: notesnook + ignoreDifferences: + - group: "" + kind: Secret + name: notesnook-secret + namespace: notesnook + jsonPointers: + - /data + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/docs/superpowers/plans/2026-07-15-notesnook-k8s-migration.md b/docs/superpowers/plans/2026-07-15-notesnook-k8s-migration.md new file mode 100644 index 0000000..de77057 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-notesnook-k8s-migration.md @@ -0,0 +1,1192 @@ +# Notesnook → k3s Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the self-hosted Notesnook stack from docker-compose on `10.10.0.5` to the k3s cluster (`apps/notesnook/` in the `k3s-homelab` GitOps repo), preserving the existing account, notes, keys, and attachments. + +**Architecture:** Replicate the compose stack as k8s workloads — a single-node MongoDB replica set (`rs0`), MinIO (S3), and the four streetwriters services (identity, sync, sse, monograph) — sharing one ConfigMap (non-secret env, mirroring compose's `env_file: .env` + `server-discovery` anchor) and one out-of-band Secret. Traefik ingress per public host with cert-manager (Cloudflare DNS-01) TLS. Data is copied via `mongodump`/`mongorestore` and `mc mirror` during a brief write-frozen cutover, then internal DNS on `10.10.0.8` is repointed from `10.10.0.5` to `10.10.0.100`. + +**Tech Stack:** k3s v1.35, ArgoCD, Kustomize, Traefik, cert-manager, MongoDB 7.0.12, MinIO, streetwriters/{identity,notesnook-sync,sse,monograph}. + +## Global Constraints + +- Namespace: `notesnook`. Repo path: `apps/notesnook/`. ArgoCD app: `argocd/notesnook.yaml`. +- Image pins (match compose exactly): `mongo:7.0.12`, `minio/minio:RELEASE.2024-07-29T22-14-52Z`, `minio/mc:RELEASE.2024-07-26T13-08-44Z`, `streetwriters/identity:latest`, `streetwriters/notesnook-sync:latest`, `streetwriters/sse:latest`, `streetwriters/monograph:latest`. +- **Do NOT regenerate `NOTESNOOK_API_SECRET` or MinIO credentials** — reuse the existing values from the old `.env`; migrated data depends on them. +- Secrets are applied out-of-band: commit only `notesnook-secret.yaml.example`; the real `apps/notesnook/notesnook-secret.yaml` is gitignored (`apps/**/*-secret.yaml` already covered). +- Ingress convention (copy from `apps/nightscout/nightscout-ingress.yaml`): `ingressClassName: traefik`, annotations `cert-manager.io/cluster-issuer: letsencrypt-prod` and `traefik.ingress.kubernetes.io/router.entrypoints: websecure`, per-host `tls.secretName`. +- Public hosts: `auth`/`sync`/`events`/`monograph`/`attachments`.notes.aleshym.co + apex `notes.aleshym.co`. All resolve (post-cutover) to `10.10.0.100` (Traefik LB). +- In-cluster service names (replace compose hostnames): `notesnook-db`, `notesnook-s3`, `notesnook-identity`, `notesnook-sync`, `notesnook-sse`, `notesnook-monograph`. +- `KNOWN_PROXIES=10.42.0.0/16` (k3s pod CIDR; node podCIDR observed `10.42.0.0/24` — /16 covers the cluster). Verify in Task 3 and adjust if forwarded-header/client-IP handling misbehaves. +- **Testing strategy:** build on a git branch; validate runtime by applying directly with `kubectl apply -k apps/notesnook` (ArgoCD watches `main` only, so it won't interfere on the branch). After end-to-end validation, merge to `main`; ArgoCD adopts the already-running resources (identical manifests → no churn). + +## Access prerequisites (resolve before Task 9 / Task 11) + +- **Git push** to `git.aleshym.co/funman300/k3s-homelab` (current clone is read-only). Default workflow: feature branch `feat/notesnook-k8s` → PR → merge to `main`. +- **Internal DNS edit** on `10.10.0.8` (Task 11 cutover). Identify the service (AdGuard/Pi-hole/unbound) and access method. +- Old-stack values needed for the real Secret (Task 9): pull from `manage@10.10.0.5:~/notesnook/.env` — `NOTESNOOK_API_SECRET`, `SMTP_PASSWORD`, `MINIO_ROOT_PASSWORD`. + +## File Structure + +``` +apps/notesnook/ + kustomization.yaml + namespace.yaml + notesnook-config.yaml # ConfigMap: all non-secret env + server-discovery + notesnook-secret.yaml.example # Secret template (real applied out-of-band) + db-statefulset.yaml # mongo:7.0.12 --replSet rs0 + db-service.yaml # headless Service notesnook-db + db-init-job.yaml # idempotent rs.initiate() + minio-pvc.yaml + minio-deployment.yaml + minio-service.yaml + minio-bucket-job.yaml # create 'attachments' bucket + identity-deployment.yaml + identity-service.yaml + identity-ingress.yaml + sync-deployment.yaml + sync-service.yaml + sync-ingress.yaml + sse-deployment.yaml + sse-service.yaml + sse-ingress.yaml + monograph-deployment.yaml + monograph-service.yaml + monograph-ingress.yaml + attachments-ingress.yaml +argocd/notesnook.yaml +``` + +--- + +### Task 1: Scaffold namespace, ConfigMap, secret template, kustomization + +**Files:** +- Create: `apps/notesnook/namespace.yaml`, `apps/notesnook/notesnook-config.yaml`, `apps/notesnook/notesnook-secret.yaml.example`, `apps/notesnook/kustomization.yaml` + +**Interfaces:** +- Produces: ConfigMap `notesnook-config` and Secret `notesnook-secret` (both in ns `notesnook`), consumed via `envFrom` by every service Deployment in later tasks. + +- [ ] **Step 1: Create the branch** + +```bash +cd +git checkout -b feat/notesnook-k8s +``` + +- [ ] **Step 2: namespace.yaml** + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: notesnook +``` + +- [ ] **Step 3: notesnook-config.yaml** (non-secret env — mirrors compose `.env` non-secrets + `server-discovery`, with k8s service hostnames) + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: notesnook-config + namespace: notesnook +data: + INSTANCE_NAME: "aleshym-notesnook" + DISABLE_SIGNUPS: "true" + NOTESNOOK_APP_PUBLIC_URL: "https://app.notesnook.com" + AUTH_SERVER_PUBLIC_URL: "https://auth.notes.aleshym.co" + MONOGRAPH_PUBLIC_URL: "https://monograph.notes.aleshym.co" + ATTACHMENTS_SERVER_PUBLIC_URL: "https://attachments.notes.aleshym.co" + NOTESNOOK_CORS_ORIGINS: "https://app.notesnook.com,https://notes.aleshym.co" + KNOWN_PROXIES: "10.42.0.0/16" + # server-discovery (compose anchor) — hostnames point at k8s Services + SELF_HOSTED: "1" + NOTESNOOK_SERVER_HOST: "notesnook-sync" + NOTESNOOK_SERVER_PORT: "5264" + IDENTITY_SERVER_HOST: "notesnook-identity" + IDENTITY_SERVER_PORT: "8264" + SSE_SERVER_HOST: "notesnook-sse" + SSE_SERVER_PORT: "7264" + IDENTITY_SERVER_URL: "https://auth.notes.aleshym.co" + NOTESNOOK_APP_HOST: "https://app.notesnook.com" + # S3 (non-secret) + S3_INTERNAL_SERVICE_URL: "http://notesnook-s3:9000" + S3_INTERNAL_BUCKET_NAME: "attachments" + S3_SERVICE_URL: "https://attachments.notes.aleshym.co" + S3_BUCKET_NAME: "attachments" + S3_REGION: "us-east-1" + MINIO_BROWSER: "on" +``` + +- [ ] **Step 4: notesnook-secret.yaml.example** (template; real file applied out-of-band in Task 9) + +```yaml +# DO NOT COMMIT THE REAL VERSION. Copy to notesnook-secret.yaml (gitignored), +# fill real values from the old stack's .env, then: +# kubectl apply -f apps/notesnook/notesnook-secret.yaml +# ArgoCD ignores this Secret's /data (see argocd/notesnook.yaml). +apiVersion: v1 +kind: Secret +metadata: + name: notesnook-secret + namespace: notesnook +stringData: + NOTESNOOK_API_SECRET: "CHANGE_ME" # reuse existing value — do NOT regenerate + SMTP_USERNAME: "funman300@gmail.com" + SMTP_PASSWORD: "CHANGE_ME" # Gmail app password + SMTP_HOST: "smtp.gmail.com" + SMTP_PORT: "587" + MINIO_ROOT_USER: "notesnook" + MINIO_ROOT_PASSWORD: "CHANGE_ME" # reuse existing value + S3_ACCESS_KEY_ID: "notesnook" # = MINIO_ROOT_USER + S3_ACCESS_KEY: "CHANGE_ME" # = MINIO_ROOT_PASSWORD +``` + +- [ ] **Step 5: kustomization.yaml** (all resources; grows as tasks add files) + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: notesnook +resources: + - namespace.yaml + - notesnook-config.yaml + - db-service.yaml + - db-statefulset.yaml + - db-init-job.yaml + - minio-pvc.yaml + - minio-deployment.yaml + - minio-service.yaml + - minio-bucket-job.yaml + - identity-deployment.yaml + - identity-service.yaml + - identity-ingress.yaml + - sync-deployment.yaml + - sync-service.yaml + - sync-ingress.yaml + - sse-deployment.yaml + - sse-service.yaml + - sse-ingress.yaml + - monograph-deployment.yaml + - monograph-service.yaml + - monograph-ingress.yaml + - attachments-ingress.yaml +``` + +- [ ] **Step 6: Apply just the namespace + config, and the real secret, to validate** + +```bash +kubectl apply -f apps/notesnook/namespace.yaml +kubectl apply -f apps/notesnook/notesnook-config.yaml +``` +Expected: `namespace/notesnook created`, `configmap/notesnook-config created`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/notesnook/namespace.yaml apps/notesnook/notesnook-config.yaml apps/notesnook/notesnook-secret.yaml.example apps/notesnook/kustomization.yaml +git commit -m "feat(notesnook): scaffold namespace, config, secret template" +``` + +--- + +### Task 2: MongoDB single-node replica set (`rs0`) + +**Files:** +- Create: `apps/notesnook/db-service.yaml`, `apps/notesnook/db-statefulset.yaml`, `apps/notesnook/db-init-job.yaml` + +**Interfaces:** +- Produces: reachable Mongo at `notesnook-db:27017` with replica set `rs0` initiated (member host `notesnook-db:27017`). Consumed by identity (`/identity`) and sync (`/notesnook`) via `?replSet=rs0`. + +- [ ] **Step 1: db-service.yaml** (headless for stable DNS) + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-db + namespace: notesnook +spec: + clusterIP: None + selector: + app: notesnook-db + ports: + - name: mongo + port: 27017 + targetPort: 27017 +``` + +- [ ] **Step 2: db-statefulset.yaml** + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: notesnook-db + namespace: notesnook +spec: + serviceName: notesnook-db + replicas: 1 + selector: + matchLabels: + app: notesnook-db + template: + metadata: + labels: + app: notesnook-db + spec: + containers: + - name: mongod + image: mongo:7.0.12 + args: ["--replSet", "rs0", "--bind_ip_all"] + ports: + - containerPort: 27017 + name: mongo + volumeMounts: + - name: dbdata + mountPath: /data/db + readinessProbe: + exec: + command: ["mongosh", "--quiet", "--eval", "db.runCommand('ping').ok"] + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: { cpu: "100m", memory: "256Mi" } + limits: { cpu: "1000m", memory: "1Gi" } + volumeClaimTemplates: + - metadata: + name: dbdata + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi +``` + +- [ ] **Step 3: db-init-job.yaml** (idempotent replica-set initiation) + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: notesnook-db-init + namespace: notesnook + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 20 + template: + spec: + restartPolicy: OnFailure + containers: + - name: rs-init + image: mongo:7.0.12 + command: ["bash", "-c"] + args: + - | + until mongosh mongodb://notesnook-db:27017 --quiet --eval 'db.runCommand("ping").ok' ; do + echo "waiting for mongod..."; sleep 3; + done + mongosh mongodb://notesnook-db:27017 --quiet --eval ' + try { rs.status() } + catch (e) { rs.initiate({_id:"rs0", members:[{_id:0, host:"notesnook-db:27017"}]}) } + ' + echo "replica set ready" +``` + +- [ ] **Step 4: Apply and verify replica set is PRIMARY** + +```bash +kubectl apply -f apps/notesnook/db-service.yaml -f apps/notesnook/db-statefulset.yaml +kubectl -n notesnook rollout status statefulset/notesnook-db --timeout=180s +kubectl apply -f apps/notesnook/db-init-job.yaml +kubectl -n notesnook wait --for=condition=complete job/notesnook-db-init --timeout=180s +kubectl -n notesnook exec notesnook-db-0 -- mongosh --quiet --eval 'rs.status().members[0].stateStr' +``` +Expected final line: `PRIMARY` + +- [ ] **Step 5: Commit** + +```bash +git add apps/notesnook/db-service.yaml apps/notesnook/db-statefulset.yaml apps/notesnook/db-init-job.yaml +git commit -m "feat(notesnook): mongodb single-node replica set rs0" +``` + +--- + +### Task 3: MinIO (S3) + attachments bucket + +**Files:** +- Create: `apps/notesnook/minio-pvc.yaml`, `apps/notesnook/minio-deployment.yaml`, `apps/notesnook/minio-service.yaml`, `apps/notesnook/minio-bucket-job.yaml` + +**Interfaces:** +- Produces: MinIO at `notesnook-s3:9000` (API) / `:9090` (console), bucket `attachments`. Consumed by sync server (`S3_INTERNAL_SERVICE_URL`) and the `attachments` ingress. +- Consumes: Secret `notesnook-secret` (`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`). + +- [ ] **Step 1: minio-pvc.yaml** + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: notesnook-s3-data + namespace: notesnook +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi +``` + +- [ ] **Step 2: minio-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-s3 + namespace: notesnook +spec: + replicas: 1 + strategy: { type: Recreate } + selector: + matchLabels: + app: notesnook-s3 + template: + metadata: + labels: + app: notesnook-s3 + spec: + containers: + - name: minio + image: minio/minio:RELEASE.2024-07-29T22-14-52Z + args: ["server", "/data/s3", "--console-address", ":9090"] + envFrom: + - secretRef: { name: notesnook-secret } # MINIO_ROOT_USER/PASSWORD + env: + - name: MINIO_BROWSER + value: "on" + ports: + - { containerPort: 9000, name: api } + - { containerPort: 9090, name: console } + volumeMounts: + - name: s3data + mountPath: /data/s3 + readinessProbe: + tcpSocket: { port: 9000 } + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + volumes: + - name: s3data + persistentVolumeClaim: + claimName: notesnook-s3-data +``` + +- [ ] **Step 3: minio-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-s3 + namespace: notesnook +spec: + selector: + app: notesnook-s3 + ports: + - { name: api, port: 9000, targetPort: 9000 } + - { name: console, port: 9090, targetPort: 9090 } +``` + +- [ ] **Step 4: minio-bucket-job.yaml** (create `attachments`, idempotent) + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: notesnook-s3-setup + namespace: notesnook + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 20 + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: minio/mc:RELEASE.2024-07-26T13-08-44Z + envFrom: + - secretRef: { name: notesnook-secret } + command: ["/bin/bash", "-c"] + args: + - | + until mc alias set minio http://notesnook-s3:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do + sleep 2; + done + mc mb --ignore-existing minio/attachments + echo "bucket ready" +``` + +- [ ] **Step 5: Apply and verify bucket exists** + +```bash +kubectl apply -f apps/notesnook/minio-pvc.yaml -f apps/notesnook/minio-deployment.yaml -f apps/notesnook/minio-service.yaml +kubectl -n notesnook rollout status deploy/notesnook-s3 --timeout=120s +kubectl apply -f apps/notesnook/minio-bucket-job.yaml +kubectl -n notesnook wait --for=condition=complete job/notesnook-s3-setup --timeout=120s +kubectl -n notesnook logs job/notesnook-s3-setup | tail -1 +``` +Expected: `bucket ready` + +- [ ] **Step 6: Confirm KNOWN_PROXIES CIDR is correct** + +```bash +kubectl -n kube-system get pods -o wide | grep traefik # note pod IP is within 10.42.x.x +``` +Expected: Traefik pod IP inside `10.42.0.0/16`. If not, update `KNOWN_PROXIES` in `notesnook-config.yaml`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/notesnook/minio-*.yaml +git commit -m "feat(notesnook): minio s3 + attachments bucket" +``` + +--- + +### Task 4: Identity/auth server + +**Files:** +- Create: `apps/notesnook/identity-deployment.yaml`, `apps/notesnook/identity-service.yaml`, `apps/notesnook/identity-ingress.yaml` + +**Interfaces:** +- Consumes: ConfigMap `notesnook-config`, Secret `notesnook-secret`, Mongo `notesnook-db` (`/identity`). +- Produces: `notesnook-identity:8264` (health `/health`); ingress `auth.notes.aleshym.co`. + +- [ ] **Step 1: identity-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-identity + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-identity + template: + metadata: + labels: + app: notesnook-identity + spec: + containers: + - name: identity + image: streetwriters/identity:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + env: + - name: MONGODB_CONNECTION_STRING + value: "mongodb://notesnook-db:27017/identity?replSet=rs0" + - name: MONGODB_DATABASE_NAME + value: "identity" + ports: + - { containerPort: 8264, name: http } + readinessProbe: + httpGet: { path: /health, port: 8264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 8264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } +``` + +- [ ] **Step 2: identity-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-identity + namespace: notesnook +spec: + selector: + app: notesnook-identity + ports: + - { name: http, port: 8264, targetPort: 8264 } +``` + +- [ ] **Step 3: identity-ingress.yaml** + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-auth + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: auth.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: notesnook-identity + port: { name: http } + tls: + - hosts: ["auth.notes.aleshym.co"] + secretName: notesnook-auth-tls +``` + +- [ ] **Step 4: Apply and verify pod healthy** (requires the real Secret from Task 9; if not yet applied, apply it now — see Task 9 Step 1) + +```bash +kubectl apply -f apps/notesnook/identity-deployment.yaml -f apps/notesnook/identity-service.yaml -f apps/notesnook/identity-ingress.yaml +kubectl -n notesnook rollout status deploy/notesnook-identity --timeout=180s +kubectl -n notesnook exec deploy/notesnook-identity -- wget -qO- http://localhost:8264/health +``` +Expected: rollout succeeds; health returns a 200 body (non-empty). + +- [ ] **Step 5: Commit** + +```bash +git add apps/notesnook/identity-*.yaml +git commit -m "feat(notesnook): identity/auth server + ingress" +``` + +--- + +### Task 5: Sync server + +**Files:** +- Create: `apps/notesnook/sync-deployment.yaml`, `apps/notesnook/sync-service.yaml`, `apps/notesnook/sync-ingress.yaml` + +**Interfaces:** +- Consumes: ConfigMap, Secret, Mongo (`/notesnook`), MinIO (`notesnook-s3:9000`), identity. +- Produces: `notesnook-sync:5264` (health `/health`); ingress `sync.notes.aleshym.co` + apex `notes.aleshym.co`. + +- [ ] **Step 1: sync-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-sync + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-sync + template: + metadata: + labels: + app: notesnook-sync + spec: + containers: + - name: sync + image: streetwriters/notesnook-sync:latest + envFrom: + - configMapRef: { name: notesnook-config } # incl. S3_* non-secret + - secretRef: { name: notesnook-secret } # incl. S3_ACCESS_KEY* + env: + - name: MONGODB_CONNECTION_STRING + value: "mongodb://notesnook-db:27017/?replSet=rs0" + - name: MONGODB_DATABASE_NAME + value: "notesnook" + ports: + - { containerPort: 5264, name: http } + readinessProbe: + httpGet: { path: /health, port: 5264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 5264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "1000m", memory: "512Mi" } +``` + +- [ ] **Step 2: sync-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-sync + namespace: notesnook +spec: + selector: + app: notesnook-sync + ports: + - { name: http, port: 5264, targetPort: 5264 } +``` + +- [ ] **Step 3: sync-ingress.yaml** (two hosts: sync + apex) + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-sync + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: sync.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sync, port: { name: http } } + - host: notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sync, port: { name: http } } + tls: + - hosts: ["sync.notes.aleshym.co", "notes.aleshym.co"] + secretName: notesnook-sync-tls +``` + +- [ ] **Step 4: Apply and verify** + +```bash +kubectl apply -f apps/notesnook/sync-deployment.yaml -f apps/notesnook/sync-service.yaml -f apps/notesnook/sync-ingress.yaml +kubectl -n notesnook rollout status deploy/notesnook-sync --timeout=180s +kubectl -n notesnook exec deploy/notesnook-sync -- wget -qO- http://localhost:5264/health +``` +Expected: rollout succeeds; health returns 200. + +- [ ] **Step 5: Commit** + +```bash +git add apps/notesnook/sync-*.yaml +git commit -m "feat(notesnook): sync server + ingress" +``` + +--- + +### Task 6: SSE/events server + +**Files:** +- Create: `apps/notesnook/sse-deployment.yaml`, `apps/notesnook/sse-service.yaml`, `apps/notesnook/sse-ingress.yaml` + +**Interfaces:** +- Consumes: ConfigMap, Secret, identity + sync (via server-discovery). +- Produces: `notesnook-sse:7264` (health `/health`); ingress `events.notes.aleshym.co`. + +- [ ] **Step 1: sse-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-sse + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-sse + template: + metadata: + labels: + app: notesnook-sse + spec: + containers: + - name: sse + image: streetwriters/sse:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + ports: + - { containerPort: 7264, name: http } + readinessProbe: + httpGet: { path: /health, port: 7264 } + initialDelaySeconds: 20 + periodSeconds: 15 + livenessProbe: + httpGet: { path: /health, port: 7264 } + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "256Mi" } +``` + +- [ ] **Step 2: sse-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-sse + namespace: notesnook +spec: + selector: + app: notesnook-sse + ports: + - { name: http, port: 7264, targetPort: 7264 } +``` + +- [ ] **Step 3: sse-ingress.yaml** + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-sse + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: events.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-sse, port: { name: http } } + tls: + - hosts: ["events.notes.aleshym.co"] + secretName: notesnook-sse-tls +``` + +- [ ] **Step 4: Apply and verify** + +```bash +kubectl apply -f apps/notesnook/sse-deployment.yaml -f apps/notesnook/sse-service.yaml -f apps/notesnook/sse-ingress.yaml +kubectl -n notesnook rollout status deploy/notesnook-sse --timeout=180s +kubectl -n notesnook exec deploy/notesnook-sse -- wget -qO- http://localhost:7264/health +``` +Expected: rollout succeeds; health returns 200. + +- [ ] **Step 5: Commit** + +```bash +git add apps/notesnook/sse-*.yaml +git commit -m "feat(notesnook): sse/events server + ingress" +``` + +--- + +### Task 7: Monograph server + +**Files:** +- Create: `apps/notesnook/monograph-deployment.yaml`, `apps/notesnook/monograph-service.yaml`, `apps/notesnook/monograph-ingress.yaml` + +**Interfaces:** +- Consumes: ConfigMap, Secret, sync (`API_HOST`). +- Produces: `notesnook-monograph:3000`; ingress `monograph.notes.aleshym.co`. + +- [ ] **Step 1: monograph-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notesnook-monograph + namespace: notesnook +spec: + replicas: 1 + selector: + matchLabels: + app: notesnook-monograph + template: + metadata: + labels: + app: notesnook-monograph + spec: + containers: + - name: monograph + image: streetwriters/monograph:latest + envFrom: + - configMapRef: { name: notesnook-config } + - secretRef: { name: notesnook-secret } + env: + - name: HOST + value: "0.0.0.0" + - name: API_HOST + value: "http://notesnook-sync:5264" + - name: PUBLIC_URL + value: "https://monograph.notes.aleshym.co" + ports: + - { containerPort: 3000, name: http } + readinessProbe: + httpGet: { path: /, port: 3000 } + initialDelaySeconds: 20 + periodSeconds: 15 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } +``` + +- [ ] **Step 2: monograph-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: notesnook-monograph + namespace: notesnook +spec: + selector: + app: notesnook-monograph + ports: + - { name: http, port: 3000, targetPort: 3000 } +``` + +- [ ] **Step 3: monograph-ingress.yaml** + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-monograph + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: monograph.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: { name: notesnook-monograph, port: { name: http } } + tls: + - hosts: ["monograph.notes.aleshym.co"] + secretName: notesnook-monograph-tls +``` + +- [ ] **Step 4: Apply and verify** + +```bash +kubectl apply -f apps/notesnook/monograph-deployment.yaml -f apps/notesnook/monograph-service.yaml -f apps/notesnook/monograph-ingress.yaml +kubectl -n notesnook rollout status deploy/notesnook-monograph --timeout=180s +kubectl -n notesnook exec deploy/notesnook-monograph -- wget -qO- -S http://localhost:3000/ 2>&1 | grep HTTP | head -1 +``` +Expected: rollout succeeds; HTTP status < 500. + +- [ ] **Step 5: Commit** + +```bash +git add apps/notesnook/monograph-*.yaml +git commit -m "feat(notesnook): monograph server + ingress" +``` + +--- + +### Task 8: Attachments ingress (public MinIO) + +**Files:** +- Create: `apps/notesnook/attachments-ingress.yaml` + +**Interfaces:** +- Produces: ingress `attachments.notes.aleshym.co` → `notesnook-s3:9000`. Consumed by clients fetching attachments via S3 presigned URLs. + +- [ ] **Step 1: attachments-ingress.yaml** + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notesnook-attachments + namespace: notesnook + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: attachments.notes.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: notesnook-s3 + port: { number: 9000 } + tls: + - hosts: ["attachments.notes.aleshym.co"] + secretName: notesnook-attachments-tls +``` + +- [ ] **Step 2: Apply and verify all ingresses present** + +```bash +kubectl apply -f apps/notesnook/attachments-ingress.yaml +kubectl -n notesnook get ingress +``` +Expected: 5 ingresses (auth, sync, sse, monograph, attachments), all `ADDRESS 10.10.0.100`. + +- [ ] **Step 3: Commit** + +```bash +git add apps/notesnook/attachments-ingress.yaml +git commit -m "feat(notesnook): attachments (minio) public ingress" +``` + +--- + +### Task 9: Apply the real Secret out-of-band + +**Files:** +- Create (gitignored, local only): `apps/notesnook/notesnook-secret.yaml` + +> Do this early enough that Tasks 4–7 pods can start (they mount the Secret). If those tasks were applied before this, restart them afterward: `kubectl -n notesnook rollout restart deploy`. + +- [ ] **Step 1: Build the real secret from the old stack's values** + +```bash +# Pull the three secret values from the old .env (values only; keep them off-screen) +ssh manage@10.10.0.5 'grep -E "^(NOTESNOOK_API_SECRET|SMTP_PASSWORD|MINIO_ROOT_PASSWORD)=" ~/notesnook/.env' +# Copy the template and fill in real values (NOTESNOOK_API_SECRET, SMTP_PASSWORD, +# MINIO_ROOT_PASSWORD, and S3_ACCESS_KEY = MINIO_ROOT_PASSWORD): +cp apps/notesnook/notesnook-secret.yaml.example apps/notesnook/notesnook-secret.yaml +${EDITOR:-nano} apps/notesnook/notesnook-secret.yaml +``` + +- [ ] **Step 2: Apply and confirm it's gitignored** + +```bash +kubectl apply -f apps/notesnook/notesnook-secret.yaml +kubectl -n notesnook get secret notesnook-secret -o jsonpath='{.data.NOTESNOOK_API_SECRET}' | base64 -d | wc -c # >0 +git status --porcelain apps/notesnook/notesnook-secret.yaml # expect NO output (ignored) +``` +Expected: secret exists with non-zero API secret; `git status` shows nothing (file ignored). + +- [ ] **Step 3: Restart any already-running deployments so they pick up the secret** + +```bash +kubectl -n notesnook rollout restart deploy +kubectl -n notesnook rollout status deploy --timeout=180s +``` +Expected: all deployments Ready. + +*(No commit — the real secret is never committed.)* + +--- + +### Task 10: Migrate data (Mongo + MinIO) — dry run before cutover + +**Goal:** Copy `identity` + `notesnook` DBs and the `attachments` bucket from the old stack into k8s. Run once as a rehearsal now (safe — additive), and again as the final sync during cutover (Task 11). + +**Interfaces:** +- Consumes: old stack Mongo (`notesnook-db` container on `10.10.0.5`) and MinIO; k8s Mongo (`notesnook-db-0`) and MinIO. + +- [ ] **Step 1: Dump + restore MongoDB (identity + notesnook)** + +```bash +# Dump from old compose Mongo to a local archive +ssh manage@10.10.0.5 'docker exec notesnook-db mongodump --archive --db=identity' > /tmp/identity.archive +ssh manage@10.10.0.5 'docker exec notesnook-db mongodump --archive --db=notesnook' > /tmp/notesnook.archive +# Restore into k8s Mongo +kubectl -n notesnook exec -i notesnook-db-0 -- mongorestore --archive --drop < /tmp/identity.archive +kubectl -n notesnook exec -i notesnook-db-0 -- mongorestore --archive --drop < /tmp/notesnook.archive +``` + +- [ ] **Step 2: Verify collection counts match** + +```bash +echo "OLD identity users:"; ssh manage@10.10.0.5 'docker exec notesnook-db mongosh identity --quiet --eval "db.getCollectionNames().length"' +echo "NEW identity colls:"; kubectl -n notesnook exec notesnook-db-0 -- mongosh identity --quiet --eval "db.getCollectionNames().length" +echo "OLD notesnook colls:"; ssh manage@10.10.0.5 'docker exec notesnook-db mongosh notesnook --quiet --eval "db.getCollectionNames().length"' +echo "NEW notesnook colls:"; kubectl -n notesnook exec notesnook-db-0 -- mongosh notesnook --quiet --eval "db.getCollectionNames().length" +``` +Expected: OLD == NEW for both DBs. + +- [ ] **Step 3: Mirror the attachments bucket** + +```bash +# Port-forward the k8s MinIO API locally, then mirror old -> new with mc. +kubectl -n notesnook port-forward svc/notesnook-s3 9000:9000 & # PF_PID=$! +PF_PID=$!; sleep 3 +# Old minio creds = MINIO_ROOT_USER/PASSWORD (same on both sides after Task 9) +mc alias set oldminio "http://10.10.0.5:9000" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" 2>/dev/null || \ + ssh -f -N -L 9001:notesnook-s3:9000 manage@10.10.0.5 # if old minio not directly reachable, tunnel to 9001 and use that +mc alias set newminio "http://127.0.0.1:9000" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" +mc mirror --overwrite oldminio/attachments newminio/attachments +kill $PF_PID +``` +Note: the old MinIO API isn't publicly exposed (only `attachments.*` via presigned GETs); reach it via the compose network from `10.10.0.5` or an SSH tunnel as shown. + +- [ ] **Step 4: Verify object parity** + +```bash +mc du oldminio/attachments ; mc du newminio/attachments +``` +Expected: matching object count / size. + +*(No commit — data operation.)* + +--- + +### Task 11: Register with ArgoCD, merge, and cut over + +**Files:** +- Create: `argocd/notesnook.yaml` + +**Interfaces:** +- Produces: ArgoCD `Application` adopting `apps/notesnook`; DNS repoint on `10.10.0.8`. + +- [ ] **Step 1: argocd/notesnook.yaml** + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: notesnook + namespace: argocd +spec: + project: default + source: + repoURL: https://git.aleshym.co/funman300/k3s-homelab.git + targetRevision: main + path: apps/notesnook + destination: + server: https://kubernetes.default.svc + namespace: notesnook + ignoreDifferences: + - group: "" + kind: Secret + name: notesnook-secret + namespace: notesnook + jsonPointers: + - /data + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +- [ ] **Step 2: Validate the full kustomize build (server dry-run)** + +```bash +kubectl apply -k apps/notesnook --dry-run=server +kustomize build apps/notesnook >/dev/null && echo "kustomize OK" +``` +Expected: no errors; `kustomize OK`. + +- [ ] **Step 3: Pre-cutover TLS + endpoint check via /etc/hosts override** + +```bash +# Temporarily map the hosts to 10.10.0.100 locally to test before flipping real DNS +for h in auth sync events monograph attachments; do echo "10.10.0.100 $h.notes.aleshym.co"; done | sudo tee -a /etc/hosts +curl -sS -o /dev/null -w "auth %{http_code}\n" https://auth.notes.aleshym.co/health +curl -sS -o /dev/null -w "sync %{http_code}\n" https://sync.notes.aleshym.co/health +# (cert-manager issues certs via Cloudflare DNS-01 regardless of A records) +kubectl -n notesnook get certificate +``` +Expected: HTTP 200; all `Certificate` resources `READY=True`. Remove the temporary `/etc/hosts` lines afterward. + +- [ ] **Step 4: Push branch, open PR, merge to main (ArgoCD adopts running resources)** + +```bash +git push -u origin feat/notesnook-k8s +# open PR in Gitea, review, merge to main +# ArgoCD syncs apps/notesnook; since resources already exist identically, expect Synced/Healthy with no churn +kubectl -n argocd wait --for=jsonpath='{.status.sync.status}'=Synced application/notesnook --timeout=180s +``` +Expected: Application `Synced` + `Healthy`. + +- [ ] **Step 5: Freeze writes on old stack + final data sync** + +```bash +# Stop only the app services on the old stack (keep db + minio for the final dump) +ssh manage@10.10.0.5 'cd ~/notesnook && docker compose stop identity-server notesnook-server sse-server monograph-server' +# Re-run Task 10 Steps 1 & 3 (mongorestore --drop + mc mirror --overwrite) for the final delta +``` +Expected: final restore/mirror complete with matching counts. + +- [ ] **Step 6: DNS cutover on 10.10.0.8** + +``` +# On the internal DNS server (10.10.0.8 — AdGuard/Pi-hole/unbound), repoint these +# A records from 10.10.0.5 to 10.10.0.100: +# auth.notes.aleshym.co, sync.notes.aleshym.co, events.notes.aleshym.co, +# monograph.notes.aleshym.co, attachments.notes.aleshym.co, notes.aleshym.co +# Then flush caches. +``` +Verify from a client: +```bash +for h in auth sync events monograph attachments; do printf "%-12s " "$h"; getent hosts $h.notes.aleshym.co | awk '{print $1}'; done +``` +Expected: all → `10.10.0.100`. + +- [ ] **Step 7: End-to-end validation** + +- Request a login code in the Notesnook client → email arrives (Gmail SMTP). +- Log into the **existing** account (no re-signup). +- Confirm pre-existing notes appear after sync. +- Open a note with an attachment → downloads via `attachments.notes.aleshym.co`. +- Open a monograph public link → renders. + +- [ ] **Step 8: Stop the old stack (keep data for rollback)** + +```bash +ssh manage@10.10.0.5 'cd ~/notesnook && docker compose down' # NO -v; data retained ~1 week +``` +Expected: containers removed, `dbdata`/`s3data` volumes retained. + +--- + +### Task 12: Decommission (≈1 week after stable cutover) + +- [ ] **Step 1: Confirm k8s stack stable for ~1 week** (no sync errors, backups if any in place). + +- [ ] **Step 2: Remove old stack + data** + +```bash +ssh manage@10.10.0.5 'cd ~/notesnook && docker compose down -v && cd ~ && rm -rf ~/notesnook' +``` +Expected: volumes and compose dir removed. + +- [ ] **Step 3: Update memory** — edit `notesnook-selfhost-smtp` memory to note the app now runs in k8s (`apps/notesnook`, ns `notesnook`), SMTP config lives in the `notesnook-secret` Secret, and DNS points at `10.10.0.100`. + +--- + +## Self-Review notes + +- **Spec coverage:** every spec component (Mongo rs0, MinIO+bucket, 4 services, 5 ingresses, ConfigMap/Secret split, data migration, DNS cutover on 10.10.0.8, keep-then-remove old stack, monograph included) maps to Tasks 1–12. ✓ +- **Secret handling:** `.example` committed, real gitignored, ArgoCD `ignoreDifferences` on Secret /data (Task 11). ✓ +- **Type/name consistency:** service names (`notesnook-db/-s3/-identity/-sync/-sse/-monograph`), ConfigMap `notesnook-config`, Secret `notesnook-secret`, TLS secrets `notesnook--tls` used consistently across tasks. ✓ +- **Ordering caveat:** the real Secret (Task 9) must exist before identity/sync/sse/monograph pods become Ready; execute Task 9 right after Task 3 if running strictly in order, or `rollout restart` after. +``` diff --git a/docs/superpowers/specs/2026-07-15-notesnook-k8s-migration-design.md b/docs/superpowers/specs/2026-07-15-notesnook-k8s-migration-design.md new file mode 100644 index 0000000..8a03493 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-notesnook-k8s-migration-design.md @@ -0,0 +1,222 @@ +# Notesnook self-host → k3s migration — design + +**Date:** 2026-07-15 +**Status:** Approved design, pending spec review → implementation plan + +## Goal + +Move the self-hosted Notesnook stack from the docker-compose deployment on host +`10.10.0.5` (LXC on Proxmox `pve`) into the k3s cluster (CT 114, node IP +`10.10.0.100`), managed by ArgoCD via the `k3s-homelab` GitOps repo. The existing +account, notes, encryption keys, and attachments must be preserved. + +## Current state (source) + +- **Host:** `manage@10.10.0.5`, docker-compose stack in `~/notesnook`. +- **Services:** MongoDB 7.0.12 (replica set `rs0`), MinIO (S3), identity/auth, + sync, sse/events, monograph, plus bootstrap helpers (`validate`, `setup-s3`) + and `autoheal`. +- **Public hosts** (Traefik on the compose box, cert via Cloudflare resolver): + `auth`, `sync` (+ apex `notes`), `events`, `monograph`, `attachments`, + `minio` — all under `notes.aleshym.co`. +- **Data lives in:** Mongo databases `identity` and `notesnook`; MinIO bucket + `attachments`. +- **Root cause history:** login-code email was failing because SMTP was left at + `CHANGE_ME`; now fixed to Gmail SMTP. That corrected config carries into the + migration. + +## Target cluster conventions (observed) + +- **GitOps:** ArgoCD (no app-of-apps). Monorepo + `https://git.aleshym.co/funman300/k3s-homelab.git`, branch `main`. App + manifests under `apps//` (kustomize); the `Application` CR committed at + `argocd/.yaml` with `automated {prune, selfHeal}` + `CreateNamespace=true`. +- **Ingress:** Traefik (`ingressClassName: traefik`), entrypoint `websecure`. +- **TLS:** cert-manager clusterissuer `letsencrypt-prod` via **Cloudflare DNS-01**. + Per-host TLS secret named `-tls`. +- **Storage:** single-node `local-path` (default), `ReadWriteOnce`, `Delete` + reclaim. **Implication:** deleting a PVC deletes its data — treat Mongo/MinIO + PVCs as precious. +- **Secrets:** no sealed-secrets / external-secrets controller. Convention is a + committed `*-secret.yaml.example`; the real `*-secret.yaml` is **gitignored** + (`apps/**/*-secret.yaml`) and applied once with `kubectl apply`. The + `Application` CR uses `ignoreDifferences` on the Secret's `/data` so ArgoCD + won't fight the out-of-band secret. +- **Internal DNS:** resolver `10.10.0.8` holds split-horizon `*.aleshym.co` → LAN + IP records (`ns.aleshym.co → 10.10.0.100`, `auth.notes.aleshym.co → 10.10.0.5` + today). This is where the cutover repoint happens. Public authority for + `aleshym.co` is Cloudflare (used only for the ACME DNS-01 challenge). + +## Architecture + +Namespace: `notesnook`. Repo path: `apps/notesnook/`. ArgoCD app: +`argocd/notesnook.yaml`. + +| Component | Image | k8s workload | Service (in-cluster) | Public ingress host → port | +|---|---|---|---|---| +| MongoDB `rs0` | `mongo:7.0.12` | StatefulSet (1) + headless Service + init Job | `notesnook-db:27017` | none | +| MinIO | `minio/minio:RELEASE.2024-07-29T22-14-52Z` | Deployment + PVC | `notesnook-s3:9000` | `attachments.notes.aleshym.co` → 9000 | +| Identity/auth | `streetwriters/identity:latest` | Deployment | `notesnook-identity:8264` | `auth.notes.aleshym.co` → 8264 | +| Sync | `streetwriters/notesnook-sync:latest` | Deployment | `notesnook-sync:5264` | `sync.notes.aleshym.co` + `notes.aleshym.co` → 5264 | +| SSE/events | `streetwriters/sse:latest` | Deployment | `notesnook-sse:7264` | `events.notes.aleshym.co` → 7264 | +| Monograph | `streetwriters/monograph:latest` | Deployment | `notesnook-monograph:3000` | `monograph.notes.aleshym.co` → 3000 | + +**Dropped from compose:** `autoheal` (replaced by k8s liveness/readiness probes), +`validate` (env presence enforced by manifests), `setup-s3` (folded into a MinIO +bucket-create init Job / or `mc` init container). + +### MongoDB replica set (the one real deviation from the nightscout template) + +Notesnook's connection strings use `?replSet=rs0` and the sync server relies on +change streams/transactions, which require a replica set. Plan: + +- **StatefulSet** (1 replica) `notesnook-db`, `command: [mongod, --replSet, rs0, --bind_ip_all]`. +- **Headless Service** `notesnook-db` for stable DNS. +- **One-time init Job** (or `postStart`) runs + `rs.initiate({_id:"rs0", members:[{_id:0, host:"notesnook-db:27017"}]})`, + idempotent (skip if already initialized). The RS member host must be the stable + Service name `notesnook-db:27017` so clients using `?replSet=rs0` resolve the + primary correctly. +- No Mongo auth (matches current compose: `mongodb://notesnook-db:27017`, no + credentials). Access is namespace-internal only. + +### Config vs secrets + +**ConfigMap `notesnook-config`** (committed, non-secret): + +``` +INSTANCE_NAME=aleshym-notesnook +DISABLE_SIGNUPS=true +NOTESNOOK_APP_PUBLIC_URL=https://app.notesnook.com +AUTH_SERVER_PUBLIC_URL=https://auth.notes.aleshym.co +MONOGRAPH_PUBLIC_URL=https://monograph.notes.aleshym.co +ATTACHMENTS_SERVER_PUBLIC_URL=https://attachments.notes.aleshym.co +NOTESNOOK_CORS_ORIGINS=https://app.notesnook.com,https://notes.aleshym.co +KNOWN_PROXIES= # may need the Traefik pod CIDR; see open items +# service-discovery (in-cluster names replace compose hostnames) +NOTESNOOK_SERVER_HOST=notesnook-sync +NOTESNOOK_SERVER_PORT=5264 +IDENTITY_SERVER_HOST=notesnook-identity +IDENTITY_SERVER_PORT=8264 +SSE_SERVER_HOST=notesnook-sse +SSE_SERVER_PORT=7264 +IDENTITY_SERVER_URL=https://auth.notes.aleshym.co +NOTESNOOK_APP_HOST=https://app.notesnook.com +# S3/MinIO non-secret +S3_INTERNAL_SERVICE_URL=http://notesnook-s3:9000 +S3_INTERNAL_BUCKET_NAME=attachments +S3_SERVICE_URL=https://attachments.notes.aleshym.co +S3_BUCKET_NAME=attachments +S3_REGION=us-east-1 +MONGODB_CONNECTION_STRING (identity)=mongodb://notesnook-db:27017/identity?replSet=rs0 +MONGODB_CONNECTION_STRING (sync)=mongodb://notesnook-db:27017/?replSet=rs0 +``` + +**Secret `notesnook-secret`** (out-of-band, `*.example` committed, real gitignored): + +``` +NOTESNOOK_API_SECRET=... # reuse existing value from old .env +SMTP_USERNAME=funman300@gmail.com +SMTP_PASSWORD=... # Gmail app password +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +MINIO_ROOT_USER=notesnook +MINIO_ROOT_PASSWORD=... # reuse existing value +S3_ACCESS_KEY_ID=notesnook # = MINIO_ROOT_USER +S3_ACCESS_KEY=... # = MINIO_ROOT_PASSWORD +# TWILIO_* : present in old .env but SMS 2FA unused; omit unless in use +``` + +Reusing `NOTESNOOK_API_SECRET`, MinIO creds, and the Mongo data verbatim is what +keeps the migrated account valid — do **not** regenerate the API secret. + +## Repo layout to add + +``` +apps/notesnook/ + kustomization.yaml + namespace.yaml + notesnook-config.yaml # ConfigMap + notesnook-secret.yaml.example # secret template (real applied out-of-band) + db-statefulset.yaml # mongo rs0 + db-service.yaml # headless + db-init-job.yaml # rs.initiate (idempotent) + minio-pvc.yaml + minio-deployment.yaml + minio-service.yaml + minio-bucket-job.yaml # create 'attachments' bucket + identity-deployment.yaml + identity-service.yaml + identity-ingress.yaml + sync-deployment.yaml + sync-service.yaml + sync-ingress.yaml + sse-deployment.yaml + sse-service.yaml + sse-ingress.yaml + monograph-deployment.yaml+ monograph-service.yaml+ monograph-ingress.yaml + attachments-ingress.yaml # attachments.notes.aleshym.co -> notesnook-s3:9000 +argocd/notesnook.yaml +``` + +`.gitignore` already covers `apps/**/*-secret.yaml`. + +## Data migration procedure + +Runs during the cutover window; both stacks reachable from the migration host. + +1. **Mongo:** `mongodump` the `identity` and `notesnook` DBs from the compose + Mongo (`docker exec notesnook-db mongodump --archive`), stream to the k8s Mongo + via `kubectl exec` + `mongorestore --archive --nsInclude 'identity.*' + --nsInclude 'notesnook.*'`. Verify collection counts match on both sides. +2. **MinIO:** `mc mirror` the `attachments` bucket from the compose MinIO to the + k8s MinIO (mirror via a temporary `mc` pod/job with both aliases, or + port-forward). Verify object count/size. +3. **Sanity:** confirm identity DB user document and notesnook sync docs present. + +## Cutover procedure + +1. Deploy the empty k8s stack (commit → ArgoCD sync). Verify all pods healthy, + ingress created, **TLS certs issued** for all 5 hosts (temporary test hostnames + or `/etc/hosts` override so we can validate before flipping real DNS). +2. Announce brief downtime. Stop client sync / freeze writes on old stack + (`docker compose stop` the app services, keep DB+MinIO up for the dump). +3. Run the data migration (above). +4. **DNS cutover on `10.10.0.8`:** repoint + `auth`/`sync`/`events`/`monograph`/`attachments`.notes.aleshym.co **and** apex + `notes.aleshym.co` from `10.10.0.5` → `10.10.0.100`. Lower TTL beforehand if + possible; flush caches after. +5. Verify end-to-end against k8s: request a login code (email arrives), log in, + sync a note, open an attachment, load a monograph link. +6. `docker compose down` the old stack (data retained) — rollback = flip DNS back + and `docker compose up -d`. + +## Rollback + +Until decommission, rollback is: repoint the 6 records on `10.10.0.8` back to +`10.10.0.5` and `docker compose up -d` on the old host. Because writes were frozen +during cutover, no data diverges. + +## Decommission (≈1 week after stable cutover) + +Remove the compose stack and its data on `10.10.0.5` +(`docker compose down -v`, remove `~/notesnook`), and reclaim the LXC if no longer +needed. Update memory notes to point at the k8s deployment. + +## Verification / success criteria + +- All Notesnook pods `Running`/healthy; Mongo RS `PRIMARY`; MinIO bucket present. +- All 5 ingress hosts serve valid Let's Encrypt certs. +- Existing account logs in (email code delivered) — **no re-signup**. +- Pre-existing notes + attachments visible in the client after sync. +- Monograph public link resolves. + +## Open items / access still needed + +- **Git push access** to `git.aleshym.co/funman300/k3s-homelab` (clone was + anonymous/read-only). Decide branch vs PR workflow. +- **Edit access to internal DNS `10.10.0.8`** for the cutover (mechanism/creds TBD + — AdGuard/Pi-hole/unbound?). +- **`KNOWN_PROXIES`:** old value empty; behind Traefik the sync/identity servers + may need the Traefik pod source CIDR here for correct client-IP/proxy handling. + Confirm during implementation. +- **Public access path:** are the `notes.*` hosts reachable externally (Cloudflare + tunnel / netbird), or LAN/VPN-only? Affects whether any Cloudflare records also + need updating, or just internal DNS. +- **Twilio SMS 2FA:** present in old `.env`; assumed unused (email 2FA works). Omit + unless confirmed in use. +``` From 07654cd135b25521ec0408ee00e6380da5371106 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Jul 2026 09:21:35 -0700 Subject: [PATCH 2/4] feat(notesnook): manage secret via SealedSecret Secrets are now committed encrypted (sealed-secrets v0.38.4) instead of hand-applied out-of-band, closing the config-DR gap the backups intentionally don't cover. Existing Secret adopted via the managed annotation (values verified unchanged). Controller private key backed up GPG-encrypted off-cluster. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/notesnook/kustomization.yaml | 1 + apps/notesnook/notesnook-sealedsecret.yaml | 24 +++++++++++ apps/notesnook/notesnook-secret.yaml.example | 44 +++++++++++--------- argocd/notesnook.yaml | 7 ---- 4 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 apps/notesnook/notesnook-sealedsecret.yaml diff --git a/apps/notesnook/kustomization.yaml b/apps/notesnook/kustomization.yaml index a33ee61..41354d9 100644 --- a/apps/notesnook/kustomization.yaml +++ b/apps/notesnook/kustomization.yaml @@ -4,6 +4,7 @@ namespace: notesnook resources: - namespace.yaml - notesnook-config.yaml + - notesnook-sealedsecret.yaml - db-service.yaml - db-statefulset.yaml - db-init-job.yaml diff --git a/apps/notesnook/notesnook-sealedsecret.yaml b/apps/notesnook/notesnook-sealedsecret.yaml new file mode 100644 index 0000000..7d4a030 --- /dev/null +++ b/apps/notesnook/notesnook-sealedsecret.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: notesnook-secret + namespace: notesnook +spec: + encryptedData: + MINIO_ROOT_PASSWORD: AgDAj0bx9aNQHIXAcNB3YfA4+P5qIhVNwEDBDDTEcJ9OQ3qrmTrZ0HtYIWswMU1CYuCGzfCT9sdoJ94L4VFWCdqavHdPn3/zDtXXR7WAaMOoYygiKIPTVO1rmRNnk0gzirl1aWSm+fWsc61+SYCvx08C/RnSyTeocfhpY0rvm0r9eiIxmdjc0u2VKksh1ARSkSqJkLniPXBH6PCFH/tUOH3FZqYycCW+FEhj8KE9D/FIVKy3pCFhfg9KfH7XHqQx5wnQ0j2aL9ljqA6DPI6tDB5rcR8lW7+3iC8X3YZwaExFfWq+3ksZj5LLnESBlqwvV1IY8B7k1VoiL8f5r/5VAE/EJ6SFvIueoOnKxUWC+NJroWeBSnk0isDpNPluVkrGJOVTHv0bx3bAhUVjr3lQsimvyi1ZZXKvr/b/Hpg++RLTF3wBGLjM7tsJ0RHnzysvIyfg8euU2OYitjfkQKbvDIREkU5A0AQ35oapgNnVAB2gWlm4SAkkfOQuT/oowTOLEYKg4zzk64lQiOWFtD5q82Dr6PU6s/snIDixkY/5ICgcV5/Y1Oq0wtvhblDBroWpEnohoQAqlYkRNN4jh+oSEJwJORv6mF370XxBxY9k3h/IyCdJeC9xv9DocUjUP+FU9HQE7Qvd3OcNag1DAyhmtLv5wDgZEea3fPX5XMhnvE5U+YUxZDXkN1MchItTTMMim5fySIPg5KZ+Hr2Ticpxfjw47RMFNH3y2qfDqb8hBfB9SA== + MINIO_ROOT_USER: AgAyLNT8gHmThqLjQBBcmoZsxtEG/ZR/hjUICEZKAyF8+FV6A3WFizA8tYBzjNyfaGFSdS2yLmZGbwtEKmHSApBmXoY4xgnyZpEjh2whsgeBjMFdx2jWt+odvEaPGA+xEbuODJbkJMQiXhi/ctyiMD1/y6WIWZ5Ya8Ahz469di0fT1znMk9B050FKFgVEK6Sfzb8MQuZBj2wukTgZ2rlKMQ+7EQi6pb6cEATLDjheXvbgx4g9LTPwVN+1uZcC5GU7jg/IGJcEg/QJfmOCgRmz9iDx3/d9lFnDnJBOv8Plt6PmWEMR9QOvdQjUhWiN2BwrdICHqFxFTnV+z9JbMk2kT2Ovw6W239BT+KbabEaiwVE5+3ubLLymF3bXwRO7fqPO3xy6/aVsv6k9995jPY7/DNIr5kozo/uFf2DeP8sbldJp2ymR2RBoa8TCiGHT5pgWr36qHxMt1J7fU1mWIRPeUmkFJchCjNc8qDr1bwY9hjrdXxC5fUkFsG/gyb5FsmzvQaAPAy45Wl1DRpp4xBiE/SUc+xBcA1y3Bo6lJ2w7w8tB2bMZUqZkgOyPDaPfzLViMtPUlnCnKRGzvi8HoGvzLS4gN1jjRUK33YtLpLqa6B9Wj1/fkgu9H1cIz1ffS3baL4ZIyA3K7/4LrAS2xzUcJ8HdezxjuZQKqtwDgJSysZJ0blXG6GLpx8JonZh4gx8na1c9kKt07BNYao= + NOTESNOOK_API_SECRET: AgC3JoH/PCMgUPPvUCkIsRWGEIJtwt+E+wXvfBTi2V60nN19RiJLpXF860UrKnFZxvloFalJLIxA1mtLUAfO2wZuzGpid8IIrAUIyhXCZkZWkZr9UbB/TjzmorC5NEAwjo08zhBKIlME+Emdb7BrpHGbj2ykQlyKqwCSTPKoitkypdgXQsbPWM6j03o/F9hSmKVFCTb6ugBY72rXBlVBqBGdeoPLkwSnuk7kk6y+lOfufeqGw2MXoNCZJ1+rbZQ2ihGFSGLKd955I3wnidcu8oCQO50u4V1IHhj0/iJHh/bXu6ipXEw4ZX886SFQgqqb36sFt8b2PIk+h2GtqFC+Ixew+ay790B/wXIMWhze680oAwq/6ZvWe0PSbJgDXLCYga7ZIw8RYFphk3+VblYfc9EObGWvuLDoGzSs/qgi/+euxOkM1P8lGUGuSETz4Bm7VEg4udEneuCnfHqQ19/qkhu/isMwN7KpOaymKwpw0Yb2oefUljjPOGHq+ni08TUU1V+fmKMmnxG3Uq05mLDjTpN6lkpm5yF5xCA4WRflL1kqLFsCPm4JF62NiLDXuVb7c0uz9l85YaJg06RaYVHOf5Km7B3wnBHHrk8sJ2NtkUa2vvGbkZ64JFP9PrD7Ubvh62i4QoXn83nv67rihtzB8BNzkA7sQVLH2OpBqFkbJaTquBWXo3zg8AWcxpuXvNXv6ba4kZJEbBbCDpQDUdQ//MSJttHEgPKvZgmqtqCQ7W3Mypr6CWWGhSfcEmgAzcSfYlYNAbGAGxFErDkXelhbsgNr + S3_ACCESS_KEY: AgC9kcAVZEpanlIjfkpJjDDJe6nikUTorye4l03NhgOoMrU6ivxVXBboiXAYyRYYRidp60r041YPs5lZNCYOwwpuf5iquAlAuD5mecR2xFJIsv3Mxi5wEdoFvPtCIFv8Xm0MTMvR1ch4J4X1gds4S3HQmYIxEKSg/qU+/4ww1CccbDexGOhwOwMMor2KZwfGTrDIZO7/3SwMQs6h8XVRUR5BhuLGftV6tUFEOQBQ6pjijurkSW/faG6YJbE2hpmWNdot6Qau7matCcJhRZI0NAeZhAPeCvIlyrEyJpqjMxNEMVJszj//F7dlPxTW8AkWyw2bmzzgNPhAYvpRTxrDDr70huRUWIo5mAlBbJypT3haVjKYFoRKAGXWC/8dhLSp3NKWQ/smBzpvUOQ30N4EskIVT+V8GArkrLN35yi2yP1aFaQpvLCA1m8pp5hUTgyDm1dqcOnQg3DRJgiMN7/Jt+P9TQGs2RiKoHCPp6lIfDGRKDLYeG5uuiUouridf1HnkrKGXlSmnd4xYWYlym6ZAC7Uzd9GlwTeZwZT8YJCQHqnZFPGq6MAVOPR5B+kQldAH81qosXjRdQyn8RT3DefdAjbduh4/DzuLJTTu8OeSmyPx7H5uqDIrkn1DLuBca2+lJPu4xMv6bthGRzwBLA1aU+nmniFBznkg+YlI5XxJLrA5Ra/PZsglhCHRz1hSiajZ5g1jUzMk2XDjti/q3Xbqj1AkmLmKjZbR05e7VRELMIj4g== + S3_ACCESS_KEY_ID: AgAjLq/HHmD30h2/2mHU/iQKS6l4PRTRhquB4UgCdJjkr/a4eGfxxPZWRJJrj/AbFaWQrAq/ADqa9cAxwXsJInwVLkmv42sB9hODGxRtKbQgwubJaa84uOqdNvce/GvlxXrVIh5H7GUTTKuIXSuS5U/AtBniPudJ5/p8QulYXyqY3K34uePIMEqBP5m7CJ8kKB7EPTLvb29pjr/VdKCpSMwnhXiiiwU/Y1NDdf4ZiIc1gsp79kRd1RhFsLfJky/tBntv4waHHfYKZ7jkCJOGp189ZSqNFlAkP2TPg+jpygpznPvTTggQB45DlYKupUI1bMGlkWcfYLM9+/jmvHngGuzPvSUlGFukaPHkhJ8YwLz+Jc+K6xxdbX9WjzqqGYs3CW41ccxHjxgrZnUW62NbCYIR7xj1K2vCVmmyjwC++N9ArDvS/VreXAq2QJsxKQjL7YElsQrFlMzKrRw98kUd4vpQPQxK+nkLe9HVj0VJprDK22ZrfCcyA8rl64Zx8huwbKEmNscZou49oDx3LJRI8vGFS0lQJFLBrjJKjF9xT+7R59SIo+7JxFAWDvmEaJrdPSq4EDEturHhzvKcv5y1Gy1ul4nwIGvxDZAK9kwp5oiGxfARncoVzgFINqwlVmVfPRb4RZl5mzu8IToMebwvJ52Rpf+IhkW1yEnCYxppBNmUyPWewTc3UUvtViOOawaARaXii6O9sR0uhlE= + SMTP_HOST: AgBZiWzF45lbMOrRXlGi2k1jq2bA+hQWF1DVLJemKgn9et0uBfcIyICHS/7E4cukDI+MOInEQEYem5M4FoQ2mM2ylcIm+LrUI+p5azDKRYkkbcwKD2F0+J9G5YDXxYth11NzS3fLhrVxAuqmbqy3LxOmSkjFvFdJxLx0Su78B2CSDBXmy+gu4soY7bdQV0L3T/zfG6s1WuLzR8WPnji3EmBM5dexG7Smhziz0dymOuhdGfSwaI2MWrYzw9J3Vshc+mRXzw1JvI5mVbawhQOLrnW57NvXlJtk19LLr/kXWOt1ppJcJOYGOxWrFs4VA4Bg4QSN2piARWZ6FbL1/a8ynamUhw+R2yDlT/J8oQIVBzhIWCU5EwfHgDu+QRKzmd2iAhfYZjrUkJNojUjqaQvb7RXrG4DVL9UJxGjushebgc2EG/PykdzoRL2d1KMfJRK6O5ouM42UvOCqRzbBbWuTiA6fj8hGTIZYFXX3vWIM368uDvrEVxj2C+skrRzHEzAQgsJEaWtFvZm6WGkZZOLTuava9+JxMvnpftWMIE8FrYCAYwn5xa88eftgw+GW65EWVSm8IXFtGoNd4DKYj5OGF2DcN//OxteoVd3OxsmuJtY0Qx0REb9nqg9Qdw2bP6emmpmgtyw8opUS/VubGghdCnhLUES+CA08ooK4a8n8rZSSFiujvjMjoDdOnVktM/D3R6ECftJRakhMes4INANM4w== + SMTP_PASSWORD: AgAdjxnT2uC0LRUmlQ/BOlLs6l1PCP13tMmgmSnqqNWSuucYkWwTWtz4qBnG3dauhC0ufS5eqABJqZq0r+QpyhxU0Otqzk7oVcea0Mq9ai159IMCx/rZL8HXIkUF76muv8GOGDYlaf3A+CbrNTsaRXDbjZ7LjzD8OwsdfaeIzUejyNXgs5EMjSOHLx4YhmVfVEZLAtoP9zcNlDSNFaat79wnwjwUifHuY3usvQXhJS+vLVtLLtWwKF0rCsfkbCRwJtJCCrOvOLuid8aZ3cO9Y7n0+TOAlkSe4dSSq0D2psg3njEZ2l3RNLW2YbM2LkcXK+MjIpCivqSCXLufq1dUuhDxCirxSd0VgTt5TPgeTCi+kuNv/UAqydQWDQlh6izUDdnTtMlRqtk4ZFhoBvIo1NVdpObmbFlSlBURwrmNqmdyO1/MNZT40gi1UsuEtktDCohoq6zQrahiGqcXr1VVscE+u5VoV7PRTICMSJQexFV9fx8YsZxDycng7zaf6xbK0JWVSuU1IXwb1ZOReQs0MtZj45o/c9JtYS1HPoSZRCCSCAd6qPhnHoUc6jYYFznfKTqvrpSdXqoBF61I1Sw+CKuVA/aut9mgCzWIwGjb38Hjh/VYufrgkFMcvnqbpkX4F8sMvzI3zsvFT2S0RwNGHOay6f+otg2KJdPlE2HSLVGLTFxS1Ku2F3ZPL79/Br8eCW3PPnq4c6w4f0XBFtuGSXtt + SMTP_PORT: AgATegLz9D+74mUBak9tv+7L/LeRqcJXcE58T1fOh1shsz3KjSzuXb4HxytqghT0LqplAEemCPoG823wPFKYnxZtvedQ2CyZGCxEXPoV6iUvBAVC3Pj6H92k9CIEZRPHLvR6IR+mXzu6HLWwJsOGrxn28waSx374uVizylptkpjIHvyseY3chfH0N/x2tK0yZn9rKOqnzfjKVGh4DF+i7wU/uuFPhcMbqRPg70J0pxhesTKHBAerGnvfnoCxFRoSl0ZPgC0kK5NfIEp3zWW45NWshPNsbb0yix76Rnr+8dEn/Rbopa8jZtfdK260ZPclLRO6z04ks69Zp5u9kE1g883Q25I+M9C8B27QDC+KHxgSwq0w8a5CwlCYDRu/e9UBHtsJobRaARgghyyAVsS4FsDS45kJom3U7n0Ip/27hg2VMm+jwNRoB5cmlyhDDlFng49U36DRsd0z2d2wBFAG4NM4xgbWlK5zUDmKCveC7zJfFAuZGsMbY5U8/lDv1WvGIG8xmQsg5CgriVL6gG3ReRjk3BmK93b8OkUhfquFHKBoyqJZWIIz+W5dcP2fKdjZc0Ec2vPqszHaNTN+dEcGzm9MGFpeus6rA6Egxzq1esVbEPuixcI6eXc87SOG0Ntn2wbPJQKpNZq+JkzYSXBUZcemPz5M2nybLr/lwuX7//Qg5b95nwzXMRRm9kagwWubDPNPpd8= + SMTP_USERNAME: AgAxdxvMjUjvj8vYu8upKMfpAIi00dBizXD83F5f6TCSlAFlo3kuVFbfJ8tCQZNRSdU7vP6n3zQfW2IhYUQSnO44+Wb8q5V4sfs1cClbPyGpQdEyFeLk1KwW3LeFxsi9cym3H7Qtu9e/S3dOmD9IxK8AuOJkq5t7Xepum7WvuOuvBVFm2nVetZlTs1BmHoxA8dHlAhk+qwGMAuo/Xltut67uDsPzZTcA4fEAYe+We7zqfD0hpN+k1TffZHE7x+GpA3tyc6yUf/uSsWRq9HKyASc0UM5uoK/WoxYoUA7RF5j5FXFrA1jY/Y1pE3oLjQmpB59q5vOasd77uXE/1b+rs6l8L1zngWw+8MeKA/julrmWBwte+vs68yqDFOL7QNfwpYmYoaAcuvezeXZwF4nnwzXyE8ZEO7xtbp/r9DATeGypjRnm1bDAMTARGLgHoxV18UELCiksNNCbVO7twXJs+HUE/sbfI3tQmk6O5zbWD/GbcR0lTsTjM+hcM5r9ZYfrq7Ad3Oyb3ANU184Rm4fiR9v+t9OyJf9jIASF9p8oFcTr2dupdv3r8wzPq/ayfvN44nyOr2xVfIv+ubMqjjLixg0jUDeFoR/GST6fpvWBmd2HVMG1tHvZxphUb0V/M946HxKBMpsFn/WWyLgUzJG64bIICDAetCferbaa4UFEHP23G/R5tLBWM2GNZpT6yfkBQYppc6e1/KAjGNVMrTpKQeNkD/xs + template: + metadata: + annotations: + sealedsecrets.bitnami.com/managed: "true" + name: notesnook-secret + namespace: notesnook + type: Opaque diff --git a/apps/notesnook/notesnook-secret.yaml.example b/apps/notesnook/notesnook-secret.yaml.example index e492e11..847d80d 100644 --- a/apps/notesnook/notesnook-secret.yaml.example +++ b/apps/notesnook/notesnook-secret.yaml.example @@ -1,19 +1,25 @@ -# DO NOT COMMIT THE REAL VERSION. Copy to notesnook-secret.yaml (gitignored), -# fill real values from the old stack's .env, then: -# kubectl apply -f apps/notesnook/notesnook-secret.yaml -# ArgoCD ignores this Secret's /data (see argocd/notesnook.yaml). -apiVersion: v1 -kind: Secret -metadata: - name: notesnook-secret - namespace: notesnook -stringData: - NOTESNOOK_API_SECRET: "CHANGE_ME" - SMTP_USERNAME: "funman300@gmail.com" - SMTP_PASSWORD: "CHANGE_ME" - SMTP_HOST: "smtp.gmail.com" - SMTP_PORT: "587" - MINIO_ROOT_USER: "notesnook" - MINIO_ROOT_PASSWORD: "CHANGE_ME" - S3_ACCESS_KEY_ID: "notesnook" - S3_ACCESS_KEY: "CHANGE_ME" +# NOTE: This app's Secret is now managed via SealedSecrets (notesnook-sealedsecret.yaml), +# which IS committed (encrypted). You should not need to hand-apply a plain Secret. +# +# This file remains only as documentation of the required keys. +# +# To change a secret value: +# 1. Build a plain Secret locally (NEVER commit it): +# kubectl -n notesnook create secret generic notesnook-secret \ +# --from-literal=KEY=value ... --dry-run=client -o yaml > /tmp/s.yaml +# 2. Seal it: +# kubeseal --format yaml --scope strict < /tmp/s.yaml \ +# > apps/notesnook/notesnook-sealedsecret.yaml +# 3. Commit the SealedSecret; ArgoCD applies it, controller unseals it. +# +# DISASTER RECOVERY: the controller's private key is backed up GPG-encrypted at +# /mnt/bulk/backups/sealed-secrets/master-key-YYYYMMDD.gpg (passphrase in password manager) +# Restore it BEFORE applying SealedSecrets to a rebuilt cluster: +# gpg -d master-key-YYYYMMDD.gpg | kubectl apply -f - +# kubectl -n kube-system rollout restart deploy/sealed-secrets-controller +# +# Required keys: +# NOTESNOOK_API_SECRET (reuse existing — do NOT regenerate) +# SMTP_USERNAME, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT +# MINIO_ROOT_USER, MINIO_ROOT_PASSWORD +# S3_ACCESS_KEY_ID (= MINIO_ROOT_USER), S3_ACCESS_KEY (= MINIO_ROOT_PASSWORD) diff --git a/argocd/notesnook.yaml b/argocd/notesnook.yaml index d5f03f6..e31facb 100644 --- a/argocd/notesnook.yaml +++ b/argocd/notesnook.yaml @@ -12,13 +12,6 @@ spec: destination: server: https://kubernetes.default.svc namespace: notesnook - ignoreDifferences: - - group: "" - kind: Secret - name: notesnook-secret - namespace: notesnook - jsonPointers: - - /data syncPolicy: automated: prune: true From 3a9675f9928df8fd368c2e8f95892c39324da2f9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Jul 2026 10:01:58 -0700 Subject: [PATCH 3/4] feat(secrets): seal remaining user-provided cluster secrets Converts cloudflare-api-token, argocd repo creds, nightscout, and the three solitaire secrets to SealedSecrets (adopted in place; values verified unchanged by before/after data hash). Controller-generated secrets (cert-manager webhook CA, LE account key, argocd internals) intentionally left alone. Verified post-seal: argocd repository label and dockerconfigjson type preserved, letsencrypt-prod issuer still Ready, both ArgoCD apps still Synced/Healthy. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cluster-secrets/README.md | 30 +++++++++++++++++++ .../argocd-gitea-repo-sealedsecret.yaml | 21 +++++++++++++ .../cloudflare-api-token-sealedsecret.yaml | 16 ++++++++++ ...solitaire-gitea-registry-sealedsecret.yaml | 16 ++++++++++ .../solitaire-matomo-sealedsecret.yaml | 21 +++++++++++++ .../solitaire-secrets-sealedsecret.yaml | 16 ++++++++++ apps/nightscout/kustomization.yaml | 1 + apps/nightscout/nightscout-sealedsecret.yaml | 18 +++++++++++ 8 files changed, 139 insertions(+) create mode 100644 apps/cluster-secrets/README.md create mode 100644 apps/cluster-secrets/argocd-gitea-repo-sealedsecret.yaml create mode 100644 apps/cluster-secrets/cloudflare-api-token-sealedsecret.yaml create mode 100644 apps/cluster-secrets/solitaire-gitea-registry-sealedsecret.yaml create mode 100644 apps/cluster-secrets/solitaire-matomo-sealedsecret.yaml create mode 100644 apps/cluster-secrets/solitaire-secrets-sealedsecret.yaml create mode 100644 apps/nightscout/nightscout-sealedsecret.yaml diff --git a/apps/cluster-secrets/README.md b/apps/cluster-secrets/README.md new file mode 100644 index 0000000..71920fe --- /dev/null +++ b/apps/cluster-secrets/README.md @@ -0,0 +1,30 @@ +# cluster-secrets + +SealedSecrets for credentials belonging to components **not** deployed from this +repo's ArgoCD apps (cert-manager, argocd itself, and solitaire — whose manifests +live in the Ferrous-Solitaire repo). They are committed here **encrypted**, purely so +the cluster's secrets are reproducible after a rebuild. + +No ArgoCD Application watches this directory; these are applied manually: + + kubectl apply -f apps/cluster-secrets/ + +## Disaster recovery + +These are useless without the sealed-secrets controller's private key. Restore it +FIRST, or a fresh controller will generate a new key and none of these can be decrypted: + + gpg -d /mnt/bulk/backups/sealed-secrets/master-key-YYYYMMDD.gpg | kubectl apply -f - + kubectl -n kube-system rollout restart deploy/sealed-secrets-controller + kubectl apply -f apps/cluster-secrets/ + +Passphrase for that file lives in the password manager. It is not recorded anywhere else. + +## Contents +| File | Secret | Notes | +|---|---|---| +| cloudflare-api-token-sealedsecret.yaml | cert-manager/cloudflare-api-token | DNS-01 ACME; TLS renewal depends on it | +| argocd-gitea-repo-sealedsecret.yaml | argocd/gitea-rusty-solitare | repo creds; needs label argocd.argoproj.io/secret-type=repository | +| solitaire-gitea-registry-sealedsecret.yaml | solitaire/gitea-registry | image pull secret (dockerconfigjson) | +| solitaire-matomo-sealedsecret.yaml | solitaire/matomo-secret | | +| solitaire-secrets-sealedsecret.yaml | solitaire/solitaire-secrets | | diff --git a/apps/cluster-secrets/argocd-gitea-repo-sealedsecret.yaml b/apps/cluster-secrets/argocd-gitea-repo-sealedsecret.yaml new file mode 100644 index 0000000..b8402e6 --- /dev/null +++ b/apps/cluster-secrets/argocd-gitea-repo-sealedsecret.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: gitea-rusty-solitare + namespace: argocd +spec: + encryptedData: + password: AgBhlHGmiLheVHJuHdg7nLAeriCka35zqVhi5VeugM+BT3JuMiFnZs7riC1U6BbCRwkVFZGA1ecegelKowtkwvohlegGWLIx0GJIfLKA2uuRqD9JdPc7x9x9+hPu8OqE9/vZrV4ojQ0BtD1C/XQDxHmVGn0p6doxIwoiQJMPNYceuvWiVEqMky93mX//8/DbgT/Wi8qK74OhI32oKgPwh9+AbF+kB3N+gnZHk68eETvrmQVMMHGy6JcjbknTdIThU5DI9OgUxhuPZO9kylD9A5M2cSdFVvuWb3Zp86aMO/fnLJxgPSnpqXYDJp4E1dxIpu5neq1OwVyNQwrbUMQKvjsIb9cOzhAojxoyruzzOfadYVafGUtiIdNQU4U1IQJPlSRZyFAYmOAiN+dF9ZokNtSdPEYJZijPovw06nXo+xvuO8JoK5lh4l6XbvN3ilRIdaBeJGGsjJrH0kf3C5vqD0fkBtopymZuhj74joo7MaDAXMlWaQqN/ER28vAfVcxz8mZ/673Td6a8AM4WLbL30IrJJOWrgBvrqbr0peXfXkDVgCXNDaG/PlwirhOzpc2E6kqppAZrqLvL2Seuo57pcnc79mUU79saewNeiHnv/t8lZhfXJ2Ya5UDqu3lha4mhckYb8OOSwk+GpJ8CUZqOJ1j4oruQaUtFb0y1l0lesLQL6CE38t2kyidMhSu05cg2NUqpZGH8xRLCClYjt0nHpfM3hZqp9Ka8coCQ9lVTHf0Kh88DkB8ZlXep + type: AgAPt1KdfoqkWYyAcGC7NmTow6+Emr3Uvav1vmYyGUQpiFpFTcmnpMwQBnrZ8hTLBvXNVgQvhgkl9iGHUr3CESd/nFkd68hDg5PN3mAL3fBn8Ng0e3ctD1zWYQSd2v1MqhIARRM3vOutAx2n2lpSJkEhwHGIadG+fdW1u/apqMuiQIAU9V15/rilY/riEJdZOZm+cRUWcdSHsR0lbkQ3wQWVoH9GT8yl+IUAhDjoZad+DuLCjvlC8oOWXtToGuKkcFgVVHow+3aXY49oXm8mBqdj2y+/dcBPhuw3RTKlVNYn4OR7AqOtmWvh88gghKXDSoS12QADyggNKxaLBVc5HA0CMe5hfJDPf7gIZpx32HBOxPtwLQkycfw9QFiZgkz61wu8htNO09kpZvq2TmuKKbae1vfat8iFmAljIH4xBlMFGRPvjZLm3psOgFZo+XYUZogYqvWlnmA4/H8Pj3fxRmjZohcwbR333jzBck9M74/d19Qv7Oyini+BTmk9iZwU2D9jZL82AjFc9fOgvcU57aMZ1UdOLLxLQwAUCd41flv0vR+7s30gyh0wKvc/ShSrGJUbgegS6JqoncuRu6thOjUuP6xeUCcWgM9xkg2L7N6Pzo/jwVPNUcxFyZ9ZLZXVtJEQubAXDze7Soui/VJAT1CEtH+9eLceRLk3xoIPnrWBK6kpxs5MrltvS8W/+9UCuc3GTAY= + url: AgCCMYfzUfvm3BlVnaCUu0FKglk77trG4x2EZqK4+PsYmPtB4czZmerMaqFk/RXGWR/pYa/EZL/CfJRdZE+qA3fJHjKWSKpBV5mfb2s8GaEHQl6kBUgIaDiCj7MCIu25b00zHe5agcZBUydD3T+V7ABtMhZTkQvUA+4OZjzQhdas0n6F/hDWBlhWQAzEY2Hj4KRY9r7thyuWYK2m3a4dCXepry2WAGG/sYn4qtD487NOQCNJ9qedg1eI0o0zbL45ErrLLJ3kIhC0upyEMekF85fVgUVu9uTRGvRzdibk55VgqrJPEZwxn3DHvqrJp2e2NYnY6Vo2dpa1avCwdORxA/pK5m4NjetTlD78N2+rJujuAODdglQQXEbe5Kscp0CChI5+/cVPD/gFsnt6YHiEohkMHYT7w+Aa+NWpikcL+QH7pQfKoW+LQKTM89eeVhcgDHsYavqk2oIL/X8+j2I3nFGJPiqkrAgbV95u7SO0tTycfq9m8+hs2yUodxAd2wUxOKk516COTYQ6WzpPXKTCje92m/XqtshoqF4PfH1Fxdf3bE6DS+X8XFOL3z+nR/8neaPbX0VC4kTDks3I2slc+uGRr7YZM+Wv9shsQzJXVgbbJYZgXJS0bwuvUhnc+BMUixPKMx772TjYDvXzsi/xubYyAsIT6KOyzJ+AyT+M7f+754vdrM6uinNnLpSbYN6J1MJ4WNhWluJidpYuBwhFCYg5V/71T6P62lgepaqPkwwHggwHwfVkRQfc7KqWimcfbS4HSFQ= + username: AgAOrTTBYkYzZqSHaj03zUnpkSB8daMP0CoGGfw9dWwDK/BNgRzEdkTEGpCKwtJN5ojKTjZTm9q014clw8gXTz2sX5k/NFFF3L9UxeRksrixJUtzLe9deOah+XvurxalhDXHClBIWCipqUGmqdShPOYY8vLHVuXzmVPstLiW58eUmnG/Wafo95weUpFr078rZUKu3+f7DkFP9j+3HqJAsOZsEOjhfCtvmhaOjwHVBldXBR964R1H52w/Jbr3WILNEKSso9/fZD279cvbdBESd7NPuj6UM60cZE8NB5rV6cOkHT9yncFBRq2co13lCo34Xl/4fpqRfF1Dq41DiO8Cm1n5i7EJeY5XyfmKl+Eq6FEGuDxQBVdxFtQfSERICk4g+GJkzJdDhaESzTMeANqLM5alTnIrn/MX+dhCnmD3N6VTYwN8Xiso4CTRPUEwwQqf5CY+prgWQLTUoqomfS4m81C9JCDqKKwKj0mc9df+rGzfIQXNR8W0ETcgitr/9TxTtGMVcU8wanNgNQbV8vu+AKmJZPgXCs7ghqLarzXuTPPPrF/12gtvG77+Gpa/Qh3RsUq3v35fbZZHyy2Evg4D18Rt7nUmvf3ao5QVgG/vI60WjM2TNmNtBmCjrbzGGntZuL3om4LZAV9EIhDDWL6ISdaUtgubrRpGSW7lNgfemi8NIkTq5hd/o/vK8j3ozOsLacvsRaMDnrK3LP8= + template: + metadata: + annotations: + sealedsecrets.bitnami.com/managed: "true" + labels: + argocd.argoproj.io/secret-type: repository + name: gitea-rusty-solitare + namespace: argocd + type: Opaque diff --git a/apps/cluster-secrets/cloudflare-api-token-sealedsecret.yaml b/apps/cluster-secrets/cloudflare-api-token-sealedsecret.yaml new file mode 100644 index 0000000..63c05c6 --- /dev/null +++ b/apps/cluster-secrets/cloudflare-api-token-sealedsecret.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: cloudflare-api-token + namespace: cert-manager +spec: + encryptedData: + api-token: AgDB+icQdE9haTjqxdeXMFJJkDteMpN+jIjgsbT0RQo55rx8e4U6y4FgBcadOovNDF9g4slD4xqalcJVLBeJWvEZ80wOBhzY+9iP+m8HUPzmm67XWCJgnADtWoBjXqZT6NgUoxNb8MMDrFXdRPatM74o3cbVOcp7dIumOEgo+IML0+TdyIXIkSkjMaPTBQZwkxJ5SQs7WQfnRQw1rTxc9lti49LtH3tELWWLK8MdCOtj23OU4YkbzdbZsqymsiHTYJTK80YyL8bP+TjEMp6ZFstkvEvk8Xk6JNiuPqymiQLTK3y9MOfxK6Picux4Q5KGvWoi3N3D3Ol3I5mwsCXGlZA2WfDIyl86VcrYEphKqouxD/dMNJRJW0vzxbtNiFHBBQDulKfmWs9QVYvA2lD+fdPC34CbvyzyVCBVgg9bp/4OUHWkq0Koo5PDVGgIO7ylHP3YHu271koLNmcIKLBKnMlVnhX5RDVjAk48sFK9+rla6mDiQ19xJpd4P5ep1NxRe9NV0DtFrsRVLsbvPe27P0pwICevG/9OPzfDHQyd6ScGzKM+ltzpi6qgyLudu2bb+0rQ83r6EEvCW654nTGatlGaIbyG1n3cs7V2TlMYRyUYD8IjVXpRoVbYqnxMK2PoGL9ZgKGBoYTKabGYZnl9ngUBHwipEPXZ1ShxmvMn3/l1Mjr1thUlO2ktRMo19gGGAUulnFPoHycY7DxFwyxFa9vcHH//oejC20Lut3kq6cJCYt98WtOtXWqaY45t0gsEcnX89nvkyA== + template: + metadata: + annotations: + sealedsecrets.bitnami.com/managed: "true" + name: cloudflare-api-token + namespace: cert-manager + type: Opaque diff --git a/apps/cluster-secrets/solitaire-gitea-registry-sealedsecret.yaml b/apps/cluster-secrets/solitaire-gitea-registry-sealedsecret.yaml new file mode 100644 index 0000000..c0c035b --- /dev/null +++ b/apps/cluster-secrets/solitaire-gitea-registry-sealedsecret.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: gitea-registry + namespace: solitaire +spec: + encryptedData: + .dockerconfigjson: AgC4qgegUdhckd0IVypEjGhMEshxPaZ1Xk6JYbmxZqFyzdIQs72Uv/HxIYTwnfMKcKPd75mkFz6wi4I5yAFRnZDs3JFMujHejIXXAxNYB+mp9jTCDGGS+V4V84zEbdNNuXFBZ034LrnOMeOhn14+ieq22FZWX4w/pnjPpuMGpWL2aunG4nCwoRikXMcvZfVptGjfrYUDEYksCGPcoIlGaV9xuMRQcdt2gWeecs0UdZtTDVBUOVNX4CnnZX2zrTdEX6MCooPqUsv4ZMjDUhjSeLUDgNeAZde0jhbc3wSdjedeeUOqlLTfYgeVRaABUaaslVsBAqTp7MTDjhBYpXl9IRJkiV/RTaC+6vlL1Gku8rva9B2RjORTWL2+IGxiFWmyKnkAR1OxJIkH4VpuiimpWV5eVTNNBSH80k+rquYA8KOjn8aJQ6ZxXN8kOSz3xicNOjZpSp+6eCPwSFUEJePSA/z0JaKWtVYk+rB24D2FHqUkVwOV/X1AG65zsHG1gTUNdr/2k42jB7SOHsBr/0nCgvuJoXftxXnLLinKmBDKa3hHhG7Wjqq5p3eu1MWAADvn0p4UiM/mhomCPHP9oPiuAiiYPMU6Z3nL2eAT2vA0i9gikv9vrk0e6HEtCW2FUlk7JtPZjJCJwKjqlAPu5dceS+YD/eETLX1h1AwugzDbYjtasfiRoQ+AiOqyWitNrds6Dcqk8SlMsWsGf/SRfODR1Dh/JFX6du+dyNzcyJM6L2s0dSCCRuBcUPi2NrjNXbwcIVOHepPLRXyz4KuGNH6pF6ePLpdlxH5DuatTvcaeTKdEtLjwW2YUmK/L66DldrFs5OhGpD02L+LPWYHjrARa/yCl7NWTU7lfzvhGLukVZ2nJG7INYRvPy9qnHIYLRdwpdK+t0UaMCWOgeeThHF4PWn/+BZEjpSk0jDsqj4ETCjtf0NonqhONceeVdg== + template: + metadata: + annotations: + sealedsecrets.bitnami.com/managed: "true" + name: gitea-registry + namespace: solitaire + type: kubernetes.io/dockerconfigjson diff --git a/apps/cluster-secrets/solitaire-matomo-sealedsecret.yaml b/apps/cluster-secrets/solitaire-matomo-sealedsecret.yaml new file mode 100644 index 0000000..3dc6588 --- /dev/null +++ b/apps/cluster-secrets/solitaire-matomo-sealedsecret.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: matomo-secret + namespace: solitaire +spec: + encryptedData: + MATOMO_ADMIN_PASSWORD: AgCfXf0iM1z1Z3RFkWV1De+u5hCwR5nJyH8OJJy95vboJReTx+pKph4nkHSX1dCOSkm1rDBXu/hAyzq2bytXG7qa7xLMtQLpBl0X/Q/b1tI+G+hwDolOQSzKUSuKghf9n0d7wDBHho1n5JPptTYuyC1jhRzugw9v3lXXzUHT/vS4sypVJ9QGj8fBoyRrld0LLTTooRIV5l0+R3daGrk8EJyV+cx300aFQ1xYBCMgjhexW5lVsbTtYNSZNx0aejPt0pPeOBqZbDHTBKXQA4mXP0VvCHyB6LanZ2oJImbJXyUVKM4uxKrICSVdCPdyOWk22rh4sm1DalmPSAYOS5aEKWOlcX87YCOByamiFF3J/vbtolscqcpWQOE9dv1fBNW8hHEHRmneNyH5UEn7q3Bz3ruqotl1b4wq9tmr5cyYCAZPRXWXKLr6oXr+eGjU0ZliAS/fIJ/VF7MvQpqelS10x+MSSnTV8u+ju9sZuQkRMWVdy/OPFj6S4pxZTfuqwF/Bsptg+TL+kXoAf5zKJjBwEUlEDGkoevGwryADLsITd10kc86zDhrjdLrh7Y5RcKIuXe8H51mzCTHuThB3oxEVQEsgftRudRwvubRmYg3/9HC/k7TSFVX2fmBi+fk3YVTdvxldrRj0+UaRPm+48Ohuxvqccj5N1dFgZXL9Z3TRP6EnuxY5Ehm/dVJx+NXQEH9mGpWJ0lVJAk54i2B+PzbEDYsW1qRaEnXXNkw= + MYSQL_DATABASE: AgAH48u2q6zQrN5PaWFRZWssUzStVmIvydUQMZSMqEkrCUK91RzOla0v6Yx9pV1lxlPrqW5NJQ3vQxD7VI4YYiD3FL3DlLFru2wgAT5emvU/UvZnUC6wh5zAxQNjaDPMbjinzVHflKHTztKSyXnnn8j7E2uTAAiRbFpjE94uckBVAxbrUohk62yTFThJ7Y08MV48RE2v5NugWumyisKoMjpo7Wovrj76a17yLO80QQ4Af5Fnk2sAw9/hk4SFrCl5OXdOGq71wtCw8jNAsqHZRlM6cXkcRiGYoFKWTgsW+h/VnSEbuWuEAyF71u/hnre6mSk8Ci5k+VGkTh37afaIcjNdihH8EcWeubOIIxVm7f3LCQ4S9dXMalqG3Frc0QX2zH5eZL0qEqy1zEJ8weXZkgMy6WspCWegnVvfubVZzvrUlRu+oZDpt4SpiKiEWh9g3R5GImWXPrmVm5feet0A7Ff9QhhLdLTl2K/mqTiK5kj0mfbmXnnBvGhh8YNaa5CBTlE3C/5TH/Cop2Qno4/xsgvAqMr5qSd+xUy65S94pOdlc38ngNNsWe71/jfP49u/szO2v/OHnXE2JbhFArAeMRYvcLKIxf73Us/IiliKwIRdFWO2cYknD6Z2FplM9IoC9BmTSgZZMagz0+SnsZBVcVP+K7yizKFVtj5rTjcsHSvqRaAFrOxyiqPAdI7yQTOa/QYHIpHD4f8= + MYSQL_PASSWORD: AgC2K1DumViJ6BO8+ACM7ayaE6R46mEiPcFmftjF1wHGaSLYejk0sqJWdh1DHWo2Xa1jk7CnCZBmhZmbbbteDR/E2hKEDfDa3YTQIBVIn+G2blEsSmG/tgnbhW/ZZUSwF/Z/i2I+gMbQDUeKsnSSZ2I+dOo/jVnCfps3xz/9s+ChhrM7mqc9J1Qumh4rtIAd8X+cXPq20rHUMfKm8jt4m/w7UY0DGg5CuoHswrfteo40LtW8KcP4Pc4N/b7uDCdZY54iEzg4e2EL/QlayX8PFiqgRzOHr/fMZ6Z7wWCEiP13V+gPdHaajdqJzlz0QgwvxI5EUOv6Pl2efIfoitOnCvQz7cw7cpT6iapCi9SY65kWRlrDYmybpfP06Jvk39r3HtUzLNEn9cadagbVg1zsjTm/EtMvNPfwY5MQdra0rbQy2b/WI0U8V5Olf9LvrW+y6kRs+YGFKY+FO1CNrWD2N9i/0K5jQVHPx5p3LZPyILKmDZN8ndPDNAGfjRmIC7WHPHRm3sY55P0xc5fI9hb9qss+2BfH70cRu+rdylO70pIcrV/px6V4xTI76MXVp90j4lxbTmwTLggJY6aZoh8NOCU3oaHGwzCXwTDmVpAzgx5amLuG8l4VM8dqBOke7FB0x8Q+2VTK0OEqFiYUVRY0KIwc8i7fXziR0Y/MMbzMyY0EjpoE+kOjNSKgrCxnWxoBCO/0rmlPqeWP1rrbh4h4EfaDIaZ/YG0XsiA= + MYSQL_ROOT_PASSWORD: AgCiCZzjb0xHh/ZttsDcnWp6jauP5wBYEoFNc5yRJ+WbSiEBB/ayTHP23XJUH6tBDs9xTGjvoXsMJi5yAX2hxMT/fQ0tlTTzoGlwq2fNdWGkqAL1qkOxLRRyoJhnDA0E98sb6v4LBxWCWlCCBgBrZNi2TMpRI+M5htrEfWXcdUa4pPqGDh5Wd1P7z/DLdmtyAGSIIq3BS6T5dnNaobJj4ya/X9PamzZP9Ck3A98qKJnQpHgUKOgm9zWGx/tpxXpru4oNmYvGxDC6Og7XxfVvBp+eBRcWYMuE6ds6cH5iqvrsJJJJBR1vjVTa3UJ8ByP1tyUngJxiz3YQY/zX+UtewkO93AkBA4eU4oZvUxi6+HzRBBHqK3AEjWbuGKRRbmyfjLAvp/nNmTo3MKKPRDILkNG2yA9d/WDama9L9wTiS48SGZdIo8JesPfOJ86ag6XnLdC7BoO6s3W7zuTyXJ8Lre3e65oo9zDHqIX1OgAJBSXq9yuYK7aZG0gTgZauKtktJJFRexwmaCGkrUP9c19jrdDBj87A1gr5+dNYPqt0yuZqIw/rNLjXdLUqh9HqBev1abYyfJ4Z3BkgKUyFfyicTUMma8WfGFWlEoKFbwnaL2Z2/d2GhqP876Tu+YpFDC5GcqszBpfy0Gg83nhIsWlaWWSRtkIr0NTL3LewtvZKbIqjb/VM0xeiYf5iBO30Ma9NtiFzYU4D1Z5A5cQzdGlqfF5bN8toL5iCkVs= + MYSQL_USER: AgAr+wwear3WFeXjqEuB2bY5SF5KpkGQvEeLZlxKwaMSH+PeVxylHUxlPmg2EQAFezfoR0EzOJtTLttNUDi2+7vLIeS0i6GRoHIHul1oInzMiOQWognohU868OqUwuNJedgwGhskx3TgKspRsUUykhcyQTmPP5GbbfH16i11pYQzCVcZ0H8/rRdey3UDwVZl3nY0Ei6d2kXXEKLwBwRsuQ3q0ZfCYCIrnpSuK00tPYubo16ESYdvy50oP4Ssn2NO2j2Httk6EAUtH1HpAdwmjleEIuVXeXIAdK6Ne5N3JXVTqt/JoI1J0PvPgmIaGSMFTyo59IfKfatD7TqFW/JbDp/Ui3pByqEjNljrrs+RqzJ+6tl77U9/hFO2kN61VP1zLpkK6uNYTnGH1ENjjdmCy3Ih/+j9CJ2DVdQ0pp24cBG1cMi2gm66j/w6389OqAjOQoi7ta8GwVdmltAFij3tvrS+aCV2MSS5BgtqJXk8zfLAPW43MKar2N5aH1N1ODiZ99EIfJM9UsTPRDWDuRPiNzuINF2XxGiG6VQwAVVzmlK6s4R65zU9ZpNVi4gFZZlYhppM2IVlM+02w/7pE1c5Am+tMnpWnIM2Z32YcKASdZT/K1MpL/7CSerfcr+gYh4hepWUFKfirVGoZ4LerIEG0OEDaLM/hxZH3hxh8s/kHRQWovgXqGIkcCLJRlwn/zEyy2WeCnSmcQk= + template: + metadata: + annotations: + argocd.argoproj.io/sync-options: Prune=false + sealedsecrets.bitnami.com/managed: "true" + name: matomo-secret + namespace: solitaire + type: Opaque diff --git a/apps/cluster-secrets/solitaire-secrets-sealedsecret.yaml b/apps/cluster-secrets/solitaire-secrets-sealedsecret.yaml new file mode 100644 index 0000000..2f47805 --- /dev/null +++ b/apps/cluster-secrets/solitaire-secrets-sealedsecret.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: solitaire-secrets + namespace: solitaire +spec: + encryptedData: + jwt-secret: AgCDylu761COJ1pbjk1QZbZ9tbwYVX0k2I0T5U/UXhaNPJsNaekiOz0+1063VphPYILey16I+ijc1OplzMgUNaLCdYRCeFjfq1XR2+ntfv3EhtZG19CFlyoZWrQ0guliV3fD0pqMpstClZQ7LmvdeseQ7tknDYrOKg5A19k9eP4kO3SQXW2nuPesDTwddVOs1hU33noIeFAM89cI9TdPC5s9RYjB4sbWFbbtgJPmebku1Mcc/PaTepx0GjaS52OK7cSkcsp+JRlZ6oENfv7EpZSu4V3qW3ajFXLHUSGvxP8VlQGCrLRS2RkNY7b15X3HctYxEdkq1Co4kjArFQ9Ut7qqUl26ANNcfSPnrN3xKA+xBGREr3x8VhQAvWmm/CKAedxmTnIImfpmrerbVELLl3gGz2OztFFzt0l9kcXnlpOBVjmoYvn9O6WStp4YecUy70FrMKmI9Q40qsy10E2hQR5wK+y2ptd6RZ6DCLxS2yRIldqflQPjBdhFjRZl+5cQIwVAaWrqrHd7/J98E1mTd0Pn5cb1iboIdET0Hc/98hF+VMiXQ3GRyih5+brazTePIEpfgDVwmpBodaiqwC2c3qpqWFudt1WQsUupud/koosb3YOWy9sRXTBVaS7hA+X+Pq9woAQeXaJs1CWcPb5amsG7DGpOT8O7DswjODfS53g0yX0SK+6VoZgU0poT68i7PkDyHQ3S96Boz1LxKV8RmLTQbxrREXwXkyCCRof6ZhJfYclKNfncoc8TDRm7rcGfi4lRB2lMq5W1gALJhOl2BADA + template: + metadata: + annotations: + sealedsecrets.bitnami.com/managed: "true" + name: solitaire-secrets + namespace: solitaire + type: Opaque diff --git a/apps/nightscout/kustomization.yaml b/apps/nightscout/kustomization.yaml index 01dbd3b..0ba8284 100644 --- a/apps/nightscout/kustomization.yaml +++ b/apps/nightscout/kustomization.yaml @@ -4,6 +4,7 @@ kind: Kustomization resources: - namespace.yaml - nightscout-configmap.yaml + - nightscout-sealedsecret.yaml - mongo-pvc.yaml - mongo-deployment.yaml - mongo-service.yaml diff --git a/apps/nightscout/nightscout-sealedsecret.yaml b/apps/nightscout/nightscout-sealedsecret.yaml new file mode 100644 index 0000000..7ed256e --- /dev/null +++ b/apps/nightscout/nightscout-sealedsecret.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: nightscout-secret + namespace: nightscout +spec: + encryptedData: + API_SECRET: AgC0frB9Yx9u8pzIE1//OVLdEFaFZSAguSlosSF6ZcQg2sJrunqlxygNujo8LxPHv9YsT1Wwjcl7B0+4SVpqygkIAIN15dQiQX8QEYAytGkRv2nnG1q7jaW/m9S5D5EAIyFgzLHtO8zFCw6lO+e5Yt+qgEE7ZRN/eWEOK9LIYmIhVa7Pbc/xQoueqS2XxtHORhyLU3b74e4GEzb0HJDSDYq1LZfTqi1dnuxKTx0zNcFhQL/QM2W+kUhuZEM/eWpdQt4GBgRvNRT1CPUCeHw1Hf5veh38CxiByU3jYy1SA5q/GN5skgq7fEReKGpBLNIadgjt9516Mognt/f+C60E/VHxKyIWb9F6Lf0tJT9lRv2mU9bX3YykPHLzrVIZ/TdFQ/SDqZ6I2Lz5MOSIJWV5pTqPOvMBLNdzGLOx56kdolxBbwNYRAsELKwwlZnW27Kodc1PFgtFbfkwpoVGdnh3xdyFPQTBcUTyZHAMcproZAhMrcALdyT0rAvnNICFetvGsacT0gv+2c88UWvb1UZ0FYKcnQmUWKD5ou69Gspm2mr+izVt1dQPNa5wxLbRFF5pzong4Q/6hVA+VX1i8rR7/D0adaqdvPYOV36Wl80JLmGbgpilFnDTZP5IQV8kaIZqb94Y1YqOlkciAcO6eICQuXf923kigELB9CYPEUoQCOYWCGBFTFxLbIwq3dvQEjgDoNyR0P3D1mPhR6mCEt7n + MONGODB_URI: AgA9Wj9k2Tdq3UiZVSK8QBhxqxch4pMWDPzWVG/gRcSsAs0KNGiDfd3MINencnSPF5Qf6ChOdc1XXJVZg83H/no068nNev+qT11T/xs7cFNhFgagYzp5bd7i5vsxIC9f69GfGbtOie32phQtCQgjkjwv9seYFywbR6u6cuaBl0wcU/RJRfLEr+goGW3EEtV0yvqxMUeg574D/uL9TSs2uZ9/XGBWGrEzbwIQkyWpUKSn7BWlCxPLQn1s7yOVQsjCKiehzb5EyEkqXhhv2QKObHyIduETFcx+8Pw40QCLVL4IXaAIV//gxDxPJPRbJ+3kL09sWW1ZdnLyvu6Tf7S228MA7uswq3Qe67q/s3vm31+ujnjGugmFtABRVAwvvmC44r79PtrRatMzeRaqMWAJSb+aGv0vxCBifA2qK/mGMIkniSFK0ciV19+Bv6m4L8IB4B4Vn1ookYjcyp0YiSqTJo2c5gMaCHqfwIS6F53+RBod0iQHByLpELgYx9h7qwoNsbFqFTDQLPk8qH1tBoJO3b1vp6FWIFLlkHbuJLfA8hBnWDEEvKoSbVjiDwyusW6duJ3CF4F0WugtfUtg8YVffR1RllUczzPgie1MCl1eZaY9ro86W1+vMC9SRyewIPTUxi64Xpmv4LtXdqdcBctSw3SxWz0ITjVk2pOgDFDOxYYRpngAiLk+8FGRzKiWM175L1E/LyjQsdsNrwwfhWjkaYQbHyelBBh/R5EJTIx2aqLL7hY6 + template: + metadata: + annotations: + argocd.argoproj.io/sync-options: Prune=false + sealedsecrets.bitnami.com/managed: "true" + name: nightscout-secret + namespace: nightscout + type: Opaque From fb110d1c5b424cde848e39f29352e543f1d7a236 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Jul 2026 10:09:08 -0700 Subject: [PATCH 4/4] chore(sealed-secrets): commit controller manifest with key renewal disabled Pins a single sealing key (--key-renew-period=0). Upstream rotates every 30d and kubeseal seals with the newest key, which would silently invalidate the off-cluster key backup for secrets sealed after a rotation. Includes DR runbook. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/sealed-secrets/README.md | 26 ++ apps/sealed-secrets/controller.yaml | 415 ++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 apps/sealed-secrets/README.md create mode 100644 apps/sealed-secrets/controller.yaml diff --git a/apps/sealed-secrets/README.md b/apps/sealed-secrets/README.md new file mode 100644 index 0000000..a462958 --- /dev/null +++ b/apps/sealed-secrets/README.md @@ -0,0 +1,26 @@ +# sealed-secrets + +Controller v0.38.4, applied directly (not via ArgoCD — matches how cert-manager is handled; +ArgoCD apps here are workloads, not cluster infra): + + kubectl apply -f apps/sealed-secrets/controller.yaml + +`controller.yaml` is the upstream release manifest plus ONE local change: +`--key-renew-period=0`. Upstream defaults to rotating the sealing key every 30 days; +kubeseal always seals with the newest key, so a rotation would silently invalidate the +off-cluster key backup for anything sealed afterwards. Pinning to one key keeps the +backup permanently valid. Rotation buys little here — it never re-seals existing secrets. + +## Disaster recovery (order matters) + + # 1. install controller + kubectl apply -f apps/sealed-secrets/controller.yaml + # 2. restore the private key BEFORE applying any SealedSecret, + # or a fresh key is generated and nothing decrypts + gpg -d /mnt/bulk/backups/sealed-secrets/master-key-YYYYMMDD.gpg | kubectl apply -f - + kubectl -n kube-system rollout restart deploy/sealed-secrets-controller + # 3. apply the SealedSecrets + kubectl apply -f apps/cluster-secrets/ # cert-manager, argocd, solitaire + # (apps/notesnook + apps/nightscout SealedSecrets come via ArgoCD) + +Passphrase for the .gpg is in the password manager, and nowhere else. diff --git a/apps/sealed-secrets/controller.yaml b/apps/sealed-secrets/controller.yaml new file mode 100644 index 0000000..ad64f59 --- /dev/null +++ b/apps/sealed-secrets/controller.yaml @@ -0,0 +1,415 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + name: sealed-secrets-controller + name: sealed-secrets-controller + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + name: sealed-secrets-controller + name: sealed-secrets-controller + namespace: kube-system +spec: + minReadySeconds: 30 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + name: sealed-secrets-controller + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + annotations: {} + labels: + name: sealed-secrets-controller + spec: + containers: + - args: [] + command: + - controller + env: [] + args: + - --key-renew-period=0 # renewal disabled: keeps ONE key so the GPG key backup stays valid forever + image: docker.io/bitnami/sealed-secrets-controller:0.38.4 + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: /healthz + port: http + name: sealed-secrets-controller + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: metrics + readinessProbe: + httpGet: + path: /healthz + port: http + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + stdin: false + tty: false + volumeMounts: + - mountPath: /tmp + name: tmp + imagePullSecrets: [] + initContainers: [] + securityContext: + fsGroup: 65534 + runAsNonRoot: true + runAsUser: 1001 + seccompProfile: + type: RuntimeDefault + serviceAccountName: sealed-secrets-controller + terminationGracePeriodSeconds: 30 + volumes: + - emptyDir: {} + name: tmp +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sealedsecrets.bitnami.com +spec: + group: bitnami.com + names: + kind: SealedSecret + listKind: SealedSecretList + plural: sealedsecrets + singular: sealedsecret + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + SealedSecret is the K8s representation of a "sealed Secret" - a + regular k8s Secret that has been sealed (encrypted) using the + controller's key. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: SealedSecretSpec is the specification of a SealedSecret. + properties: + data: + description: Data is deprecated and will be removed eventually. Use + per-value EncryptedData instead. + format: byte + type: string + encryptedData: + additionalProperties: + type: string + type: object + x-kubernetes-preserve-unknown-fields: true + template: + description: |- + Template defines the structure of the Secret that will be + created from this sealed secret. + properties: + data: + additionalProperties: + type: string + description: Keys that should be templated using decrypted data. + nullable: true + type: object + immutable: + description: |- + Immutable, if set to true, ensures that data stored in the Secret cannot + be updated (only object metadata can be modified). + If not set to true, the field can be modified at any time. + Defaulted to nil. + type: boolean + metadata: + description: |- + Standard object's metadata. + More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata + nullable: true + properties: + annotations: + additionalProperties: + type: string + type: object + finalizers: + items: + type: string + type: array + labels: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: Used to facilitate programmatic handling of secret + data. + type: string + type: object + required: + - encryptedData + type: object + status: + description: SealedSecretStatus is the most recently observed status of + the SealedSecret. + properties: + conditions: + description: Represents the latest available observations of a sealed + secret's current state. + items: + description: SealedSecretCondition describes the state of a sealed + secret at a certain point. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: The last time this condition was updated. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: |- + Status of the condition for a sealed secret. + Valid values for "Synced": "True", "False", or "Unknown". + type: string + type: + description: |- + Type of condition for a sealed secret. + Valid value: "Synced" + type: string + required: + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the sealed-secrets controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: {} + labels: + name: sealed-secrets-controller + name: sealed-secrets-controller + namespace: kube-system +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + name: sealed-secrets-controller + type: ClusterIP +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + annotations: {} + labels: + name: sealed-secrets-service-proxier + name: sealed-secrets-service-proxier + namespace: kube-system +rules: +- apiGroups: + - "" + resourceNames: + - sealed-secrets-controller + resources: + - services + verbs: + - get +- apiGroups: + - "" + resourceNames: + - 'http:sealed-secrets-controller:' + - http:sealed-secrets-controller:http + - sealed-secrets-controller + resources: + - services/proxy + verbs: + - create + - get +--- +apiVersion: v1 +kind: Service +metadata: + annotations: {} + labels: + name: sealed-secrets-controller-metrics + name: sealed-secrets-controller-metrics + namespace: kube-system +spec: + ports: + - port: 8081 + targetPort: 8081 + selector: + name: sealed-secrets-controller + type: ClusterIP +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + annotations: {} + labels: + name: sealed-secrets-service-proxier + name: sealed-secrets-service-proxier + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: sealed-secrets-service-proxier +subjects: +- apiGroup: rbac.authorization.k8s.io + kind: Group + name: system:authenticated +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + annotations: {} + labels: + name: sealed-secrets-controller + name: sealed-secrets-controller + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: sealed-secrets-key-admin +subjects: +- kind: ServiceAccount + name: sealed-secrets-controller + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + annotations: {} + labels: + name: sealed-secrets-key-admin + name: sealed-secrets-key-admin + namespace: kube-system +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - list +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: {} + labels: + name: sealed-secrets-controller + name: sealed-secrets-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: secrets-unsealer +subjects: +- kind: ServiceAccount + name: sealed-secrets-controller + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: {} + labels: + name: secrets-unsealer + name: secrets-unsealer +rules: +- apiGroups: + - bitnami.com + resources: + - sealedsecrets + verbs: + - get + - list + - watch +- apiGroups: + - bitnami.com + resources: + - sealedsecrets/status + verbs: + - update +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - create + - update + - delete + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get