No description
Find a file
lamar daughma 61b25b8b4c
All checks were successful
ci/crow/push/build Pipeline was successful
ci/crow/push/deploy Pipeline was successful
feat: add migration init container + DATABASE_URL env (compose parity)
The server now runs prisma/data migrations before starting, mirroring
the affine_migration job in .docker/selfhost/compose.yml:
  node ./scripts/self-host-predeploy.js

- init container gets a writable emptyDir (init-config) because the
  script generates private.key; config.json is overlaid via subPath
- DATABASE_URL env on server + init containers: the db module is not in
  the published config schema (config.schema.json), compose passes it as
  an env var (maps to db.datasourceUrl)
- REDIS_SERVER_* env on the init container: our redis requires a
  password (compose's does not)
- drop the db block from config.json (not a valid schema key)
- README: document migration init container and DATABASE_URL env
2026-08-18 19:25:54 +00:00
.crow ci: add Crow pipelines for affine 2026-08-18 16:22:28 +00:00
.gitignore chore: gitignore compiled binary 2026-08-18 16:17:15 +00:00
go.mod prototype: AFFiNE CE on k3s (Pulumi Go) 2026-08-18 16:16:53 +00:00
go.sum prototype: AFFiNE CE on k3s (Pulumi Go) 2026-08-18 16:16:53 +00:00
main.go feat: add migration init container + DATABASE_URL env (compose parity) 2026-08-18 19:25:54 +00:00
Pulumi.dev.yaml updating config 2026-08-18 17:03:48 +00:00
Pulumi.yaml prototype: AFFiNE CE on k3s (Pulumi Go) 2026-08-18 16:16:53 +00:00
README.md feat: add migration init container + DATABASE_URL env (compose parity) 2026-08-18 19:25:54 +00:00

AFFiNE CE on k3s (Pulumi)

Self-hosted AFFiNE Community Edition deployed to the homelab k3s cluster via Pulumi (Go). Storage on Longhorn, volumes enrolled in the core-volumes recurring-job group (daily snapshots + weekly backups).

Architecture

                    ┌──────────────────────────────┐
                    │ namespace: affine            │
                    │                              │
  affine.litelot.us │  ┌──────────────┐            │
  ───────────────►  │  │ AFFiNE CE    │            │
   (traefik +       │  │ server :8080 │            │
    cert-manager)   │  └──────┬───────┘            │
                    │         │                    │
                    │  ┌──────▼──────┐ ┌─────────┐ │
                    │  │ Postgres 16 │ │ Redis 7 │ │
                    │  └──────┬──────┘ └────┬────┘ │
                    │         │             │      │
                    │  Longhorn PVCs (all in core-volumes group)
                    │  affine-postgres-data 8Gi
                    │  affine-redis-data    1Gi
                    │  affine-storage      20Gi  (blob uploads)
                    │  config.json → ConfigMap (read-only, managed by Pulumi)
                    └──────────────────────────────┘
  • Image: ghcr.io/toeverything/affine:stable (CE — free to self-host; the EE license covers only the enterprise server product). Note: the legacy toeverything/affine/affine path 403s on ghcr. Runs with the image's default entrypoint/args — no command overrides (the image entrypoint is bare node; overrides break startup).
  • State: Postgres for application data, Redis for sync/cache, blob storage for uploads. All three PVCs are ReadWriteOnce on the longhorn storage class.
  • Config: config.json mounted read-only at /root/.affine/config — the path the server reads per CONFIG_JSON_PATHS. Keys verified against the server source: server.listenAddr/port/externalUrl (server.ts), redis.host/port/username/password (base/redis/config.ts). The DB URL is NOT in the published config schema — compose passes it as the DATABASE_URL env var (maps to db.datasourceUrl), so this stack does the same on the server container. The AFFINE_SERVER_* env vars from older compose docs are NOT read by this image. Config changes go through Pulumi only.
  • Migrations: a affine-migration init container runs node ./scripts/self-host-predeploy.js (prisma schema + data migrations) before the server starts, mirroring the affine_migration job in the official compose. It gets a writable emptyDir for private.key with config.json overlaid via subPath, plus DATABASE_URL and REDIS_SERVER_* env (our redis requires a password; the compose one doesn't).
  • Backups: every PVC carries the annotation recurring-job-group.longhorn.io/core-volumes: enabled, which enrolls the underlying Longhorn volume in the existing core-snapshots (daily) and weekly backup jobs. No extra Longhorn config needed.

Configuration

Pulumi.dev.yaml:

Key Default Purpose
affine:namespace affine Kubernetes namespace
affine:host affine.litelot.us Ingress host + TLS secret name
affine:external-url https://affine.litelot.us AFFINE_SERVER_EXTERNAL_URL (OAuth callback base)
affine:storage-class longhorn Storage class for all PVCs
affine:backup-group core-volumes Longhorn recurring-job group
affine:image ghcr.io/toeverything/affine/affine:stable Server image

DB and Redis passwords are generated by pulumi.RandomPassword at first deploy and live only in the cluster.

Deploy

cd ~/Projects/homelab/affine
pulumi stack init dev            # first time only
pulumi up                        # review the diff, then confirm

Secrets provider is hashivault://pulumi (matches the other homelab stacks).

Pre-flight checks (do these before pulumi up)

  1. Image pull: ghcr.io/toeverything/affine:stable (verified pulling on the cluster). The legacy .../affine/affine path returns 403.
  2. Config schema: config.json keys are verified against the server source (server.listenAddr/port/externalUrl, db.url, redis.host/port/username/password). If a future image changes them, the ConfigMap template in main.go is the single place to update.
  3. Readiness probe: the server probe hits /healthz on 8080. If the CE build uses a different path, adjust the probe in main.go.

Post-deploy verification

kubectl -n affine get pods,svc,ingress,pvc
kubectl -n affine logs deploy/affine --tail=50
# Longhorn group enrollment:
kubectl get volumes.longhorn.io -n longhorn-system -o json | jq \
  '.items[] | select(.metadata.labels["longhorn.io/volume-pvc-namespace"]=="affine")
   | {name: .metadata.name, groups: .spec.recurringJobSelector}'

Then browse to https://affine.litelot.us, create the admin account from <url>/admin, and run a sync test between two devices.

CI (Crow)

  • .crow/build.yaml — runs automatically on push to main: go build, go vet, gofmt check. No secrets needed.
  • .crow/deploy.yaml — runs on push/manual/deployment: OpenBao approle login → GCS backend login → pulumi refreshpulumi preview. The pulumi up step runs only on deployment events (manual-only deploy rule).

Prerequisites (already in place for the other homelab stacks):

  • Org/repo secrets: vault-url, google-srvacc; pulumi-approle (role-id/secret-id) via the OpenBao external-secrets integration.
  • RBAC: the pipeline runs as the git namespace default SA, bound to the git-kubernetes-operator ClusterRole — covers namespaces, PVCs, deployments, services, ingresses.
  • Stack: dev, encrypted with OpenBao (secretsprovider: hashivault://pulumi), state in gs://glacier-homelab.

Deploy (explicit action):

crow pipeline deploy homelab/affine <pipeline-number> dev

Notes / known follow-ups

  • Single replica for all components — fine for personal use; the AFFiNE server can scale out once sync traffic justifies it (Postgres/Redis are already the shared state).
  • AI (BYOK) and OAuth/OIDC are configured in-app after first login — no server-side config needed for the prototype.
  • config.json is declarative (ConfigMap). The migration init container gets a writable emptyDir for private.key; if the server needs a persisted auth key later, move that to a Secret or PVC mount.