Skip to content
Last updated: Sep 25, 2026

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 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) ​

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:

bash
# 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 (and build in blitz).
  • 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.

What :latest means now (and when it moves) ​

Local compose pins every service to :latest:

yaml
image: ghcr.io/finaisse-org/blitz-api:latest   # all 12 backend services + both UIs

That 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:latestthe digest release.json pins
Tracksmainthe last released tag
Typicallyahead of staginga 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 :latest for nothing.
  • Nothing is scheduled. blitz's nightly all-images build was removed when releases became tag-driven, so :latest advances only when someone manually dispatches a build on main.

⇒ 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:

bash
docker image inspect ghcr.io/finaisse-org/blitz-api:latest \
  --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'

git rev-parse origin/main      # compare

If it is stale, refresh it yourself (either repo → Actions → Run workflow on main):

  • blitz → Build and Push All blitz Images
  • blitz-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:

ProfileBrings up
infraPostgres, RustFS/S3, Temporal, Temporal-UI, Valkey
backendbackend 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
sftpthe SFTP test server on its own
alleverything (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) ​

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:10013—10013
excelrw10014:10014—10014
bapiproxy10017:10017—10017
reconnot published—10015
remotecontrolnot published—10016
wfw, wfwpdf, emailprocessor, classicmlnot 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 ​

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.proxy → default.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 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):

sql
-- 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 template

pgAdmin 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 ​

  1. Enable tenancy. Set TENANCY_ENABLED=true (exact lowercase) on blitz-api — without it, the resolver short-circuits to the default tenant and logs tenancy is disabled, skipping tenant info resolution. blitz-api also needs MANAGEMENT_DATABASE_URL → local mgmt (to look up the tenant).
  2. Provisioning deps. blitz-apimgmt needs TENANT_DB_BASE_URL (or DATABASE_URL) → local Postgres and TEMPORAL_HOST/TEMPORAL_PORT. The wfw worker and Temporal must be running (the provisionTenant workflow executes there). The Postgres role needs CREATEDB (the local superuser has it).
  3. Point the domain template at .localhost (optional convenience). In blitz-ui's src/apps/mgmt/.env set VITE_TENANT_DOMAIN_TEMPLATE={name}-app.localhost and 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 built blitz-mgmtui image does not inject this var (blitz-ui#774), so there you just type demo-app.localhost in the field. Typing it manually always works regardless — the backend matches on the stored domainname, not on how it was filled.
  4. Create the tenant in the mgmt UI (:5006): Name demo, Domain Name demo-app.localhost. On save, watch the provisionTenant workflow in the Temporal UI (CREATE DATABASE demo TEMPLATE tenant_base → migrate → seed → isenabled = true). Confirm a demo database appears and the row is enabled.
  5. Seed a marker (isolation proof): in the demo DB, change something visible — e.g. set the company/org display name to DEMO-TENANT.
  6. Log in from the tenant URL: open http://demo-app.localhost:10000 (the ui host app; port = VITE_DEV_PORT, default 10000). Log in with a user seeded in tenant_base (a finance clone, e.g. root@finaisse.com).
  7. Verify: the DEMO-TENANT marker shows on demo-app.localhost but not on default.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 devapimgmt and the blitz-apimgmt container at the same time — both bind host :9999.
  • The tenant domainname must equal the host you browse to (port stripped) — e.g. row demo-app.localhost ⇄ URL demo-app.localhost:10000. A mismatch resolves to no tenant (or, with TENANCY_ENABLED off, silently to the default).
  • WebSockets can't resolve tenant by host. The ui app proxies /api/ws with changeOrigin: true, so the WS Host is rewritten — realtime features rely on the JWT tenantId instead. Fine after login; not a data-isolation gap.
  • 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.
  • A successful just pullupdate does not mean you are current. :latest only moves on a manual main-ref build (see above), so the pull can be a no-op while main has moved on. Verified 2026-09-11: the two UI images were 97 commits / 6 days behind main while every pull reported success. Check the image's org.opencontainers.image.revision label rather than trusting the pull.

Finaisse Internal — Confidential. Access-restricted; not for external distribution.