18 KiB
Deployment: FreeBSD Jail with PostgreSQL 18
Layout: host nginx terminates TLS and proxies /chess/api/ and /chess/health
to chess-server in a Bastille jail. The rc.d service is chessd, running as
the unprivileged chess account (no shell, no password). PostgreSQL 18 runs in
the same jail and listens only on its Unix socket. The chess role reaches
only the chess database through peer authentication, so no database password
exists.
browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (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>). Addresses in this document are placeholders.
For a native Linux install with systemd, see
deployment-linux.md; database recipes are in
database.md.
Scripted Installation and Upgrade
The scripts in deploy/ are idempotent; rerun them to upgrade.
Their header comments list every input.
-
Host, once: give the jail its own System V shared-memory namespace, which PostgreSQL needs, and restart it:
host# sh deploy/freebsd/host.sh <jail> -
Jail, once: install the packages:
jail# pkg install postgresql18-server postgresql18-client stockfish -
Build the release (FreeBSD
makeor GNU make) and copy the server binary and a checkout of this repository into the jail:make server-freebsd # bin/chess-server-freebsd-amd64 -
Jail: install or upgrade the service.
TRUSTED_PROXIESis the address the host nginx connects from, as seen inside the jail.jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \ TRUSTED_PROXIES=<nginx-address> \ ENABLE_LOG_ROTATION=yes ENABLE_BACKUP=yes \ sh deploy/freebsd/setup-jail.shThe script stops
chessd, installs everything, starts it again, and fails unless/healthreports"storage":"ok". -
Host: point nginx at the jail (
deploy/nginx-chess.conf) and publish the web client (below).
setup-jail.sh inputs:
| Variable | Default | Meaning |
|---|---|---|
CHESS_BINARY |
required | chess-server binary to install |
TRUSTED_PROXIES |
unset (warns) | Stored as chessd_trusted_proxies in rc.conf |
SPLIT_PRIVILEGES |
no |
yes for a DML-only runtime role (below) |
ENABLE_LOG_ROTATION |
no |
newsyslog entry for the service log |
ENABLE_BACKUP |
no |
Nightly pg_dump via cron |
The service layout (account, home, binary, log, listen address, DSN, key)
comes from rc.conf chessd_* settings, with the defaults of
rc.d/chessd: account chess, home
/home/chess, binary ~/bin/chess-server, log /var/log/chessd.log,
0.0.0.0:8080, DSN postgres:///chess?host=/tmp, key ~/jwt.key.
What the script changes, in order:
| Step | Result |
|---|---|
| Preflight | Requires root, PostgreSQL 17+ installed, chessd_user=chess; refuses an rc.conf chessd_flags that still contains -storage-path; warns if stockfish is missing |
| Cluster | initdb only when none exists: UTF-8, builtin C.UTF-8 locale, peer local auth, host auth rejected |
pg_hba.conf |
Only postgres (all databases) and chess (database chess), both local + peer; original kept as pg_hba.conf.orig. This replaces the trust-everything default of FreeBSD's standard initdb flags |
| Listener | ALTER SYSTEM SET listen_addresses = '' (no TCP) and a restart, only when TCP is still enabled |
| Provisioning | setup.sql when neither role nor database chess exists |
| Service stop | service chessd onestop through the installed rc.d script |
| Binary | root:wheel 0555; the previous file kept as chess-server.prev |
| rc.d | rc.d/chessd installed; a differing previous copy saved as /var/backups/chessd.rc.<time> (not inside rc.d, where rc(8) would run it) |
| JWT key | ~/jwt.key, chess:chess 0600, generated once |
| Schema | chess-server db init (as chess, or as chess_owner in split mode) |
rc.conf |
chessd_enable=YES, chessd_trusted_proxies when given; removes the unused chessd_storage_path and chessd_dir |
| Log rotation (opt-in) | /usr/local/etc/newsyslog.conf.d/chessd.conf: daily or 1 MB, 7 kept, SIGHUP to daemon(8) |
| Backups (opt-in) | /usr/local/sbin/chess-backup via /usr/local/etc/cron.d/chess-backup: pg_dump at 03:30 as postgres, 14 days kept in /var/db/postgres/backups |
| Start | service chessd start, then /health on the configured port |
Files of an earlier SQLite release are not touched; delete them once the new release is verified.
The script and rc.d script were exercised on Linux with the FreeBSD-only
commands (rc.subr, daemon, service, sysrc, pw, fetch) stubbed and
everything else real (PostgreSQL 18.6, su, both server generations). The
tested runs were an upgrade from a running v0.11 SQLite chessd, an idempotent
rerun, and a fresh split-privilege install.
Accounts and Data Retention
A new database has no accounts. Create the first one (for example your own)
with the CLI; without -password it prompts, so the password stays out of
the shell history and the process list:
jail# su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
The row lands in chess.users with an Argon2id hash in password_hash; the
password itself is stored nowhere and cannot be read back, only replaced
(db user set-password). The tables are in schema chess, not public:
as postgres, list them with \dt chess.* and name them as chess.games.
database.md explains the layout and has the psql recipes
(inspecting and deleting games, sessions, starting fresh).
- 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, 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.
With
-db-cleanup delete, games left without any registered player are deleted like anonymous ones. - A game row deleted by hand while the server still plays it no longer degrades storage: the server unloads that game and carries on.
Service (rc.d/chessd)
rc.conf settings, with defaults:
| Setting | Default | Purpose |
|---|---|---|
chessd_enable |
NO |
Start at boot |
chessd_user, chessd_group |
chess |
Service account; must stay chess for peer authentication |
chessd_home |
/home/chess |
Account home |
chessd_bin |
${chessd_home}/bin/chess-server |
Server binary |
chessd_logs |
/var/log/chessd.log |
Log file |
chessd_host, chessd_port |
0.0.0.0, 8080 |
Listen address |
chessd_dsn |
postgres:///chess?host=/tmp |
Database chess over the socket as the OS user |
chessd_jwt_key |
${chessd_home}/jwt.key |
JWT signing key; created (0600) on first start if missing |
chessd_trusted_proxies |
empty | Proxy address(es) whose X-Real-IP is trusted |
chessd_public_url |
empty | The web client's public address, which emailed reset links open (https://<site>/chess/web-client/) |
chessd_smtp_host, chessd_smtp_port |
empty, 25 |
SMTP relay for password-reset email; unset leaves recovery off |
chessd_smtp_user, chessd_smtp_password_file |
empty, ${chessd_home}/smtp.secret if present |
Relay credentials, when the relay authenticates this host; the file is mode 0600 |
chessd_mail_from |
empty | Sender address, e.g. Chess <chess@<site>> |
chessd_flags |
empty | Extra flags, e.g. -max-users 500 -db-cleanup report |
The script runs daemon(8) as root, which writes the supervisor pidfile
(/var/run/chessd.pid), drops to chess, restarts the server 5 seconds after
an unexpected exit, and reopens the log on SIGHUP. It declares REQUIRE: postgresql, so the database starts first at boot. The environment passed to
the server is HOME and a PATH that includes /usr/local/bin for Stockfish.
Server flags:
| 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 |
Proxy address(es) trusted for the client-IP header | |
-proxy-header |
Client-IP header set by the proxy (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) |
|
-db-cleanup |
Hourly integrity sweep: off (default), report (log findings), or delete (database.md) |
|
-smtp-host, -smtp-port |
SMTP relay for password-reset email (default port 25); recovery is off without a transport | |
-smtp-user, -smtp-password-file |
Relay credentials, optional; the file is mode 0600 and only sent over TLS | |
-mail-from |
Sender address of server email | |
-mail-dir |
Development: write email to .eml files here instead of a relay |
|
-public-url |
The web client's address that emailed links open; required with a transport |
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 '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
db subcommands: init, delete -confirm (drops the tables), query
(games, with 8-digit ID prefixes), pgn -gameId <id|prefix> [-ply N],
verify [-gameId <id|prefix>] (replays stored games against the rules and
exits non-zero on a mismatch; run it after an upgrade), and user add|delete| set-password|set-hash|set-email|set-username|reset-link|list.
A forgotten password is reset from the jail without mail:
jail# su -m chess -c '/home/chess/bin/chess-server db user set-password -username <name> -dsn "postgres:///chess?host=/tmp"'
which prompts for the new password (or takes -password), or with a one-time
link to hand to the person, the same link the recovery email carries:
jail# su -m chess -c '/home/chess/bin/chess-server db user reset-link -username <name> -url https://<site>/chess/web-client/ -dsn "postgres:///chess?host=/tmp"'
Password Recovery by Email
With an SMTP relay the server emails a reset link on request (POST /api/auth/recover, the Forgot password? link in the web client); the link
opens the client, which sets the new password and signs the person in. The
link works for an hour and once; using it signs the account out everywhere
else. Accounts without an email address cannot use it (the CLI commands above
can).
The relay is a separate jail reached over the internal network. Set in
rc.conf:
sysrc chessd_public_url="https://<site>/chess/web-client/"
sysrc chessd_smtp_host="<mail jail address>" # chessd_smtp_port, default 25
sysrc chessd_mail_from="Chess <chess@<site>>"
# only when the relay authenticates this host:
sysrc chessd_smtp_user="<user>"
(umask 077 && echo '<password>' > /home/chess/smtp.secret) && chown chess:chess /home/chess/smtp.secret
service chessd restart
The server uses STARTTLS when the relay offers it and sends credentials only
over TLS. GET /chess/health reports "mail": "ok" once recovery is on. For
a test without a relay, -mail-dir <dir> in chessd_flags writes each
message to a file instead.
Host nginx and Web Clients
The API has no version segment: routes are /api/... and /health. With the
layout in nginx-chess.conf, browsers
call <origin>/chess/api/... and nginx maps /chess/api/ to /api/. The
bare /chess and /chess/ redirect (302) to the page that embeds the
client, as a short link.
X-Real-IPmust carry the real client address ($remote_addr, afterreal_ipprocessing when PROXY protocol is in front of nginx). The server honors it only on connections fromchessd_trusted_proxies.proxy_set_header Connection "";lets nginx reuse the upstreamkeepaliveconnections; without it, every request opens a new connection.- The existing security headers are unaffected: the API is same-origin under
/chess/.
Browser clients are static files published by the host, and must match the server's API paths:
- Web client: copy
internal/server/webserver/chess-client-web/(index.html,app.js,style.css) to the site directory. It takes its API base from its script element,<script src="app.js" data-api="…">, which the embedded server sets from-web-api-url; without one, as copied, it uses the/chessAPI prefix. - WASM terminal client (if published):
make wasm(xterm.js and its licence are committed underlib/), then copyweb/chess-client-wasm/.terminal.jsruns the client in the page's#chess-terminal, whosedata-wasm-urlnames the binary; a site with a page of its own loads it into the same markup (index.html). The client derives its API base as<origin>/chess. Its API paths are compiled intochess-client.wasm, so rebuild it wheneverinternal/client/apichanges; publish it together with thewasm_exec.jsthatmake wasmcopies from the same Go toolchain.
web/chess-client-web is a symlink to the embedded web client; copy with
cp -RL or rsync -L so the files, not the link, reach the site. Publish the
clients in the same change as the server, and expect browsers to hold cached
copies until a hard refresh.
Reference
PostgreSQL configuration
What setup-jail.sh applies, for review or manual installation:
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 # only when no cluster exists
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';
SQL
jail# service postgresql restart
/var/db/postgres/data18/pg_hba.conf:
# TYPE DATABASE USER METHOD
local all postgres peer
local chess chess peer
Database administration uses the postgres account over the socket:
su -m postgres -c psql.
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.
| Mode | Schema owner | chess role can |
Schema migrations |
|---|---|---|---|
| Owner (default) | chess |
DML and DDL in schema chess only |
Automatic at server start |
Split (SPLIT_PRIVILEGES=yes) |
chess_owner (NOLOGIN) |
SELECT/INSERT/UPDATE/DELETE only |
setup-jail.sh, or manually as postgres |
In split mode the server refuses to start while a migration is pending.
JWT signing key
The key is 48 random bytes, base64-encoded, readable only by chess. Without a
key file, production mode generates a new key at every start and every session
ends on restart. To rotate, replace the file and restart chessd; users sign
in again.
Rollback
Until the old release's files are deleted, a failed upgrade can be reverted:
jail# service chessd onestop
jail# cp /var/backups/chessd.rc.<time> /usr/local/etc/rc.d/chessd
jail# cp /home/chess/bin/chess-server.prev /home/chess/bin/chess-server
jail# service chessd start
Restore the previous web client files on the host as well.
Backups and restores
chess-backup(deploy/postgresql/chess-backup.sh, shared with Linux) writespg_dump --format=customfiles nightly 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 untilchessdrestarts.
Verification checklist
jail# service chessd status
jail# fetch -qo - http://127.0.0.1:8080/health # "storage":"ok", "version" = the build
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
jail# sockstat -4 -c | grep ':8080' # peer = nginx address
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
host# curl -s https://<site>/chess/health # through nginx
version in /health is git describe of the checkout the binary was built
from. After an upgrade it must match the new build; a web client copied to a
static site ahead of the server gets 404 NOT_FOUND (shown as "Game Not
Found" by older clients) for endpoints the running server predates.