Self-Hosted: Docker Compose

Two flows are documented here:

  1. Quick demo — scripts/demo-up.sh brings 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.

  2. 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, bash on the host

  • For production: a domain, TLS certs in deploy/docker/certs/, a signed shiftit.lic file + 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:

  1. 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 writes dev/zitadel-vars.env with all the resulting IDs.

  2. Builds shiftit-backend:demo with --build-arg CARGO_FEATURES=saas (license check compiled out — see Build arguments).

  3. Builds shiftit-frontend:demo with the Zitadel client IDs + auth URL from step 1 baked into the WASM bundle via --build-arg.

  4. Writes deploy/docker/.env with ZITADEL_DOMAIN=http://localhost:8090 and the Zitadel project_id as ZITADEL_EXPECTED_AUDIENCE.

  5. docker compose -f deploy/docker/docker-compose.demo.yml up -d.

  6. Waits for the backend /health endpoint (migrations run on startup).

  7. Applies scripts/seed-demo.sql and reconciles tenants.zitadel_org_id against the real Zitadel org.

When it returns, the stack is live:

Endpoint URL

Frontend (Anubis PoW → nginx)

http://localhost:8080

Backend API + Swagger

http://localhost:3010/api/docs/

Backend health probe

http://localhost:3010/health

Zitadel console

http://localhost:8090/ui/console

Postgres (psql)

postgres://shiftit:shiftit@localhost:5433/shiftit

Seeded application users (sign in via the frontend’s OIDC redirect):

Role Username Email Password

Admin

demo-admin

admin@demo.test

Demo123!

Employee

demo-employee

emma@demo.test

Demo123!

Customer

demo-customer

colin@demo.test

Demo123!

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:8090 for the OIDC redirect.

  • Backend container must fetch JWKS from the same URL string (because the iss claim is pinned and JwksCache uses that string as both the issuer and the JWKS base).

  • Inside the container, localhost would 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); backend exit(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 containing shiftit.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_AUDIENCE in .env and 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

CARGO_FEATURES

(empty)

Cargo features. Pass saas to compile out the license check (demo / hosted builds).

SHIFTIT_PUBLIC_KEY

(empty)

ed25519 verifying key, URL-safe base64 no pad. Required for self-host builds (not saas).

Frontend (deploy/docker/nginx.Dockerfile)

Arg Default Purpose

ZITADEL_ADMIN_CLIENT_ID

shiftit-admin

OIDC client_id for /auth/admin/callback. Override with your Zitadel app’s actual id.

ZITADEL_EMPLOYEE_CLIENT_ID

shiftit-employee

OIDC client_id for /auth/employee/callback.

ZITADEL_CUSTOMER_CLIENT_ID

shiftit-customer

OIDC client_id for the customer-facing flow.

ZITADEL_AUTH_URL

(empty)

Zitadel base URL (e.g. https://auth.example.com). Used to derive authorize / token / userinfo.

VAPID_PUBLIC_KEY

(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

  1. git pull the new release tag.

  2. Rebuild both images with the new TAG (and update SHIFTIT_VERSION in .env).

  3. 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_AUDIENCE unset — backend fails fast with a clear message naming the variable.

  • Self-host build started without SHIFTIT_PUBLIC_KEY embedded — panic with SHIFTIT_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.lic at 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:

  1. Confirm the entry was applied — docker compose exec backend cat /etc/hosts should list a non-127.0.0.1 mapping for localhost. If it doesn’t, your Docker version is too old (host-gateway needs 20.10+).

  2. Switch to a nip.io hostname — restart scripts/zitadel-up.sh after editing it to set ZITADEL_EXTERNALDOMAIN=127.0.0.1.nip.io, then re-run scripts/demo-up.sh. 127.0.0.1.nip.io resolves to 127.0.0.1 from the browser via public DNS, and Docker’s extra_hosts then overrides it inside the container without colliding with the built-in localhost entry.

Push notifications silently don’t fire

  • Confirm runtime VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY are 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.