This guide explains how to use Docker Compose to run the Vatix backend — either just the data layer (for host-run development) or the fully containerized stack.
| Service | Profiles | Container name | Notes |
|---|---|---|---|
postgres |
(default) | vatix-postgres |
PostgreSQL 16 |
redis |
(default) | vatix-redis |
Redis 7 — caching + job queues |
api |
app, api |
vatix-backend |
Fastify HTTP API, port 3000 |
indexer |
app, indexer |
vatix-indexer |
Stellar event indexer |
finalization-worker |
app, workers, finalization-worker |
vatix-finalization-worker |
Resolution finalization loop |
oracle-worker |
app, workers, oracle-worker |
vatix-oracle-worker |
Oracle submission queue consumer |
settlement-worker |
app, workers, settlement-worker |
vatix-settlement-worker |
Trade settlement queue consumer |
migrate |
tools, migrate |
vatix-migrate |
One-off prisma migrate deploy job |
load-test |
tools, load-test |
vatix-load-test |
One-off local order-placement load test (~100 rps) |
finalization-worker, oracle-worker, and settlement-worker have no HTTP
port to probe, so each declares a healthcheck: that greps /proc/1/cmdline
for its entrypoint script — docker compose ps and docker inspect report
unhealthy if the process has crash-looped or hung, instead of the workers
profile silently going dark (no trades settling, no resolutions finalizing)
with every container still showing as "running".
Container names match the ones referenced in
docs/runbooks/incident-runbook.md, so
commands like docker logs vatix-indexer work as documented there.
postgres and redis have no profiles: entry, so they always start by
default — this preserves the original host-run development workflow below.
Every application process lives behind a profile so you opt in explicitly.
All application images (api, indexer, finalization-worker,
oracle-worker, settlement-worker) run as the non-root vatix user
(uid/gid 1001), set in the Dockerfile runtime stage. CI's
docker-image-smoke job builds all worker target images and asserts
id -u inside each container is non-zero, ensuring non-root execution is
consistent across all processes. The api target additionally confirms
postgres/redis healthchecks pass with the stack up.
This is the original workflow: run infra in containers, run the app processes
on the host with tsx.
-
Clone the repository and install dependencies:
git clone https://github.com/vatix-protocol/vatix-backend.git cd vatix-backend pnpm install -
Copy environment variables:
cp .env.example .env
Edit
.envif needed (see.env.examplefor details). -
Start the data layer:
docker compose up -d
This starts PostgreSQL (on port 5433) and Redis (on port 6379). No app profile is requested, so only
postgresandrediscome up. -
Initialize the database:
pnpm prisma:generate pnpm prisma:migrate dev
-
Run the backend processes on the host:
pnpm dev # API pnpm indexer:dev # Indexer pnpm workers:finalization:dev # Finalization worker pnpm workers:oracle:dev # Oracle worker pnpm workers:settlement:dev # Settlement consumer
Build and run every process as a container, using the Dockerfile at the repo
root, which defines one build --target per process.
-
Copy environment variables (same as above):
cp .env.example .env
-
Run database migrations before starting the app processes for the first time:
docker compose --profile migrate up --build migrate
-
Start everything:
docker compose --profile app up -d --build
This builds and starts
postgres,redis,api,indexer,finalization-worker,oracle-worker, andsettlement-worker.To run a subset, use the matching profile instead of
app, e.g.:docker compose --profile api up -d --build # postgres + redis + api only docker compose --profile workers up -d --build # postgres + redis + all workers docker compose --profile settlement-worker up -d --build # settlement consumer only
Inside the compose network, app containers reach Postgres/Redis via the
service DNS names postgres and redis (not the host-mapped localhost:5433
/ localhost:6379 from .env.example) — docker-compose.yml overrides
DATABASE_URL and REDIS_URL per service for this reason. Every other
variable (API keys, Stellar config, log levels, etc.) is read from your local
.env via env_file.
docker compose --profile app down # stop infra + app containers
docker compose down # stop infra onlyAdd -v to also remove the postgres_data / redis_data volumes.
- View running containers:
docker compose ps
- View logs for a specific process:
docker compose logs -f api docker logs vatix-indexer --tail 100 --follow
- Rebuild a single service after a code change:
docker compose --profile app up -d --build api
scripts/load-test-orders.ts places signed synthetic orders against
POST /v1/orders at a target rate (default ~100 rps) to exercise the API and
matching engine under sustained write load.
⚠️ Local use only. This places real rows in whatever database the target API is backed by. It refuses to run against anything other thanlocalhost/127.0.0.1/ the composeapiservice unless you pass--allow-remote— never do that against a shared staging or production environment.
Run it via the tools/load-test compose profile once the API is up:
docker compose --profile api up -d --build # start postgres + redis + api
docker compose --profile tools run --rm load-testOr against a host-run pnpm dev API, without Docker:
pnpm load-test:orders # defaults: ~100 rps for 30s
pnpm load-test:orders -- --rps 50 --duration 10The target API's default write rate limiter (10 req/60s per IP — see
src/api/middleware/rateLimiter.ts) will throttle a single-IP load test
almost immediately. To actually sustain the target rps for this local run,
raise it just for that process:
RATE_LIMIT_WRITE_MAX=2000 RATE_LIMIT_WRITE_WINDOW_MS=1000 pnpm devSee the header comment in scripts/load-test-orders.ts for the full option
list (--url, --market-id, --traders, etc.) and prerequisites.
The run reports a capacity number — capacityRps, the sustained rate of
accepted (201) orders — for tuning the admission-control watermarks
(SETTLEMENT_LAG_SHED_THRESHOLD et al., see
ADMISSION_CONTROL_CONFIG.md), plus
successRate (201s excluding 429s) and p50/p95/p99 latency.
Two optional SLO gates make a regression fail loudly instead of silently:
| Flag / env | Effect |
|---|---|
--max-p95-ms <n> / LOAD_TEST_MAX_P95_MS |
Exit non-zero if observed p95 latency exceeds nms |
--min-success-rate <r> / LOAD_TEST_MIN_SUCCESS_RATE |
Exit non-zero if the 201 rate drops below r (0..1) |
With neither set (a local ad-hoc run) the gates are a no-op and the script
always exits 0. .github/workflows/nightly-load-test.yml runs this nightly
(cron 0 3 * * *) and on workflow_dispatch: it boots Postgres + Redis,
seeds an ACTIVE market, starts the API, and runs
pnpm load-test:orders with both gates set (defaults: p95 ≤ 1500ms,
success rate ≥ 0.95). It never runs on pull requests.
Every process registers SIGINT/SIGTERM handlers (see
Graceful Shutdown), and the Dockerfile sets
STOPSIGNAL SIGTERM, so docker compose stop / docker stop <container>
triggers the same clean shutdown path used for Ctrl+C in local development.
For more details, see the main README.md and Deployment Runbook.