Local Development
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 Build; 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)
For day-to-day local work you pull images, never rebuild. Both repos have an identical just pullupdate recipe (docker compose pull then up --detach --remove-orphans — never builds), and both default to the whole stack:
# Either repo — pulls and starts everything:
cd blitz-ui/src && just pullupdate # UI-only devs
cd blitz/src && just pullupdate # backend devs
# Narrow it with a profile:
cd blitz/src && just pullupdate infra # just Postgres/S3/Temporal/Valkey
cd blitz-ui/src && just pullupdate core # just enough to bring the UIs up- Every recipe takes an optional profile and defaults to
all—up,pullupdate,pause,down,shutdown(andbuildin blitz). - 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.
What :latest means now (and when it moves)
Local compose pins every service to :latest:
image: ghcr.io/finaisse-org/blitz-api:latest # all 12 backend services + both UIsThat is deliberately different from what staging runs. Since the release manifest became the source of truth, Railway deploys the <service>-vX.Y.Z tag and digest pinned in release.json — nothing deploys from :latest any more (see Deploy). So:
Local (just pullupdate) | Staging | |
|---|---|---|
| Image | :latest | the digest release.json pins |
| Tracks | main | the last released tag |
| Typically | ahead of staging | a frozen point |
Locally you get newer code than staging, not older. That is usually what you want for development.
:latest does not move on its own
It is pushed only when a build runs with GITHUB_REF=refs/heads/main. Two consequences:
- A release does not refresh it. A tag-triggered build has
refs/tags/api-v0.3.0, so cutting release tags moves:latestfor nothing. - Nothing is scheduled. blitz's nightly all-images build was removed when releases became tag-driven, so
:latestadvances only when someone manually dispatches a build onmain.
⇒ On a day with no manual dispatch, just pullupdate pulls nothing new however much landed on main. docker compose pull reports success either way, so there is no local signal that you are behind.
Check what you actually pulled — the image carries the commit it was built from:
docker image inspect ghcr.io/finaisse-org/blitz-api:latest \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
git rev-parse origin/main # compareIf it is stale, refresh it yourself (either repo → Actions → Run workflow on main):
blitz→ Build and Push All blitz Imagesblitz-ui→ Build and Push blitz-ui / Build and Push blitz-mgmtui
Do not redeploy staging to "pick up" a new :latest — no deploy path reads it. bun run images:drift in fin-infra reports how far each :latest lags its repo's main. It is on demand only — removed from the daily Drift Check on 2026-09-23 (fin-infra#284), because :latest trailing main is expected now that it moves only on a manual dispatch.
Compose profiles
Both repos share one vocabulary:
| Profile | Brings up |
|---|---|
infra | Postgres, RustFS/S3, Temporal, Temporal-UI, Valkey |
backend | backend app services + the infra they depend on + sftp |
core (blitz-ui only) | the UIs and what they need — blitz-ui, blitz-mgmtui, blitz-api, blitz-apimgmt, ws + infra |
sftp | the SFTP test server on its own |
all | everything (the default) |
sftp is in backend because blitz-api and blitz-wfw consume it (libs/sftp); the standalone sftp profile remains for starting just that container.
The two nginx UI containers live in core/all, not backend — they are frontends, and on their own they are useless without the API they proxy to, which is exactly what core bundles. There is deliberately no frontend profile: it would resolve to core minus blitz-ws.
Two gotchas worth knowing
A profile also tags what its members depends_on. Compose does not auto-activate a dependency's profile, so infra services are tagged backend too. Forgetting this is how --profile backend used to die with depends on undefined service "temporal". If you add a service, tag it and check its dependencies carry the same profile.
all cannot be replaced by "just omit --profile". Compose reads a missing flag as "only services with no profiles: key" — which here selects nothing, silently, with exit code 0. (--profile '*' is not a substitute either: it also starts the *_ignore / *_notused services — kafka, vault, mq, pg-cdc — that are parked on purpose.)
just up now starts more than it used to
In blitz, just up previously hardcoded the infra profile with no way to override it. It now defaults to all, so it also starts the app services and the sftp helper (port 2222). Use just up infra for the old behaviour.
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 |
bapiproxy | 10017:10017 | — | 10017 |
recon | not published | — | 10015 |
remotecontrol | not published | — | 10016 |
wfw, wfwpdf, emailprocessor, classicml | not published (workers) | — | null |
bapiproxy — the SAP service
blitz-bapiproxy (SAP/BAPI, .NET) listens on 10017 under /bapi (WELLKNOWN_PORTS.BAPIPROXY). It originally duplicated excelrw's 10014 — fine on Railway, where each container has its own port space, but locally only one of the two could bind the host port, so bapiproxy published none. Now distinct, so it publishes 10017:10017 and you can reach the API directly. Note the normal path into this service is Temporal (taskqueue-system-sap), not HTTP — the port is for debugging.
It is also linux/amd64 only — the bundled SAP NW RFC libraries are x86-64 and SAP ships no arm64 Linux build — so on Apple Silicon it runs under QEMU emulation. Expect slow startup and a platform-mismatch warning; that is expected, not a misconfiguration.
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 Environments.)
Testing multi-tenancy locally
Exercise the full per-tenant flow — provision a tenant DB, then log in from that tenant's own URL and confirm the app reads from the newly created database — on your machine, no DNS or TLS work required. It works because the app is host-agnostic: the SPA calls /api at window.location.origin, the Vite dev proxy forwards /api with changeOrigin: false (so the browser's Host survives to blitz-api), and blitz-api resolves the tenant by a full-hostname match against mgmt.tenant.domainname. Browsers resolve *.localhost to 127.0.0.1 natively, so demo-app.localhost needs nothing in /etc/hosts.
Prerequisite: the tenant_base template database
Provisioning clones a golden template (CREATE DATABASE <tenant> TEMPLATE tenant_base); it does not create it. Build it once per environment (rebuild whenever the finance schema or baseline data changes). CREATE DATABASE cannot run in a transaction and needs no connections to the source, so run each statement on its own from a session connected to a non-finance database (e.g. mgmt):
-- terminate other connections to finance first, if any
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = 'finance' AND pid <> pg_backend_pid();
CREATE DATABASE tenant_base TEMPLATE finance; -- run alone
ALTER DATABASE tenant_base WITH ALLOW_CONNECTIONS false; -- lock the templatepgAdmin batches multi-statement runs into one transaction — hence "CREATE DATABASE cannot run inside a transaction block" if you run both lines together. Select and run each line separately.
Steps
- Enable tenancy. Set
TENANCY_ENABLED=true(exact lowercase) onblitz-api— without it, the resolver short-circuits to the default tenant and logstenancy is disabled, skipping tenant info resolution.blitz-apialso needsMANAGEMENT_DATABASE_URL→ localmgmt(to look up the tenant). - Provisioning deps.
blitz-apimgmtneedsTENANT_DB_BASE_URL(orDATABASE_URL) → local Postgres andTEMPORAL_HOST/TEMPORAL_PORT. The wfw worker and Temporal must be running (theprovisionTenantworkflow executes there). The Postgres role needsCREATEDB(the local superuser has it). - Point the domain template at
.localhost(optional convenience). In blitz-ui'ssrc/apps/mgmt/.envsetVITE_TENANT_DOMAIN_TEMPLATE={name}-app.localhostand restart the mgmt Vite dev server — then typing a tenant Name auto-fills the Domain Name field (demo→demo-app.localhost). This only applies when running the mgmt UI via Vite dev; the builtblitz-mgmtuiimage does not inject this var (blitz-ui#774), so there you just typedemo-app.localhostin the field. Typing it manually always works regardless — the backend matches on the storeddomainname, not on how it was filled. - Create the tenant in the mgmt UI (
:5006): Namedemo, Domain Namedemo-app.localhost. On save, watch theprovisionTenantworkflow in the Temporal UI (CREATE DATABASE demo TEMPLATE tenant_base→ migrate → seed →isenabled = true). Confirm ademodatabase appears and the row is enabled. - Seed a marker (isolation proof): in the
demoDB, change something visible — e.g. set the company/org display name toDEMO-TENANT. - Log in from the tenant URL: open
http://demo-app.localhost:10000(theuihost app; port =VITE_DEV_PORT, default10000). Log in with a user seeded intenant_base(afinanceclone, e.g.root@finaisse.com). - Verify: the
DEMO-TENANTmarker shows ondemo-app.localhostbut not ondefault.localhost:10000— that confirms per-tenant DB isolation via host resolution.
If login hangs, check blitz-api logs: tenancy is disabled means TENANCY_ENABLED didn't take; a tenant-not-found means domainname ≠ the host you browsed to (they must match exactly, port stripped).
Gotchas
- Don't run native
bun run devapimgmtand theblitz-apimgmtcontainer at the same time — both bind host:9999. - The tenant
domainnamemust equal the host you browse to (port stripped) — e.g. rowdemo-app.localhost⇄ URLdemo-app.localhost:10000. A mismatch resolves to no tenant (or, withTENANCY_ENABLEDoff, silently to the default). - WebSockets can't resolve tenant by host. The
uiapp proxies/api/wswithchangeOrigin: true, so the WSHostis rewritten — realtime features rely on the JWTtenantIdinstead. Fine after login; not a data-isolation gap. - 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. - A successful
just pullupdatedoes not mean you are current.:latestonly moves on a manual main-ref build (see above), so the pull can be a no-op whilemainhas moved on. Verified 2026-09-11: the two UI images were 97 commits / 6 days behindmainwhile every pull reported success. Check the image'sorg.opencontainers.image.revisionlabel rather than trusting the pull.