13 KiB
Deployment: FreeBSD Jail with PostgreSQL 18
Production layout: host nginx terminates TLS and reverse-proxies /chess/ to
chess-server in a Bastille jail. PostgreSQL 18 runs in the same jail and
listens only on its Unix socket. The chess OS account has no shell or
password; its database role reaches only the chess database through peer
authentication, so no database password exists.
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port)
│ Unix socket, peer auth
▼
jail: PostgreSQL 18 (no TCP listener)
host# runs on the Bastille host as root; jail# runs inside the jail as root
(bastille console <jail>).
Scripted Deployment
The scripts in deploy/ perform every step below and are safe to
rerun for upgrades. Their header comments list all inputs.
-
Host: give the jail its own System V shared-memory namespace, which PostgreSQL needs, and restart it:
host# sh deploy/freebsd/host.sh chess -
Build the static server on any machine with Go 1.26+, then copy the binary and a checkout of this repository into the jail:
make server-freebsd # bin/chess-server-freebsd-amd64 -
Jail: install and configure everything.
OLD_SERVICEnames the rc.d service of the SQLite deployment;SQLITE_DBimports its data once.jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \ OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \ sh deploy/freebsd/setup-jail.shIt ends with a health check that requires
"storage":"ok". -
Host: add the location block from
deploy/freebsd/nginx-chess.confto the TLS server block, thennginx -t && service nginx reload. -
Verify with the checklist at the end of this document. Keep the old SQLite file until you are satisfied; the import does not modify it.
setup-jail.sh inputs:
| Variable | Default | Meaning |
|---|---|---|
CHESS_BINARY |
required | New chess-server binary |
API_HOST |
required | Listen address (the jail IP) |
API_PORT |
8080 |
Listen port |
TRUSTED_PROXIES |
empty (warns) | nginx address(es) as seen from the jail |
CHESS_HOME |
account home, else /usr/local/chess |
Binary and JWT key location |
LOG_DIR |
/var/log/chess |
Server log directory |
SQLITE_DB |
none | v0.11 SQLite database to import into an empty schema |
OLD_SERVICE |
none | Previous rc.d service to stop and disable |
SPLIT_PRIVILEGES |
no |
yes for a DML-only runtime role (below) |
EXTRA_FLAGS |
none | More server flags, e.g. -max-users 500 |
PGDATA |
/var/db/postgres/data18 |
PostgreSQL data directory |
What the script changes, in order:
| Step | Result |
|---|---|
| Packages | postgresql18-server, postgresql18-client, stockfish (and sqlite3 for an import) |
| PostgreSQL | initdb with UTF-8, builtin C.UTF-8 locale, peer local auth, host auth rejected; data checksums are on by default in 18 |
pg_hba.conf |
Only postgres (all databases) and chess (database chess), both local + peer; original kept as pg_hba.conf.orig |
postgresql.auto.conf |
listen_addresses = '' (no TCP), socket in /tmp, scram-sha-256 |
| Provisioning | deploy/postgresql/setup.sql on first run only |
| Account | chess created if missing (nologin, no password); a warning if an existing account has a login shell |
| Files | Binary root:wheel 0555 (previous copy kept as .prev); jwt.key generated once, chess:chess 0600; log directory chess:chess 0750 |
| Schema | chess-server db init (as chess, or as chess_owner in split mode) |
| Import | migrate-sqlite.sh when SQLITE_DB is set and the schema is empty |
| Service | rc.d/chess_server and rc.conf via sysrc |
| Log rotation | /usr/local/etc/newsyslog.conf.d/chess_server.conf: daily or 1 MB, 7 kept |
| Backups | /usr/local/sbin/chess-backup via /usr/local/etc/cron.d/chess-backup: pg_dump at 03:30 as postgres, 14 days kept |
The script was exercised on Linux with FreeBSD-only commands (pkg, sysrc,
service, pw, fetch) stubbed and everything else real: PostgreSQL 18.6,
su, provisioning in both privilege modes, schema migration, the SQLite
import, and the health check of the running server.
Accounts and Data Retention
- Accounts created by registration on the site and by
chess-server db user addare identical and never expire. Public registration closes at-max-usersaccounts (default 100;0removes the limit); the CLI is not limited. - A game belongs to a registered user when they created it while signed in or claimed a slot with their first move. Such games are kept indefinitely and record the player's username at claim time.
- Games with no registered player are deleted 24 hours after their last
activity (creation, move, undo, reconfiguration, or result), from memory and
from the database, by the hourly cleanup.
-anonymous-game-ttlchanges the window;0keeps them. - Deleting a user removes their sessions; their games keep the claim and name.
Reference
The sections below describe what the scripts configure, for review or manual installation.
Jail prerequisites
PostgreSQL allocates a small System V shared-memory segment even though its
main buffers use mmap. host.sh runs:
host# bastille config chess set sysvshm new
host# bastille restart chess
sysvshm=new gives the jail an isolated namespace, unlike the older shared
allow.sysvipc. Without it, initdb fails with could not create shared memory segment.
PostgreSQL
jail# pkg install postgresql18-server postgresql18-client stockfish
jail# sysrc postgresql_enable=YES
jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject"
jail# service postgresql initdb
jail# service postgresql start
jail# su -m postgres -c "psql -X -d postgres" <<'SQL'
ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp';
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SQL
jail# service postgresql restart
/var/db/postgres/data18/pg_hba.conf:
# TYPE DATABASE USER METHOD
local all postgres peer
local chess chess peer
Provisioning and privilege models
setup.sql creates the login role chess
(no superuser, create-db, create-role, replication, or RLS bypass; 20
connections), the chess database (template0, builtin C.UTF-8), and the
chess schema. It revokes PUBLIC access to the database and to schema
public, and sets role defaults for search_path, statement_timeout,
lock_timeout, and idle_in_transaction_session_timeout.
jail# su -m postgres -c 'psql -X -d postgres -f -' < deploy/postgresql/setup.sql
| Mode | Schema owner | chess role can |
Schema migrations |
|---|---|---|---|
| Owner (default) | chess |
DML and DDL in schema chess only |
Automatic at server start |
Split (-v split=true, SPLIT_PRIVILEGES=yes) |
chess_owner (NOLOGIN) |
SELECT/INSERT/UPDATE/DELETE only |
setup-jail.sh, or manually as postgres (below) |
jail# su -m postgres -c "/usr/local/chess/chess-server db init \
-dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\""
In split mode the server refuses to start while a migration is pending.
The chess account
| Resource | Requirement |
|---|---|
| Shell/password | /usr/sbin/nologin, password disabled |
| Home | Holds chess-server (root:wheel 0555) and jwt.key |
| JWT key | chess:chess 0600, at least 32 bytes; the server refuses group- or world-readable keys |
| Log directory | chess:chess 0750 |
| Database | Role chess over /tmp/.s.PGSQL.5432, database chess only |
| Network | Listens on the API port (unprivileged, above 1024) |
| Engine | stockfish in PATH (/usr/local/bin; set through chess_server_env) |
Administrative CLI commands run as chess so peer authentication selects the
right role; root may use su -m although the account has no shell:
jail# su -m chess -c '/usr/local/chess/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
JWT signing key
setup-jail.sh generates the key once with openssl rand -base64 48. Without
a key file, production mode generates a new key at every start and every
browser session ends on restart. To rotate, replace the file and restart the
service; users then sign in again.
Service
rc.conf as written by setup-jail.sh:
chess_server_enable="YES"
chess_server_account="chess"
chess_server_binary="/usr/local/chess/chess-server"
chess_server_logfile="/var/log/chess/chess-server.log"
chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin"
chess_server_flags="-api-host 10.17.89.10 -api-port 8080 -dsn postgres:///chess?host=/tmp -jwt-secret-file /usr/local/chess/jwt.key -log-level info -trusted-proxies 10.17.89.1"
The rc.d script starts daemon(8) as root, which writes the supervisor
pidfile, drops to chess, restarts the server if it exits, and reopens the log
on SIGHUP for newsyslog. It declares REQUIRE: postgresql. The SQLite-era
-storage-path flag no longer exists; a server started with it exits with
flag provided but not defined.
| Flag | Environment default | Purpose |
|---|---|---|
-dsn |
CHESS_DSN |
PostgreSQL connection string; empty disables persistence |
-jwt-secret-file |
CHESS_JWT_SECRET_FILE |
Stable JWT signing key |
-trusted-proxies |
nginx address(es) as seen from the jail | |
-proxy-header |
Client-IP header set by nginx (default X-Real-IP) |
|
-max-users |
Registration cap (default 100, 0 = none) |
|
-anonymous-game-ttl |
Retention of games without a registered player (default 24h) |
|
-finished-game-ttl |
Memory retention of finished games (default 1h) |
Host nginx
nginx-chess.conf passes the client
address in X-Real-IP (which nginx overwrites), allows 30-second long-polls,
and bounds request bodies. The server keys rate limits on X-Real-IP only for
connections from -trusted-proxies; any other peer is keyed by its own
address. Without -trusted-proxies, every proxied request shares one bucket.
The existing security headers (HSTS, X-Frame-Options, CSP with
connect-src 'self') are unaffected: the API is same-origin under /chess/,
and this release does not change the web client.
SQLite import
migrate-sqlite.sh reads a
schema-version-2 SQLite database (v0.11) and imports, in one transaction, all
users as regular accounts with their Argon2id hashes, unexpired sessions,
games with claims and player names, results, and moves. It refuses a schema
that already has data. Imported games without a registered player follow the
24-hour retention. Sessions carry over, but tokens signed with the old
per-restart key do not, so users sign in once. Manual invocation:
jail# su -m postgres -c 'sh /path/to/migrate-sqlite.sh /tmp/legacy.db dbname=chess'
Upgrades, backups, and restores
- Upgrade: build the new binary and rerun
setup-jail.shwith the same inputs (omitSQLITE_DB). It keeps the previous binary aschess-server.prev, migrates the schema, and restarts the service. To roll back a release without a schema change, copy.prevback and restart. - Migrations are transactional and serialized by an advisory lock. A server refuses to start against a schema newer than it supports.
- Backups:
chess-backupwritespg_dump --format=customfiles to/var/db/postgres/backupsnightly and removes those older than 14 days. Copy them off the jail. - Restore into a freshly provisioned database:
su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'(--role=chess_ownerin split mode). - PostgreSQL restarts: the server retries in-flight writes across a brief
outage. A write that still fails marks storage
degradedin/health; live games continue in memory, but durable history stops until the server is restarted.
Verification checklist
jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
jail# service chess_server status
jail# fetch -qo - http://10.17.89.10:8080/health # "storage":"ok"
host# curl -s https://lixen.com/chess/health # through nginx