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

1193 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <repo>
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 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**
```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 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.
```