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>
38 KiB
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_SECRETor 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 realapps/notesnook/notesnook-secret.yamlis gitignored (apps/**/*-secret.yamlalready covered). - Ingress convention (copy from
apps/nightscout/nightscout-ingress.yaml):ingressClassName: traefik, annotationscert-manager.io/cluster-issuer: letsencrypt-prodandtraefik.ingress.kubernetes.io/router.entrypoints: websecure, per-hosttls.secretName. - Public hosts:
auth/sync/events/monograph/attachments.notes.aleshym.co + apexnotes.aleshym.co. All resolve (post-cutover) to10.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 observed10.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 watchesmainonly, so it won't interfere on the branch). After end-to-end validation, merge tomain; 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 branchfeat/notesnook-k8s→ PR → merge tomain. - 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-configand Secretnotesnook-secret(both in nsnotesnook), consumed viaenvFromby 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
.envnon-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:27017with replica setrs0initiated (member hostnotesnook-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), bucketattachments. Consumed by sync server (S3_INTERNAL_SERVICE_URL) and theattachmentsingress. -
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, Secretnotesnook-secret, Mongonotesnook-db(/identity). -
Produces:
notesnook-identity:8264(health/health); ingressauth.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); ingresssync.notes.aleshym.co+ apexnotes.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); ingressevents.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; ingressmonograph.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.co→notesnook-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 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
# 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-dbcontainer on10.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
Applicationadoptingapps/notesnook; DNS repoint on10.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-smtpmemory to note the app now runs in k8s (apps/notesnook, nsnotesnook), SMTP config lives in thenotesnook-secretSecret, and DNS points at10.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:
.examplecommitted, real gitignored, ArgoCDignoreDifferenceson Secret /data (Task 11). ✓ - Type/name consistency: service names (
notesnook-db/-s3/-identity/-sync/-sse/-monograph), ConfigMapnotesnook-config, Secretnotesnook-secret, TLS secretsnotesnook-<host>-tlsused 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 restartafter.