Deployment
strikeabox deploys from one container image (API and worker), one PostgreSQL 16 database and a static docs site. The repository ships a Render Blueprint (render.yaml); configurations for Railway and Fly.io are in deploy/.
What runs
The server image runs in one of three roles: api, worker, or all for both in one process.
| Part | What it is | Public? |
|---|---|---|
API (--role=api) | HTTP API, WebSocket /ws, preview images, and the built web app (apps/web, bundled into the image and served from WEB_DIST, which the image sets) | Yes, HTTPS and WebSocket, on <APP_HOST> |
Worker (--role=worker) | Price collection, settlement, job scheduler, social housekeeping, data retention, Strike live orders and user streams | No |
| PostgreSQL 16 | The only state store | Private network only |
| Docs | Static build of apps/docs (this site), served by nginx from deploy/docs/Dockerfile | Yes, on <DOCS_HOST> |
The Render Blueprint runs the API and the worker as one always-on service in role all. The Railway and Fly.io configurations run them as separate services from the same image.
<APP_HOST> and <DOCS_HOST> stand for your hostnames.
The worker must run for the product to work: without it there are no prices, no quotes and no settlement. Settlement never depends on a browser being connected.
Before the first deploy
- Generate secrets. Each is 32 random bytes as hex (
openssl rand -hex 32):QUOTE_SECRET: signs quote tags. Must be the same on every api instance.KEY_ENCRYPTION_KEY: encrypts Strike API-wallet keys. Must be the same on api and worker. Store it somewhere safe: losing it makes every stored key unusable (users would have to reconnect).
- Decide Live mode. It stays off unless
LIVE_TRADING_ENABLED=true,STRIKE_BUILDER_CODEis set andKEY_ENCRYPTION_KEYis set. Practice works without any of them. - Choose the Strike networks.
STRIKE_NETWORKis where Live orders execute (unset: testnet).STRIKE_DATA_NETWORKis whose public market data drives practice (unset: the same network); the hosted app executes ontestnetand usesmainnetdata. Live mode works with either combination: when they differ, it shows the execution network's own market. The Strike URLs follow the networks, and a host of the wrong network stops the server at start-up. Live trading on mainnet also needsLIVE_MAINNET_ENABLED=true. - Decide how the api learns client addresses, for per-IP limits behind a proxy:
CLIENT_IP_HEADERwhen your platform's edge sets a header clients cannot forge (Render:cf-connecting-ip), otherwiseTRUST_PROXYwith your proxies' address range.
The full list is on Environment variables.
Migrations
Migrations are forward-only SQL files, applied in filename order under an advisory lock, each in its own transaction. The server applies any pending ones itself when it starts, so a new version migrates the database before it begins to serve, and several processes starting at once is safe: each migration runs once. Editing a migration that has already been applied is an error.
To run them as a separate step instead, for example as a pre-deploy command, use node dist/db/migrate-cli.js from the image (it needs DATABASE_URL). For local development it is pnpm --filter @sbx/server migrate.
Health and proof of background work
| Endpoint | Returns | Healthy when |
|---|---|---|
GET /api/health | { ok, role, db, version } | ok and db are true |
GET /api/health/worker | { lastTickAgeMs, lastSchedulerRunAgeMs, dueJobs, oldestDueJobAgeMs, deadJobs, openBets, overdueBets, ledger, liveUserStreams } | tick ages a few seconds at most per market; scheduler ran within the last few seconds; oldestDueJobAgeMs small; deadJobs and overdueBets zero; ledger.ok true |
/api/health/worker is served by the api from shared database state. It shows that the worker is collecting prices and settling boxes with no browser attached.
Render (Blueprint)
- In Render, choose New → Blueprint and select the repository.
render.yamlcreates:strikeabox-app: a Docker web service built from the repository'sDockerfileand started withnode dist/index.js --role=all. One process runs the API, the WebSocket hub and the worker, so it must never sleep: it uses a paid, always-on instance type. Health check:/api/health.strikeabox-docs: the docs site, built fromdeploy/docs/Dockerfile.strikeabox-db: PostgreSQL 16. Its connection string reaches the app asDATABASE_URL.
- Render asks once for the values marked
sync: false:QUOTE_SECRETandKEY_ENCRYPTION_KEY, generated as above (keep a private copy of the key);APP_ORIGIN, the https origin the app is served from: itsonrender.comaddress or your custom domain;TRUST_PROXY, which is optional and may stay empty: per-IP limits read the client address fromCLIENT_IP_HEADER(below), andTRUST_PROXYonly applies to requests without it. To cover those too, set it to the address range Render's proxy connects from (see Environment variables).
- Every other setting is in
render.yaml:NODE_ENV=production; the Strike networks (STRIKE_NETWORK=testnetfor Live orders,STRIKE_DATA_NETWORK=mainnetfor practice prices); the builder settings; the Live switch; andCLIENT_IP_HEADER=cf-connecting-ip, the header Render's edge sets to the client's address. Change them there or in the service's environment. - For a custom domain, add it to the service in Render and set
APP_ORIGINto match.
Then check the health endpoints:
curl -s https://<APP_HOST>/api/health
curl -s https://<APP_HOST>/api/health/workerWithin a minute, every market should report a recent tick and the scheduler a recent run.
Alternative: Railway
deploy/railway.api.json, deploy/railway.worker.json and deploy/railway.docs.json describe the same system as three Railway services, with the API and the worker as separate services.
1. Project and database
Create a Railway project and add a PostgreSQL database service. Use PostgreSQL 16.
2. Shared variables
In the project's shared variables, set everything both services need, for example:
NODE_ENV=production
DATABASE_URL=${{Postgres.DATABASE_URL}}
APP_ORIGIN=https://<APP_HOST>
QUOTE_SECRET=<secret>
TRUST_PROXY=<proxy range>
KEY_ENCRYPTION_KEY=<secret>
KEY_ENCRYPTION_KEY_ID=k1
STRIKE_NETWORK=testnet
STRIKE_BUILDER_CODE=strikeabox
STRIKE_BUILDER_FEE_CAP_BPS=100
STRIKE_BUILDER_FEE_BPS=5
LIVE_TRADING_ENABLED=false
PRACTICE_ENABLED=true
LOG_LEVEL=info${{Postgres.DATABASE_URL}} is a Railway reference to the database service's connection string; adjust the name if your database service is called something else. Values in angle brackets are placeholders. Never paste real secrets into the repository.
APP_ORIGIN is required in production and must be an https: origin with no path. TRUST_PROXY should name the address range of your platform's proxies; without it every request appears to come from the proxy, and per-IP limits (new guest sessions, WebSocket connections) apply to the proxy as a whole. If your platform's edge writes the client's address into a header that clients cannot forge, set CLIENT_IP_HEADER to that header's name instead. See Environment variables.
To use one network's public market data for practice and another for Live orders, add STRIKE_DATA_NETWORK (for example STRIKE_DATA_NETWORK=mainnet with STRIKE_NETWORK=testnet, as on the hosted app).
3. The api service
- Source: the GitHub repository, with
deploy/railway.api.jsonas its config file. It builds the repository'sDockerfile. - Start command:
node dist/index.js --role=api - Variables: the shared variables. Railway sets
PORT; the server listens on it. The image setsWEB_DISTso the api serves the web app. - Healthcheck path:
/api/health(the config allows 60 seconds). - Restart policy: always restart.
- Networking: a public domain, or your custom
<APP_HOST>. - The server runs pending migrations at start-up; a separate pre-deploy command is optional.
4. The worker service
- Source: the same repository, with
deploy/railway.worker.json. - Start command:
node dist/index.js --role=worker - Variables: the same shared variables.
- Networking: no public domain.
- Restart policy: always restart.
5. Docs
Add a third service with deploy/railway.docs.json. It builds deploy/docs/Dockerfile, and nginx listens on the PORT Railway sets. Any static host works too: run pnpm --filter @sbx/docs build and publish apps/docs/.vitepress/dist. The build fails on dead links.
6. Check
Use the same health checks as above.
Alternative: Fly.io
deploy/fly.toml.example runs the api and worker roles as two process groups of one Fly app. Copy it to fly.toml, then set DATABASE_URL, QUOTE_SECRET and KEY_ENCRYPTION_KEY as Fly secrets; the file's header lists the commands.
The web app
There is nothing separate to deploy. The Docker build compiles apps/web into the image, and the api serves it from WEB_DIST on <APP_HOST>, next to /api/*, /ws and /og/*, so the app and the API share one origin. Single-page routes such as /r/<id> fall back to the app shell; the replay route also gets its link-preview tags. Hashed build assets are cached for a long time; index.html and other un-hashed files are revalidated on every load.
Provider-neutral notes
- One image, one or two processes. Run role
allas a single process, or at least one api and the workers you want. Extra workers are safe: one records prices (advisory-lock leader), all share the job queue (SKIP LOCKEDleases). - WebSockets. The api's
/wsneeds WebSocket upgrades and long-lived connections through your proxy or load balancer. - Outbound to Strike. The worker holds one connection to the data network's public price stream, plus one user stream per connected user with open live boxes. Every api instance holds its own public price stream connection per network it follows (one, or two when the execution and data networks differ) and reads Strike's public REST market data, including a depth snapshot per market every 2 s. All of it goes only to Strike's hosts for the configured networks.
- Scaling the api. Every api instance listens for database notifications, so any instance can serve any client. Rate limits are kept in memory per instance: with N instances, per-user limits are effectively N times looser. See Known limitations.
- Time. Keep host clocks synced (NTP). Cutoffs use server time, and Strike rejects signed requests whose timestamp is more than a few minutes off.
- Kill switches.
LIVE_TRADING_ENABLED=falsestops new live connections and new live entries; reconciliation, deadline exits and closes keep running.PRACTICE_ENABLED=falserejects new practice taps withMODE_DISABLED; practice boxes already accepted still settle and refund. Neither switch can change an accepted box or decide its result. - Data retention. The worker prunes ticks older than 7 days (never less than 24 hours) once a day, except ticks around a box that is still open. Settled boxes carry their own evidence.
- Backups. The ledger, ticks and evidence live in Postgres. Back it up; receipts and replays are rebuilt from it.