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

PieceWhat it isListens on
APINestJS on Fastify, dist/main.js. Any number of copiesPORT, default 3000
WorkerThe same codebase started from dist/worker.js. Exactly one copyNothing
Postgres 16Tables managed with drizzle5432
Redis 7Demo sessions, rate limit counters, BullMQ queues6379
SiteThe Astro server. Separate app, and it serves /api/flight itself4321

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

Terminal, from the repository root
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

apps/api/docker-compose.yml
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.

VariableDefaultEffect
DATABASE_URLnone, requiredPostgres connection string
REDIS_URLnone, requiredRedis connection string
NODE_ENVdevelopmentproduction switches logs from pretty-printed debug to JSON at info level
PORT3000API port
HOST0.0.0.0Bind address. The PM2 file overrides it with 127.0.0.1
SITE_ORIGINhttp://localhost:4321Allowed by CORS, next to the two galabets.org origins written into main.ts
DATABASE_SSLfalsetrue connects over TLS, with certificate verification switched off
DEMO_SESSION_TTL86400Seconds a demo session and its keys live after the last write
DEMO_SESSIONS_PER_IP_PER_DAY30New demo sessions allowed per address per day
DEMO_START_CHIPS1000Practice chips a session starts with
VECTORS_DIRemptyWhere the vector files are. Empty means <repo>/vectors
ADMIN_TOKENemptyBearer token for /api/admin/*. Empty turns the admin API off
TURNSTILE_SECRETemptyCloudflare Turnstile secret. Empty skips the check
NOTARY_SIGNING_KEYemptyEd25519 private key, hex, for signed receipts
BEACON_DRAND_URLhttps://api.drand.shBeacon source. The default is fine
BEACON_EVM_RPChttps://bsc-dataseed.binance.orgBeacon source. The default is fine
SENTRY_DSNemptyTurns on error reporting when set
SMTP_URLemptyOutgoing mail, in the form smtp://user:pass@host:587
MAIL_FROMGalabet 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

ScriptWhat it doesWhere it's used
pnpm db:pushCompares src/db/schema.ts with the live database and applies the difference. No files, no historyDisposable local development
pnpm db:generateWrites the difference as SQL into apps/api/drizzle/Before a release, committed with the change
pnpm db:migrateApplies the SQL files that haven't been applied yet, and prints migrations appliedProduction

API and Worker

Terminal, in apps/api
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

apps/api/ecosystem.config.cjs
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,
    },
  ],
};
Terminal, on the server
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 pathGoes toNotes
/metrics, /api/admin/...APIallow 127.0.0.1; deny all;
/api/flight, exactlySite, port 4321Body capped at 2k
/api/..., /badge/..., /ws/...API, port 3000WebSocket upgrade headers, 120 second read timeout
/_astro/..., /cdn/...Files on disk, from apps/site/dist/clientCached for a year, marked immutable
Everything elseSite, 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:

deploy/nginx.galabets.conf
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

deploy/nginx.galabets.conf
# 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:

Terminal, on your own 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

Terminal
curl http://localhost:3000/api/health
Response
{"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.