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