DocsBuilding a backend
Self-Hosting the API
Running Galabet's NestJS API on your own machine or server, with its Postgres and Redis, environment variables, worker process, PM2 and Nginx setup, and the pieces the repository does not yet contain.
Nothing on this page is needed to verify a bet. The verifier runs in a browser and the library runs anywhere Node does. This is for someone who wants the project's own HTTP API, the one behind the demo games, running under their control. It isn't deployed anywhere public, so every URL below is localhost or your own host.
What Runs
| Piece | What it is | Listens on |
|---|---|---|
| API | NestJS on Fastify, dist/main.js. Any number of copies | PORT, default 3000 |
| Worker | The same codebase started from dist/worker.js. Exactly one copy | Nothing |
| Postgres 16 | Tables managed with drizzle | 5432 |
| Redis 7 | Demo sessions, rate limit counters, BullMQ queues | 6379 |
| Site | The Astro server. Separate app, and it serves /api/flight itself | 4321 |
You need Node 22, pnpm 9 and Docker. On Windows, PowerShell is fine for everything here except the shell scripts in deploy/, which want Git Bash or WSL.
Local Run
pnpm install
pnpm --filter @galabet/fair build
cd apps/api
cp .env.example .env
docker compose up -d
pnpm db:migrate
pnpm start:dev
The library is built first because the API imports the built package and not its source. Run pnpm install from the repository root or an app folder, never from the directory above. That was the first entry in the project's list of mistakes, and it leaves a stray node_modules and a broken install behind.
Wait for galabet api listening on http://0.0.0.0:3000. If you get a line saying cannot reach Redis at redis://localhost:6379 ... Is docker compose up?, the answer is no. The Redis module prints that one line where ioredis used to print a wall of ECONNREFUSED. It was added after the API was started with Docker Desktop closed, booted without complaint, and then buried the terminal in stack traces for a fault that wasn't in the code.
The worker goes in a second terminal with pnpm worker:dev. It has its own tsconfig.worker.json that compiles into dist-worker. Both watchers once shared dist, overwrote each other's output, and produced Cannot find module './beacon/beacon.service'.
Use the start:dev and worker:dev scripts and not tsx. They go through nest start, which uses the TypeScript compiler. tsx drops the decorator metadata Nest injects by, and the NestJS recipe shows what that failure looks like.
Compose File
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: fair
POSTGRES_PASSWORD: fair
POSTGRES_DB: fair
ports:
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "noeviction"]
ports:
- "127.0.0.1:6379:6379"
volumes:
- redisdata:/data
Restart policies, the Postgres health check and the volume declarations are left out above. Both ports are bound to 127.0.0.1, so neither database is reachable from another machine. fair/fair is a laptop password. Change it, and DATABASE_URL with it, before the file goes anywhere near a server.
Leave noeviction alone. The Redis recipe explains what an evicting Redis could do to a nonce counter. If the container predates that flag, docker compose up -d --force-recreate redis applies it.
Environment Variables
config.ts parses process.env with a zod schema when the app boots. A missing or malformed value stops the process with Invalid environment: and one line per problem. Two variables have no default, and the API won't start without them.
| Variable | Default | Effect |
|---|---|---|
DATABASE_URL | none, required | Postgres connection string |
REDIS_URL | none, required | Redis connection string |
NODE_ENV | development | production switches logs from pretty-printed debug to JSON at info level |
PORT | 3000 | API port |
HOST | 0.0.0.0 | Bind address. The PM2 file overrides it with 127.0.0.1 |
SITE_ORIGIN | http://localhost:4321 | Allowed by CORS, next to the two galabets.org origins written into main.ts |
DATABASE_SSL | false | true connects over TLS, with certificate verification switched off |
DEMO_SESSION_TTL | 86400 | Seconds a demo session and its keys live after the last write |
DEMO_SESSIONS_PER_IP_PER_DAY | 30 | New demo sessions allowed per address per day |
DEMO_START_CHIPS | 1000 | Practice chips a session starts with |
VECTORS_DIR | empty | Where the vector files are. Empty means <repo>/vectors |
ADMIN_TOKEN | empty | Bearer token for /api/admin/*. Empty turns the admin API off |
TURNSTILE_SECRET | empty | Cloudflare Turnstile secret. Empty skips the check |
NOTARY_SIGNING_KEY | empty | Ed25519 private key, hex, for signed receipts |
BEACON_DRAND_URL | https://api.drand.sh | Beacon source. The default is fine |
BEACON_EVM_RPC | https://bsc-dataseed.binance.org | Beacon source. The default is fine |
SENTRY_DSN | empty | Turns on error reporting when set |
SMTP_URL | empty | Outgoing mail, in the form smtp://user:pass@host:587 |
MAIL_FROM | Galabet Fair <[email protected]> | Sender of that mail |
One more variable is read outside the schema, straight from process.env in worker.ts: CRASH_ENGINE=off starts the worker without the crash round loop.
DATABASE_SSL=true deserves a second look before you rely on it. The pool is created with rejectUnauthorized: false, which encrypts the connection and doesn't check who is on the other end.
Nest doesn't read .env by itself. Both entry points begin with import 'dotenv/config', added after the first boot failed validation with a complete .env sitting next to it.
db:push, db:generate and db:migrate
| Script | What it does | Where it's used |
|---|---|---|
pnpm db:push | Compares src/db/schema.ts with the live database and applies the difference. No files, no history | Disposable local development |
pnpm db:generate | Writes the difference as SQL into apps/api/drizzle/ | Before a release, committed with the change |
pnpm db:migrate | Applies the SQL files that haven't been applied yet, and prints migrations applied | Production |
API and Worker
pnpm build
node dist/main.js # PM2's cluster mode runs one of these per core
node dist/worker.js # one, always
The API process is stateless between requests. Sessions, rate limit counters and idempotency records are all in Redis, so copies can be added freely. That wasn't always true. The rate limiter first kept its counters in process memory, and under PM2's cluster mode each worker counted alone, which multiplied every limit by the number of workers.
The worker holds what must have a single owner. It consumes two BullMQ queues, registers a repeating job every six hours under a fixed job id, and runs the crash round loop. BullMQ consumers are built to run in several copies. The crash engine and the schedule are written for one owner, and the comment at the top of worker.ts doesn't hedge: never run two. When you scale, scale the API.
PM2
module.exports = {
apps: [
{
name: 'galabet-api',
cwd: __dirname,
script: 'dist/main.js',
instances: 'max',
exec_mode: 'cluster',
env: { NODE_ENV: 'production', PORT: 3000, HOST: '127.0.0.1' },
max_memory_restart: '512M',
time: true,
},
{
name: 'galabet-worker',
cwd: __dirname,
script: 'dist/worker.js',
instances: 1, // exactly one: it owns the crash loop and the schedules
exec_mode: 'fork',
env: { NODE_ENV: 'production' },
max_memory_restart: '512M',
time: true,
},
],
};
pm2 start apps/api/ecosystem.config.cjs
pm2 save
HOST: '127.0.0.1' is doing security work. /metrics has no authentication of its own. Nginx is what keeps it private, and that only holds if nothing can reach port 3000 except through Nginx.
A third app, galabet-site, runs the Astro server from apps/site/dist/server/entry.mjs on 127.0.0.1:4321, one instance. The site doesn't load .env on its own, so the ecosystem file reads apps/site/.env on the server, if there is one, and passes its values, such as FLIGHT_DATABASE_URL, to that app only. HOST and PORT are set after them, so the file can't move the site off the address Nginx expects.
Nginx Routing
deploy/nginx.galabets.conf terminates TLS and splits traffic between the two Node processes.
| Request path | Goes to | Notes |
|---|---|---|
/metrics, /api/admin/... | API | allow 127.0.0.1; deny all; |
/api/flight, exactly | Site, port 4321 | Body capped at 2k |
/api/..., /badge/..., /ws/... | API, port 3000 | WebSocket upgrade headers, 120 second read timeout |
/_astro/..., /cdn/... | Files on disk, from apps/site/dist/client | Cached for a year, marked immutable |
| Everything else | Site, port 4321 |
The second row is the exception to remember. Galabet Flight is served by the Astro site, which keeps its sessions in Postgres, so its two endpoints, /api/flight and /api/flight-health, are carved out of /api/ with exact-match locations:
location = /api/flight {
client_max_body_size 2k;
proxy_pass http://galabet_site;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
Nginx tries = locations before regular expressions, so this wins over the ^/(api|badge|ws)/ rule that follows it. It matches that one path and nothing else. /api/flight/ with a trailing slash falls through to NestJS, which has no such route. If you copy the config and leave this block out, Flight breaks and everything else keeps working, which makes it a slow fault to notice.
Client Addresses and trustProxy
# Overwrite, never append: the API trusts exactly one hop, so a client-supplied XFF chain must not reach it.
proxy_set_header X-Forwarded-For $remote_addr;
Two settings work as a pair. Fastify is created with trustProxy: 1, meaning it believes one hop of X-Forwarded-For, and Nginx replaces that header with the address it saw for itself. The API was first written with trustProxy: true, which believes the whole chain, and a chain is something a client can type. Anyone could send X-Forwarded-For: 1.2.3.4 and be 1.2.3.4. That's number 12 in the mistakes list.
Here it mattered because the address is what two limits hang on: the rate limiter, and the cap of 30 new demo sessions per address per day. Each session comes with a fresh stack of chips. A forged address made both limits decorative.
Further out, the config sets real_ip_header CF-Connecting-IP and includes a list of Cloudflare's ranges, so $remote_addr is the visitor and not a Cloudflare edge, and only when the request did come from Cloudflare. deploy/cloudflare-ips.sh regenerates that list, and the config's comment asks for a monthly cron. If you aren't behind Cloudflare, delete those two lines. If you put a second proxy in front, revisit trustProxy, because "exactly one hop" will no longer describe your setup.
Admin API Through an SSH Tunnel
The admin routes are closed twice. Nginx refuses /api/admin to every address but 127.0.0.1, and the API's guard wants Authorization: Bearer <ADMIN_TOKEN>. With ADMIN_TOKEN empty the guard answers 401 admin api disabled to everyone, which is the state a fresh .env leaves you in. The example file has the one-liner for making a token: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".
Since the API listens only on the server's loopback, you reach it by bringing that loopback to your machine:
ssh -N -L 3100:127.0.0.1:3000 deploy@your-server
# second terminal
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3100/api/admin/audit
-L 3100:127.0.0.1:3000 forwards your local port 3100 to port 3000 as the server sees it, and -N opens no shell. The request never touches Nginx or the public internet, and the token still applies. Local port 3100 is there so the tunnel doesn't collide with a development API on 3000. /metrics comes through the same tunnel.
Health Check
curl http://localhost:3000/api/health
{"ok":true,"spec":"GFS/1.0","postgres":"fulfilled","redis":"fulfilled","time":"2026-09-20T23:21:08.501Z"}
That is a real response from a local run. The handler sends select 1 to Postgres and PING to Redis and reports each as fulfilled or rejected.
Monitor the HTTP status and the ok field. The API returns 200 only when Postgres and Redis pass their checks, and 503 when either fails or exceeds the two-second deadline. Keep probe frequency within the configured route rate limit.
Tagged Releases
deploy.yml runs when a tag starting with v is pushed. It installs and builds on the GitHub runner, copies the tree to /var/www/galabets/ with rsync --delete while excluding node_modules, .git and every .env* file, then on the server runs pnpm install, pnpm db:migrate in apps/api, pnpm --filter site db:migrate for Flight, and pm2 startOrReload with --update-env, which reloads all three apps. Before connecting it writes the server's SSH host key from the VPS_HOST_KEY secret into known_hosts, so the first connection can't be intercepted; without that secret it falls back to ssh-keyscan and prints a warning.
The .env files are never copied. They have to exist on the server already, in apps/api/.env and apps/site/.env. Two jobs are still manual: schedule pnpm --filter site flight:recover to run every minute, for example from cron, so abandoned flights get settled, and give the Flight migration its own more privileged database role if you don't want the site's role to be able to change the schema.
