No description
- Go 100%
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 |
||
|---|---|---|
| .crow | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| Pulumi.dev.yaml | ||
| Pulumi.yaml | ||
| README.md | ||
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 legacytoeverything/affine/affinepath 403s on ghcr. Runs with the image's default entrypoint/args — no command overrides (the image entrypoint is barenode; overrides break startup). - State: Postgres for application data, Redis for sync/cache, blob storage
for uploads. All three PVCs are
ReadWriteOnceon thelonghornstorage class. - Config:
config.jsonmounted read-only at/root/.affine/config— the path the server reads perCONFIG_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 theDATABASE_URLenv var (maps todb.datasourceUrl), so this stack does the same on the server container. TheAFFINE_SERVER_*env vars from older compose docs are NOT read by this image. Config changes go through Pulumi only. - Migrations: a
affine-migrationinit container runsnode ./scripts/self-host-predeploy.js(prisma schema + data migrations) before the server starts, mirroring theaffine_migrationjob in the official compose. It gets a writable emptyDir forprivate.keywithconfig.jsonoverlaid via subPath, plusDATABASE_URLandREDIS_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 existingcore-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)
- Image pull:
ghcr.io/toeverything/affine:stable(verified pulling on the cluster). The legacy.../affine/affinepath returns 403. - Config schema:
config.jsonkeys 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 inmain.gois the single place to update. - Readiness probe: the server probe hits
/healthzon 8080. If the CE build uses a different path, adjust the probe inmain.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,gofmtcheck. No secrets needed..crow/deploy.yaml— runs on push/manual/deployment: OpenBao approle login → GCS backend login →pulumi refresh→pulumi preview. Thepulumi upstep runs only ondeploymentevents (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
gitnamespace default SA, bound to thegit-kubernetes-operatorClusterRole — covers namespaces, PVCs, deployments, services, ingresses. - Stack:
dev, encrypted with OpenBao (secretsprovider: hashivault://pulumi), state ings://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.jsonis declarative (ConfigMap). The migration init container gets a writable emptyDir forprivate.key; if the server needs a persisted auth key later, move that to a Secret or PVC mount.