Skip to content

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 fileContainsFor
blitz/src/docker-compose.yamlbackend services + infraBackend devs (have the blitz repo)
blitz-ui/src/docker-compose.yamlbackend services + frontends + infraUI-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:

bash
# 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 services
  • just 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, not up --build).
  • Equivalent raw form, if you prefer: docker compose -p blitz -f docker-compose.yaml --profile all pull && … up -d.

Compose profiles

ProfileBrings up
infraPostgres, 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
alleverything

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)

Servicehost:containerOpen in browser
blitz-ui (tenant)10000:80http://localhost:10000
blitz-mgmtui (admin)10020:80http://localhost:10020

Backend services

Servicehost:containerLocal aliasRailway port
blitz-api10001:10001blitz-api.railway.internal10001
ws10002:10002ws.railway.internal10002
blitz-apimgmt9999:9999blitz-apimgmt.railway.internal9999
agents10013:1001310013
excelrw10014:1001410014
reconnot published10015
remotecontrolnot published10016
wfw, wfwpdf, emailprocessor, classicmlnot published (workers)null

Infrastructure

Servicehost:container
db (Postgres)5432:5432
s3 (RustFS)9000:9000, 9001:9001 (console)
temporal7233:7233, 8233:8233
temporal-ui8080:8080
valkey6379: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.

PathHow the target is addressedWorks locally because…
nginx container → backendhardcoded <svc>.railway.internal in the nginx configthe backend service carries a Docker network alias of that exact hostname
Vite dev server → backendvite.config.ts server.proxydefault.localhost:<published port>it hits the backend container's published host port (bypasses nginx entirely)
backend → infra / other backendenv 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):

yaml
blitz-api:
  networks:
    external:
      aliases:
        - blitz-api.railway.internal   # makes the hardcoded nginx upstream resolve locally

Backend→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:

UIContainer (nginx)Native (Vite dev)
Tenanthttp://localhost:10000bun run devui:5173, proxy → default.localhost:10001
Admin / mgmthttp://localhost:10020bun 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-mgmtui nginx rejects any /api request lacking Cf-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 as x-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:

bash
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 devapimgmt and the blitz-apimgmt container at the same time — both bind host :9999.
  • Don't run the two compose files together under -p blitz with --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.internal network alias, or the local proxy hop breaks.