Running the Stack Locally
How to run the full Blitz stack on your machine with Docker Compose, and how the local wiring maps onto Railway. For building/publishing images see Building Services; for staging/production see Environments.
Two compose files, two audiences
There is no single combined compose file. Each repo ships its own src/docker-compose.yaml (each include:-ing its own src/compose-3rdparty.yaml for infra). They are alternative full stacks, not meant to run together:
| Compose file | Contains | For |
|---|---|---|
blitz/src/docker-compose.yaml | backend services + infra | Backend devs (have the blitz repo) |
blitz-ui/src/docker-compose.yaml | backend services + frontends + infra | UI-only devs (no blitz repo) |
Both run under the same Compose project name (-p blitz). The blitz-ui compose is the superset a UI-only developer needs: it carries copies of the backend services (with the network aliases below) plus the two frontend containers, so the entire stack comes up from the blitz-ui repo alone.
Which one do I use?
If you don't have the backend (blitz) repo, use the blitz-ui compose — it has everything. If you're doing backend work, use the blitz compose.
Pulling images from GHCR (no local build)
Images are rebuilt and pushed to GHCR twice weekly (Tue/Fri) — see Building Services. For day-to-day local work you just pull the latest, never rebuild. Both repos have an identical just pullupdate recipe (docker compose pull then up --detach --remove-orphans — never builds); only the default profile differs by audience:
# UI-only devs — from the blitz-ui repo (defaults to ALL services):
cd blitz-ui/src && just pullupdate
# Backend devs — from the blitz repo (defaults to INFRA only):
cd blitz/src && just pullupdate # infra only
cd blitz/src && just pullupdate all # infra + all backend servicesjust pullupdate <profile>overrides the profile in either repo — same syntax everywhere (e.g.just pullupdate all,just pullupdate core).- It never triggers a local build (uses
docker compose pull, notup --build). - Equivalent raw form, if you prefer:
docker compose -p blitz -f docker-compose.yaml --profile all pull && … up -d.
Compose profiles
| Profile | Brings up |
|---|---|
infra | Postgres, RustFS/S3, Temporal, Temporal-UI, Valkey |
backend (blitz) / services (blitz-ui) | app services |
core (blitz-ui) | blitz-api, blitz-ui, blitz-mgmtui, blitz-apimgmt, ws |
all | everything |
Port map (local)
Ports below are the blitz-ui compose (the full stack). The blitz compose has the same backend/infra ports but no frontends and no .railway.internal aliases (nothing local proxies to them there).
Frontends (nginx containers)
| Service | host:container | Open in browser |
|---|---|---|
blitz-ui (tenant) | 10000:80 | http://localhost:10000 |
blitz-mgmtui (admin) | 10020:80 | http://localhost:10020 |
Backend services
| Service | host:container | Local alias | Railway port |
|---|---|---|---|
blitz-api | 10001:10001 | blitz-api.railway.internal | 10001 |
ws | 10002:10002 | ws.railway.internal | 10002 |
blitz-apimgmt | 9999:9999 | blitz-apimgmt.railway.internal | 9999 |
agents | 10013:10013 | — | 10013 |
excelrw | 10014:10014 | — | 10014 |
recon | not published | — | 10015 |
remotecontrol | not published | — | 10016 |
wfw, wfwpdf, emailprocessor, classicml | not published (workers) | — | null |
Infrastructure
| Service | host:container |
|---|---|
db (Postgres) | 5432:5432 |
s3 (RustFS) | 9000:9000, 9001:9001 (console) |
temporal | 7233:7233, 8233:8233 |
temporal-ui | 8080:8080 |
valkey | 6379:6379 |
How addressing works (local vs Railway)
Three distinct mechanisms move /api and inter-service traffic. Only the active front door differs between local and Railway — the target strings are made identical on purpose.
| Path | How the target is addressed | Works locally because… |
|---|---|---|
| nginx container → backend | hardcoded <svc>.railway.internal in the nginx config | the backend service carries a Docker network alias of that exact hostname |
| Vite dev server → backend | vite.config.ts server.proxy → default.localhost:<published port> | it hits the backend container's published host port (bypasses nginx entirely) |
| backend → infra / other backend | env vars (DATABASE_HOST=db, TEMPORAL_HOST=temporal, …) | env vars hold container names locally, *.railway.internal on Railway |
The nginx→backend string (blitz-api.railway.internal) is byte-for-byte the same locally and on Railway. Locally it resolves via the Docker alias; on Railway via real internal DNS. Example (blitz-ui compose):
blitz-api:
networks:
external:
aliases:
- blitz-api.railway.internal # makes the hardcoded nginx upstream resolve locallyBackend→infra addressing instead swaps per environment through .env (DATABASE_HOST=db, TEMPORAL_HOST=temporal, REDIS_URL=valkey://valkey:6379, BLOB_HOST=s3) — container names locally, .railway.internal hostnames on Railway.
Two ways to reach each UI locally
You do not need the nginx container to develop the UI. The Vite dev server is the lighter path and hits the same backends:
| UI | Container (nginx) | Native (Vite dev) |
|---|---|---|
| Tenant | http://localhost:10000 | bun run devui → :5173, proxy → default.localhost:10001 |
| Admin / mgmt | http://localhost:10020 | bun run devui:mgmt → :5006, proxy → localhost:9999 |
Both terminate at the same backend containers — the nginx container via the .railway.internal alias, Vite via the published host port. Run the backend stack in Docker either way.
Management portal specifics
The mgmt portal (blitz-apimgmt + blitz-mgmtui) has two local wrinkles because its production image is built for Railway + Cloudflare Access:
- Cloudflare Access gate. The shipped
blitz-mgmtuinginx rejects any/apirequest lackingCf-Access-Jwt-Assertion(a raw-domain bypass guard). There is no Cloudflare locally, so the compose mounts a local-only nginx template (build/nginx/nginx-mgmt.local.conf.template) that drops only that gate. The upstream is unchanged (blitz-apimgmt.railway.internal:9999, via the alias). MGMT_API_KEY. Injected server-side by nginx asx-mgmt-api-key. Unset ⇒ empty ⇒ apimgmt's/api/o/*guard is disabled, which is the intended local-dev default. Never commit a value.
Prerequisite: the mgmt database
blitz-apimgmt connects to the mgmt database via MANAGEMENT_DATABASE_URL; tm is a schema inside it (not a separate database). The local db container only auto-creates the finance database, so on a fresh volume create mgmt once and apply mgmt migrations:
docker exec -i db psql -U postgres -c 'CREATE DATABASE "mgmt";'
MANAGEMENT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mgmt" \
bunx --bun prisma migrate deploy --schema=packages/mgmt/mgmtd/prisma/schema.prisma(Existing volumes that previously ran bun run devapimgmt natively already have it. On Railway this is handled by fin-infra's init-db — see New Environment.)
Gotchas
- Don't run native
bun run devapimgmtand theblitz-apimgmtcontainer at the same time — both bind host:9999. - Don't run the two compose files together under
-p blitzwith--remove-orphans— each would treat the other's unique services (frontends vs none) as orphans. Pick one stack. - If you rename a container or add a service that another service's nginx proxies to, add the matching
<svc>.railway.internalnetwork alias, or the local proxy hop breaks.