Kubernetes
The manifests in deploy/k8s/ run a complete Nix deployment on a k3s cluster: the application, its database, broker, object store, the Zitadel identity provider and the Sites services. They are rendered with envsubst and applied by deploy/k8s/deploy.sh.
The Kubernetes path is kept for an explicit switch. Every script in deploy/k8s/ and deploy/docker/build-and-push.sh refuses to run unless you set:
export NIX_DEPLOY_TARGET=kubernetes
What you need#
A k3s cluster, version 1.32 or later, with its bundled Traefik v3 ingress controller. The manifests use the
traefikingress class and Traefik'sipAllowListmiddleware.Traefik's Service set to
externalTrafficPolicy: Local, so it sees the real client address. On k3s, set this with aHelmChartConfigfortraefikinkube-system. Without it, the Cloudflare-only allowlist answers403to everyone.The
local-pathstorage class that k3s provides, and alocal-path-retainclass for the volumes that must outlive their claims (the object store and backups). The repository does not define the second one; create it as a copy oflocal-pathwithreclaimPolicy: Retain.A domain proxied through Cloudflare, and a Cloudflare Origin CA certificate and key for it.
kubectl,envsubst,openssland a checkout of the exact commit you will run.Images for that commit. CI publishes multi-architecture images for every commit on
main, so you can pointREGISTRYat them.deploy/docker/build-and-push.shbuilds and pushes your own instead.
Layout#
Manifest | What it creates |
|---|---|
| The |
| PostgreSQL 16 with pgvector, as a StatefulSet with a 20 Gi volume |
| The versitygw object store with a 20 Gi retained volume, and a policy that limits who may reach it |
| RabbitMQ 4.1 with a 5 Gi volume and an ingress policy |
| Core, one replica, with a small volume for its data-protection keys |
| The collaboration service, one replica, replaced rather than rolled |
| One combined worker running all five roles, and its egress policy |
| Caddy and the web app, configured from the |
| The sites build worker and the sites serving process, each with an egress policy |
| Zitadel, its database bootstrap job and its configuration |
| Three Ingresses (see below) |
| A Traefik middleware that admits only Cloudflare's published address ranges |
| Creates the database roles and database; rerun it to rotate their passwords |
| Creates both buckets, a read-only account for the sites server, and a read-only policy on the sites bucket |
| Core migrations and document migrations for the release tag |
| Seeds the managed template presets |
| Optionally installs the managed template library |
| A nightly backup CronJob |
Secrets#
Run deploy/k8s/create-secrets.sh once per cluster before the first deploy. It generates strong random passwords and keys, derives the connection strings, and creates these Secrets in the nix namespace:
Secret | Holds |
|---|---|
| The PostgreSQL superuser, migrator, app and collaboration passwords |
| The derived connection strings for Core, the migrator, collaboration and document migrations |
| The internal service secret and the public forms signing key |
| One password and URL per broker account |
| The storage endpoint, public origin, region, bucket and keys |
| Google and Microsoft OAuth client IDs and secrets |
| The sites bucket and the sites server's read-only key |
| Optional Cloudflare settings for automatic site DNS records |
| The Web Push key pair and contact |
| The origin certificate and key |
| Zitadel's master key, database password and first administrator |
| The P-256 key that signs Nix access tokens |
It refuses to run again once nix-db exists, so it can never silently rotate passwords the database does not know. To change one external service later, run it with one of --rabbitmq-only, --object-store-only, --calendar-only, --sites-only, --sites-cloudflare-only, --push-only, --tls-only or --zitadel-only. The sites server's key must be a different, read-only key; the script refuses to reuse the write key.
Values reach kubectl through your shell. Once the deployment is more than a first bring-up, consider sealed secrets or an external secrets operator.
Deploying#
export NIX_DEPLOY_TARGET=kubernetes
REGISTRY=ghcr.io/<owner>/nix TAG=<full-commit-sha> \
OIDC_CLIENT_ID=<client-id> DOMAIN=<your-domain> \
deploy/k8s/deploy.sh
DOMAIN is the app's address and also the sites domain: the app answers at <your-domain>, Zitadel at auth.<your-domain> and sites at <slug>.<your-domain>. OIDC_ISSUER defaults to https://auth.<your-domain>; set it only for an external provider.
The script applies a release in an order that stops a bad step before it reaches users:
checks that every required Secret and key exists;
removes retired workloads, keeping their volumes;
PostgreSQL;
the object store, then the storage job;
RabbitMQ's configuration and broker;
the seed job for database roles;
Zitadel's database job and Zitadel;
Core and document migrations;
template presets;
Caddy's configuration;
Core, collaboration, the worker, the web app and the Sites services, waiting for each rollout;
the Cloudflare-only middleware and the Ingresses;
the worker rollout, whose readiness depends on the public origin;
the backup CronJob;
the managed template library, if enabled.
First install#
A first install has nothing to sign in with yet, so it takes two passes:
Set
NIX_TEMPLATE_BOOT_ENABLED=false. The managed template library needs a workspace, a Zitadel project and a service account key that do not exist yet; with this set, that step is skipped and everything else is applied.Deploy once with a placeholder
OIDC_CLIENT_ID. Zitadel starts with the first administrator from thenix-zitadelSecret, who must change their password at first sign-in. Self-registration is off.Sign in to Zitadel at
https://auth.<your-domain>, create the application for Nix, and deploy again with its real client ID.When you want the managed templates, set
NIX_TEMPLATE_BOOT_ENABLED=truewith theTEMPLATE_BOOT_WORKSPACE_ID,TEMPLATE_BOOT_OIDC_AUDIENCE,TEMPLATE_BOOT_PVCandTEMPLATE_BOOT_SERVICE_KEY_SECRETit requires.
Other settings:
Variable | Default | Purpose |
|---|---|---|
|
| Holds each site's first publish until an operator approves it |
|
| A label suffix owned by another deployment sharing the sites domain; slugs ending in it are refused. Set it empty when nothing shares the domain |
|
| The pod network, which Caddy trusts for the client address header. Confirm yours |
Ingress#
There are three Ingresses, all behind the Cloudflare-only middleware and all using the origin certificate:
The app:
<your-domain>goes to Caddy in the web container, which does all the path routing;auth.<your-domain>goes to Zitadel.The sites wildcard:
*.<your-domain>goes to the sites server. It is a separate Ingress at the lowest router priority, soauthand the app always win.Custom site hostnames: a rule with no host at the lowest priority catches any other name Cloudflare proxies to the origin and sends it to the sites server, which answers only for hostnames that have been verified.
The middleware admits only Cloudflare's published address ranges, so nobody can reach the origin directly and forge the client address header that Caddy trusts. Refresh cloudflare-only.yaml when Cloudflare changes its ranges. Cert-manager is not used.
Network policies#
Policy | Rule |
|---|---|
| Only Core, collaboration, the web app, the worker, both Sites services and the storage job may reach the object store |
| Only Core, the worker and the sites worker may reach the broker |
| The worker may reach Core, collaboration, the broker, the object store, HTTPS and DNS |
| The same set as the worker |
| The sites server may reach only the object store, HTTPS and DNS |
Fitting a small node#
Requests are deliberately small and limits are capped:
Workload | Memory request | Memory limit |
|---|---|---|
PostgreSQL | 384 Mi | 1 Gi |
Object store | 64 Mi | 512 Mi |
RabbitMQ | 192 Mi | 768 Mi |
Core | 256 Mi | 512 Mi |
Collaboration | 320 Mi | 768 Mi |
Worker (all roles) | 160 Mi | 1 Gi |
Web | 48 Mi | 256 Mi |
Sites worker | 128 Mi | 768 Mi |
Sites serve | 64 Mi | 256 Mi |
Zitadel | 256 Mi | 768 Mi |
The combined worker exists to fit a single small node. Split it back into one Deployment per role when you have the memory. Zitadel's start-up probe allows several minutes on a slow node.
Checking a deploy#
DOMAIN=<your-domain> SITE_HOST=<slug>.<your-domain> deploy/k8s/verify.sh
verify.sh runs its checks in order, each failing in its own way: pods, Core's health and status, log scans, collaboration, RabbitMQ (vhost, required and retired accounts, exact permissions), the workers, the object store (an unsigned request must be refused), Zitadel's issuer, the sites server, the public app routes, that /internal/authorize answers 404 publicly, that a Content Security Policy is present, that the apex is not served by Sites and, with SITE_HOST, that a site answers with its own policy and sets no cookie.
It cannot check what needs a browser. Do these by hand: sign in end to end, edit the same note in two browsers, export a note as PDF and reload a deep link.