Files
k3s-homelab/docs/superpowers/plans/2026-07-15-notesnook-k8s-migration.md
T
Alex 4b801e3444 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) <noreply@anthropic.com>
2026-07-15 22:33:56 -07:00

38 KiB
Raw Blame History

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/.envNOTESNOOK_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

cd <repo>
git checkout -b feat/notesnook-k8s
  • Step 2: namespace.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)
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)
# 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)
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
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
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)

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
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)
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
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
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

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: notesnook-s3-data
  namespace: notesnook
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 20Gi
  • Step 2: minio-deployment.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
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)
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
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
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
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

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
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
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)
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
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

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
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)
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
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
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

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
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
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
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
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

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
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
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
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
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.conotesnook-s3:9000. Consumed by clients fetching attachments via S3 presigned URLs.

  • Step 1: attachments-ingress.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
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
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 47 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
# 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
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
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)

# 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
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
# 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
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

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)
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
# 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)
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
# 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:

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)

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

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 112. ✓
  • 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-<host>-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.