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