Deployment Guide

Three deployment shapes are supported, in order of operator complexity:

  1. Compose stack — deploy/docker/docker-compose.yml. Production-shaped: nginx → anubis → backend, plus Zitadel + RustFS sidecars.

  2. Binary + nginx — see docs/deployment/binary.md and docs/deployment/nginx.md.

  3. Self-hosted with custom IdP — same as the above but Zitadel federates to your OIDC provider. See docs/deployment/zitadel-setup.md.

This file is the deployment overview; each docs/deployment/*.md is the detail for one shape.


Components

Component Image / artifact Listens on Notes

Backend

deploy/docker/backend.Dockerfile (distroless)

:3000 (configurable via PORT)

Stateless. Reads DATABASE_URL, ZITADEL_DOMAIN, ZITADEL_APP_CLIENT_ID, SHIFTIT_PUBLIC_KEY (saas off) / SHIFTIT_LICENSE_FILE (saas off), VAPID env vars

Frontend

deploy/docker/nginx.Dockerfile (nginx-alpine + frontend/dist)

:80

Static assets only; nginx config at deploy/docker/nginx.conf

Postgres

postgres:16-alpine

:5432

RLS depends on shiftit_app role (created by migration 0001)

Zitadel

ghcr.io/zitadel/zitadel:latest

:8080

OIDC issuer; ⚠️ pin version before staging — see follow-up

Anubis

ghcr.io/techarohq/anubis:latest

:8080

PoW bot challenge in front of nginx; ⚠️ pin version

RustFS

rustfs/rustfs:latest

:9000

S3-compatible; ⚠️ pin version


Configuration

All backend config is via env vars. The runtime reads .env if present (dotenvy::dotenv() in main.rs). See .env.example (in repo) for the full list. Key variables:

Variable Purpose Required

DATABASE_URL

Postgres connection string

Yes

PORT

Listen port

No, default 3000

ZITADEL_DOMAIN

OIDC issuer; backend fetches <domain>/oauth/v2/keys for JWKS

Yes

ZITADEL_APP_CLIENT_ID

OAuth client id for aud validation (P1: not yet enforced — see JWKS audience validation follow-up)

Yes

RUST_LOG

Tracing filter

No

DEV_AUTH_TENANT_ID / DEV_AUTH_USER_ID / DEV_AUTH_ROLE

Dev-only — bypasses JWT validation. Production builds must never set these.

No

SHIFTIT_PUBLIC_KEY

ed25519 public key for license verification — embedded at compile time via option_env!. Self-hosted builds only.

If non-saas

SHIFTIT_LICENSE_FILE

Path to signed .lic file

If non-saas

VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY / VAPID_SUBJECT

Web-Push signing

If push enabled

S3_*

RustFS credentials and endpoint for event_documents

If forms enabled

The frontend has a parallel set of TRUNK_* build-time vars; see frontend/Trunk.toml and docs/deployment/binary.md.


Build modes (saas vs self-hosted)

backend/Cargo.toml defines [features] saas = []. The license check in backend/src/auth/license.rs::check_on_startup is a no-op when --features saas is set; otherwise it requires SHIFTIT_PUBLIC_KEY (compile-time) and SHIFTIT_LICENSE_FILE (runtime). See docs/licensing.md for the operator workflow and the deploy/license/ standalone crate for the signing CLI.


CI/CD

Pipeline lives in .gitlab-ci.yml (GitLab — not GitHub Actions despite the host repository pattern).

Stages:

  1. check — cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings

  2. test — backend-test (with postgres:16-alpine service) + license-tool-test (separate workspace)

  3. build — frontend-build (trunk build --release, artifact = frontend/dist/) + backend-build (artifact = target/release/shiftit-backend)

CI sets SQLX_OFFLINE=true and relies on the committed .sqlx/ cache. cargo install trunk runs every pipeline — known slowness; the fix is a custom CI image with Trunk + wasm-bindgen-cli baked in (P2 follow-up).


Backups & ops

  • Postgres: standard pg_dump / pg_basebackup. RLS is policy-only — dumps include the policies, no special handling needed. Just don’t restore as a superuser without setting the role correctly afterward.

  • RustFS: S3-compatible — use any tooling that speaks S3. The event_documents.file_key column is the canonical pointer.

  • Zitadel: has its own backup model (Postgres-backed); see Zitadel docs.


Known operator footguns

Issue Mitigation

Migrations 0001 and 0005 were edited after first apply

Operator must drop+recreate dev DBs that applied earlier versions — see CLAUDE.md reset procedure. Production never had earlier versions, so this is dev-only.

:latest image tags in docker-compose.yml

P1 follow-up: pin to known-good versions before staging.

Process-local rate limiter

Multi-replica deploys each have their own counter (P2 follow-up: Redis-backed state).

JwksCache floods on unknown-kid token storms

One outbound JWKS fetch per token. P1: single-flight + minimum-interval refresh.


See also

  • docs/deployment/binary.md — bare binary + nginx without Compose

  • docs/deployment/docker-compose.md — annotated Compose walkthrough

  • docs/deployment/nginx.md — TLS termination + reverse proxy config

  • docs/deployment/zitadel-setup.md — OAuth app config, claim mapping (⚠️ verify v1/v2 action API per current Zitadel)

  • docs/licensing.md — license signing & shipping