Self-Hosted: Docker Compose
Two flows are documented here:
-
Quick demo —
scripts/demo-up.shbrings up the entire stack (Zitadel, Postgres, RustFS, backend, frontend, Anubis) with seeded data and working OIDC in roughly 5—10 minutes on a cold machine. Idempotent; safe to re-run. -
Productive spin-up — manual
docker buildx+docker compose up. You bring your own Zitadel, your own license file, your own TLS certificates, your own secrets.
Both flows use the same Dockerfiles in deploy/docker/. The demo just wires them together and skips a few production concerns (license check, real TLS).
The deploy/docker/docker-compose.yml and deploy/docker/docker-compose.demo.yml files reference image tags (shiftit-backend:…, shiftit-frontend:…) that are not published to any registry. You build them locally with docker buildx.
Prerequisites
-
Docker 24+ with the Buildx plugin (
docker buildx version) -
Docker Compose v2 (
docker compose version) -
For the demo:
curl,jq,bashon the host -
For production: a domain, TLS certs in
deploy/docker/certs/, a signedshiftit.licfile + the ed25519 public key that signed it (see Licensing)
1. Quick demo (one command)
scripts/demo-up.sh
That’s the entire interactive surface. The script:
-
Boots a standalone Zitadel + Postgres pair via
scripts/zitadel-up.sh(independent of the compose stack — see zitadel-setup.adoc for why), provisions a project + 3 OIDC apps + 3 users, and writesdev/zitadel-vars.envwith all the resulting IDs. -
Builds
shiftit-backend:demowith--build-arg CARGO_FEATURES=saas(license check compiled out — see Build arguments). -
Builds
shiftit-frontend:demowith the Zitadel client IDs + auth URL from step 1 baked into the WASM bundle via--build-arg. -
Writes
deploy/docker/.envwithZITADEL_DOMAIN=http://localhost:8090and the Zitadel project_id asZITADEL_EXPECTED_AUDIENCE. -
docker compose -f deploy/docker/docker-compose.demo.yml up -d. -
Waits for the backend
/healthendpoint (migrations run on startup). -
Applies
scripts/seed-demo.sqland reconcilestenants.zitadel_org_idagainst the real Zitadel org.
When it returns, the stack is live:
| Endpoint | URL |
|---|---|
Frontend (Anubis PoW → nginx) |
|
Backend API + Swagger |
|
Backend health probe |
|
Zitadel console |
|
Postgres (psql) |
|
Seeded application users (sign in via the frontend’s OIDC redirect):
| Role | Username | Password | |
|---|---|---|---|
Admin |
|
|
|
Employee |
|
|
|
Customer |
|
|
|
The Zitadel bootstrap admin (used to manage the IdP itself, not sign in to ShiftIt) is admin / Admin123! on the console.
Tearing down
scripts/demo-down.sh # stops everything, removes volumes
scripts/demo-down.sh --keep-volumes # preserve the seeded database
How the networking actually works
The trickiest part of a single-host OIDC demo is reconciling three views of the same Zitadel:
-
Browser hits
http://localhost:8090for the OIDC redirect. -
Backend container must fetch JWKS from the same URL string (because the
issclaim is pinned andJwksCacheuses that string as both the issuer and the JWKS base). -
Inside the container,
localhostwould normally mean the backend itself.
The demo compose pins extra_hosts: ["localhost:host-gateway"] on the backend service. Docker prepends that to /etc/hosts, which routes localhost lookups inside the backend container to the docker host gateway. The host has port 8090 mapped to the Zitadel container (started by scripts/zitadel-up.sh), so localhost:8090 resolves to Zitadel from both the browser and the backend — and the JWT issuer string stays consistent.
If this trick fails on your platform (resolved address is wrong, backend logs show connection refused on JWKS fetch), the fallback is to replace localhost with a hostname that resolves the same way everywhere — see Troubleshooting.
2. Productive spin-up
For a real deployment you control each step.
2a. Configure .env
cp deploy/docker/.env.example deploy/docker/.env
$EDITOR deploy/docker/.env
Required values:
-
POSTGRES_PASSWORD— strong random -
ZITADEL_DOMAIN— public hostname, e.g.auth.example.com -
ZITADEL_MASTERKEY—openssl rand -base64 32 | head -c 32 -
ZITADEL_EXPECTED_AUDIENCE— Zitadel project_id (UUID); backendexit(1)s on startup if unset -
RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY -
VAPID_PRIVATE_KEY/VAPID_PUBLIC_KEY—npx web-push generate-vapid-keys(the public key is also needed at frontend build time; see below) -
LICENSE_FILE_PATH— host directory containingshiftit.lic(mounted read-only into the backend container at/license)
.env.example documents every variable, including the build-time-only inputs referenced in the next section.
2b. Build the images
Dockerfiles reference paths from the repo root (Cargo.toml, backend/, frontend/, .sqlx/, deploy/docker/nginx.conf). Always invoke buildx from the repo root, not from deploy/docker/.
One-time builder setup
docker buildx create --name shiftit --use --bootstrap
docker buildx ls
The default docker driver works for single-arch local builds; the docker-container driver above is required for multi-arch or remote builders.
Pick a tag
export TAG=$(git rev-parse --short HEAD)
# Then set SHIFTIT_VERSION=$TAG in deploy/docker/.env
Use a git SHA or semver in production so up -d after a build is deterministic.
Backend
docker buildx build \
--file deploy/docker/backend.Dockerfile \
--build-arg SHIFTIT_PUBLIC_KEY="$(cat path/to/license-public-key.b64)" \
--tag shiftit-backend:${TAG} \
--load \
.
SHIFTIT_PUBLIC_KEY is the URL-safe base64 (no padding) of the ed25519 verifying key that signed your shiftit.lic. The backend’s option_env! compiles it in; runtime startup verifies the license file against this key. Without it, the binary panics on first call (SHIFTIT_PUBLIC_KEY must be set at build time for self-hosted builds).
To skip the license check entirely (hosted / SaaS-style build), drop the public-key arg and pass --build-arg CARGO_FEATURES=saas instead.
--load puts the result into the local docker daemon. For multi-arch (--platform linux/amd64,linux/arm64) swap to --output type=registry,... or --output type=oci,dest=....
Frontend
docker buildx build \
--file deploy/docker/nginx.Dockerfile \
--build-arg ZITADEL_ADMIN_CLIENT_ID=<admin-client-id> \
--build-arg ZITADEL_EMPLOYEE_CLIENT_ID=<employee-client-id> \
--build-arg ZITADEL_CUSTOMER_CLIENT_ID=<customer-client-id> \
--build-arg ZITADEL_AUTH_URL=https://${ZITADEL_DOMAIN} \
--build-arg VAPID_PUBLIC_KEY=<vapid-public-key> \
--tag shiftit-frontend:${TAG} \
--load \
.
The Zitadel client IDs come from your Zitadel project’s three applications (admin / employee / customer). All five values are read by option_env! at compile time — none of them are runtime-overridable.
VAPID_PUBLIC_KEY must match the runtime VAPID_PUBLIC_KEY in .env or browsers will subscribe to one signer while the backend pushes with another.
Build both in one shot
docker buildx bake --file - <<EOF
group "default" { targets = ["backend", "frontend"] }
target "backend" {
context = "."
dockerfile = "deploy/docker/backend.Dockerfile"
args = {
SHIFTIT_PUBLIC_KEY = "$(cat path/to/license-public-key.b64)"
}
tags = ["shiftit-backend:${TAG}"]
output = ["type=docker"]
}
target "frontend" {
context = "."
dockerfile = "deploy/docker/nginx.Dockerfile"
args = {
ZITADEL_ADMIN_CLIENT_ID = "${ZITADEL_ADMIN_CLIENT_ID}"
ZITADEL_EMPLOYEE_CLIENT_ID = "${ZITADEL_EMPLOYEE_CLIENT_ID}"
ZITADEL_CUSTOMER_CLIENT_ID = "${ZITADEL_CUSTOMER_CLIENT_ID}"
ZITADEL_AUTH_URL = "https://${ZITADEL_DOMAIN}"
VAPID_PUBLIC_KEY = "${VAPID_PUBLIC_KEY}"
}
tags = ["shiftit-frontend:${TAG}"]
output = ["type=docker"]
}
EOF
Bake shares the BuildKit cache across targets and runs them in parallel.
2c. Start the stack
cd deploy/docker
docker compose config # sanity-check the substituted manifest
docker compose up -d
docker compose ps # all services should be "Up" / "healthy"
docker compose logs backend --tail=50
The production compose binds host ports 80 and 443 via the anubis service (PoW bot mitigation in front of nginx). All other services are reachable only on the internal shiftit network.
2d. Provision Zitadel
The production zitadel: service in docker-compose.yml starts a Zitadel instance against the shared postgres. You still need to:
-
Create the project + applications + users via the Zitadel console (or the API).
-
Configure each app’s redirect URI to point at your real domain (e.g.
https://app.example.com/auth/admin/callback). -
Copy the project_id into
ZITADEL_EXPECTED_AUDIENCEin.envand the three client IDs into the frontend build args.
See zitadel-setup.adoc for the API recipe.
Build arguments reference
Backend (deploy/docker/backend.Dockerfile)
| Arg | Default | Purpose |
|---|---|---|
|
(empty) |
Cargo features. Pass |
|
(empty) |
ed25519 verifying key, URL-safe base64 no pad. Required for self-host builds (not |
Frontend (deploy/docker/nginx.Dockerfile)
| Arg | Default | Purpose |
|---|---|---|
|
|
OIDC client_id for |
|
|
OIDC client_id for |
|
|
OIDC client_id for the customer-facing flow. |
|
(empty) |
Zitadel base URL (e.g. |
|
(empty) |
Public half of the VAPID pair. Service worker subscribes with it. Must match runtime private key. |
Iterating
After a code change, rebuild only what moved and up -d again:
docker buildx build --file deploy/docker/backend.Dockerfile \
--build-arg SHIFTIT_PUBLIC_KEY="..." \
--tag shiftit-backend:${TAG} --load .
docker compose -f deploy/docker/docker-compose.yml up -d backend
BuildKit caches Cargo deps separately from sources, so incremental backend builds are typically under a minute once the cache is warm.
For the demo, just re-run scripts/demo-up.sh — every step is idempotent and only the changed image gets rebuilt.
Upgrading
-
git pullthe new release tag. -
Rebuild both images with the new
TAG(and updateSHIFTIT_VERSIONin.env). -
docker compose up -d— migrations apply automatically on backend startup.
Roll back by setting SHIFTIT_VERSION to the previous tag and up -d again (as long as you didn’t docker image prune the old tag).
Troubleshooting
docker compose pull says "manifest unknown"
Expected. The compose file references local image tags, not a registry. Build the images with docker buildx first.
Backend exits immediately on start
docker compose logs backend. Most common causes:
-
ZITADEL_EXPECTED_AUDIENCEunset — backend fails fast with a clear message naming the variable. -
Self-host build started without
SHIFTIT_PUBLIC_KEYembedded — panic withSHIFTIT_PUBLIC_KEY must be set at build time for self-hosted builds. Rebuild with the build-arg (see Backend build args) or switch to a SaaS build with--build-arg CARGO_FEATURES=saas. -
Missing or invalid
shiftit.licat the mounted path. The license file must be signed by the ed25519 key whose public half was embedded at build time. -
Postgres not yet ready. The healthcheck should prevent this; if it fires, bump
retries:on the postgres service.
Demo: backend logs connection refused fetching JWKS
The extra_hosts: ["localhost:host-gateway"] trick failed to override localhost resolution in the backend container. Two fallback paths:
-
Confirm the entry was applied —
docker compose exec backend cat /etc/hostsshould list a non-127.0.0.1mapping forlocalhost. If it doesn’t, your Docker version is too old (host-gateway needs 20.10+). -
Switch to a nip.io hostname — restart
scripts/zitadel-up.shafter editing it to setZITADEL_EXTERNALDOMAIN=127.0.0.1.nip.io, then re-runscripts/demo-up.sh.127.0.0.1.nip.ioresolves to127.0.0.1from the browser via public DNS, and Docker’sextra_hoststhen overrides it inside the container without colliding with the built-inlocalhostentry.
Push notifications silently don’t fire
-
Confirm runtime
VAPID_PRIVATE_KEYandVAPID_PUBLIC_KEYare non-empty (docker compose exec backend env | grep VAPID). -
Confirm the frontend image was built with the same
VAPID_PUBLIC_KEY— a mismatch means the browser subscribes to one signer but the backend signs with another, and the push service drops the message.
Emails not delivered
Email is opt-in. Without SMTP_USERNAME the backend uses NoopMailer and just logs the dispatch. Set SMTP_USERNAME + SMTP_PASSWORD + SMTP_FROM on the backend service environment: in your compose file to enable real SMTP. (Production docker-compose.yml doesn’t include these by default — add them manually.)
Frontend lands on a Zitadel "client not found" page after sign-in click
The WASM bundle is using the literal-string client IDs (shiftit-admin etc.) because the build-arg wasn’t supplied. Rebuild the frontend image with the real ZITADEL_*_CLIENT_ID build-args.
OIDC redirect URI mismatch on Zitadel login
The frontend posts the current origin + a fixed callback path as the redirect URI. Each Zitadel app must list every origin you serve from (http://localhost:8080 for the demo, https://app.example.com for production) in its Redirect URIs config. Edit per app in the Zitadel console.