# Good Phorum Deployment
Deploy the Mojolicious prefork service behind nginx or Caddy, with PostgreSQL
and the outbox worker on the same host. Start with the procedure for your OS;
use the later sections for configuration and verification.
Install the pgvector package matching the PostgreSQL server major before
setup or migrations. Setup installs the extension administratively. For a
manually provisioned database or local inference, follow the
[hybrid search runbook](ops/hybrid-search.md). GlifiStore is an optional,
disposable shared cache.
## Production Shape
Recommended baseline:
```text
nginx or Caddy
-> Mojolicious prefork manager
-> PostgreSQL
-> gpforum outbox (required worker)
-> optional Minion workers
```
The proxy handles TLS, compression, static files, attachment transfer, buffering
and network limits. Good Phorum handles identity, authorization, forum workflows,
transactional events, page rendering and JSON responses.
## Install on Debian or Ubuntu
From a bare Debian 13 or Ubuntu 26.04 host to a forum behind TLS. Every
command runs as a user who may use `sudo`. Set your forum's address once; the
commands below use it:
```sh
site=forum.example.com
```
1. **Packages and CPAN tools.** PostgreSQL, nginx with certbot, ClamAV for the
upload scan, and App::cpm (v1.1 or newer) plus Carton for the system Perl.
```sh
sudo apt install git perl build-essential cpanminus libpq-dev libssl-dev zlib1g-dev \
postgresql postgresql-client nginx certbot python3-certbot-nginx \
clamav-daemon clamav-freshclam
sudo cpanm -M https://cpan.metacpan.org/ App::cpm Carton
```
2. **The code.** The code belongs to root, so the service cannot rewrite it.
```sh
sudo git clone https://github.com/gipidevdor/GPForum.git /opt/gpforum
```
3. **Setup.** Install production dependencies and link `gpforum` into
`/usr/local/bin`. Answer three questions: public address, database and mail
transport (step 5). Enter accepts each suggestion; the database default
creates `gpforum` on this host.
```sh
sudo /opt/gpforum/bin/gpforum setup
```
Example setup report (values vary by release)
```text
gpforum setup installs the dependencies into /opt/gpforum/local first, as make install-deps-production does: cpm install ... --workers 5 --no-test --final-install all --top-level-phase configure,build,test,runtime --feature postgres
...
✓ dependencies: installed into /opt/gpforum/local, for Perl 5.40.1
GPForum setup. Three questions; Enter accepts the suggestion.
Public address [https://forum.example.org]:
Database [create 'gpforum' on this host]:
Mail (sendmail, smtp HOST:PORT USER, or log) [sendmail]:
✓ account gpforum: made, with /opt/gpforum/var/attachments for its uploads
✓ /etc/gpforum/gpforum.env: written, 0640 root:gpforum, with a new session secret and metrics token
✓ gpforum: linked into /usr/local/bin, so it runs from any directory
✓ database gpforum at /var/run/postgresql/.s.PGSQL.5432: made, with its role gpforum
✓ Applied NN migrations, 1000 to NNNN; synced the query budgets (NN changed)
Next: make the forum's owner, with sudo -u gpforum gpforum admin create --email EMAIL --username NAME
Then: install and start the services, with sudo gpforum service print systemd --to /etc/systemd/system
Then: check the whole forum, with sudo -u gpforum gpforum doctor
```
It makes the service's account, `gpforum`, and the directory its uploads
go in. It writes `/etc/gpforum/gpforum.env`, readable by root and the
`gpforum` group only, with a new session secret, metrics token and
database password, and prints none of them. It makes the database role
and the database as PostgreSQL's superuser, the `postgres` account, and
brings the schema up to date as `gpforum migrate` does: the tables, the
monthly partitions and the query budgets `/health/ready` compares the
plans with (`gpforum budgets --sync` syncs those alone; without them
readiness answers 503). Run again, it changes nothing and says so; a
setting the file has is replaced only when you say so.
The normal deployment keeps PostgreSQL on this host and uses its Unix
socket, as shown in [Unix socket mode](#unix-socket-mode). Only when
deliberately choosing a database on another host, answer its data source
(`dbi:Pg:dbname=gpforum;host=db.internal;port=5432`). Setup reaches its
superuser as psql would -- `PGUSER` names it, `~/.pgpass` holds its
password -- then with the user and password the data source, or
`GPFORUM_DATABASE_USER` and `GPFORUM_DATABASE_PASSWORD`, give. When none
answers, it says what PostgreSQL told each, offers the `PGUSER=...` run,
and prints the `psql` commands that make what is missing, to run there
before `sudo gpforum setup` again.
`sudo gpforum setup --yes --public-url "https://$site" --database create
--mail sendmail` answers for a script, and `--dry-run` shows what setup
would do. The file holds what setup asked and made, each under one line
saying what it is; every other setting keeps its default, and
`deploy/gpforum.env.example` lists them all. There is no size to choose:
`GPFORUM_ENV=production` runs as many web processes as the CPUs carry,
each caching its part of the memory. A setting Good Phorum cannot use stops
the start, and every command, with every problem at once, each with the
variable to set and the file to set it in.
4. **Commands as the service runs them.** `gpforum` reads
`/etc/gpforum/gpforum.env` itself, as the service does, so a command typed
by hand sees the service's settings. Run it as the service's user, so it
also has the service's permissions:
```sh
sudo -u gpforum gpforum
```
On its own it lists its commands, by what you are doing, and says which
environment file it read. What the shell sets comes first:
`GPFORUM_ENV=staging gpforum ...` overrides the file for one command, and
`gpforum --env-file FILE ...` reads another file.
5. **Mail.** Verification and reset mail leaves as setup's mail answer
says (`GPFORUM_MAIL_TRANSPORT`). The suggestion, `sendmail`, needs a
mail server on the host: `sudo apt install postfix` and choose "Internet
Site". Many VPS providers block port 25, and many mail servers refuse a
host without SPF and reverse DNS; then relay through a provider instead:
run `sudo gpforum setup` again and answer the mail question with `smtp
HOST:PORT USER`, Enter for the other two. It asks for the password
without echo. TLS follows `GPFORUM_SMTP_PORT`: STARTTLS on 587, implicit
TLS on 465 (`GPFORUM_SMTP_TLS` says otherwise), through IO::Socket::SSL,
which the dependencies install. Then send yourself a probe:
```sh
sudo -u gpforum gpforum mail-check --send --to ADDRESS --human # an address you read
```
6. **Antivirus.** Set the upload scan limits in `/etc/clamav/clamd.conf`, so
clamd scans a file as large as the upload limit, and prove it detects the
test file ([ops/antivirus.md](ops/antivirus.md) explains the report):
```sh
printf 'StreamMaxLength 251M\nMaxFileSize 250M\nMaxScanSize 512M\nAlertExceedsMax yes\n' | sudo tee -a /etc/clamav/clamd.conf
sudo systemctl restart clamav-daemon
sudo -u gpforum gpforum antivirus-check
```
7. **The services.** The web application, the outbox worker that sends the
mail, and the hourly and daily timers. `gpforum service print` writes
the units of `deploy/systemd/` for this host -- the code directory, the
environment file it read -- into the directory systemd reads them from:
its own six names, and nothing else there is touched. Then start them,
in one line:
```sh
sudo gpforum service print --to /etc/systemd/system
sudo systemctl daemon-reload && sudo systemctl enable --now gpforum gpforum-outbox gpforum-scheduled-jobs.timer gpforum-partition-maintenance.timer
```
It ends with that same line. To read the units before they go in place,
give `--to` a directory of your own; it then ends with the `cp`. The web
unit checks the host first ([Preflight](#preflight)); if it does not
start, `journalctl -u gpforum` says why.
8. **TLS and nginx.** Take a certificate, then put Good Phorum's site where
Debian's nginx reads it. `gpforum service print nginx` writes it for the
address in `GPFORUM_PUBLIC_BASE_URL` and the one the application listens
on, with the upload limit and `/metrics` kept to this host, and refuses
to put it in place before its certificate is there, since nginx would
refuse the site:
```sh
sudo certbot certonly --nginx -d "$site"
sudo gpforum service print nginx --to /etc/nginx/sites-enabled
sudo nginx -t && sudo systemctl reload nginx
```
Debian's default site stays: it answers only the names no other site
claims. Without nginx or certbot, the steps `service print nginx` and
`gpforum doctor` print begin with the `apt install` that brings them.
certbot's own timer renews the certificate.
Once the forum is serving, `curl -sS "https://$site/health/ready"`
answers `{"check":"ready","status":"ok"}` (or `degraded`, with a reason
under the metrics token, see [Health endpoints](#health-endpoints)).
9. **The forum's owner.** Make your account, which can sign in at once:
no mail and no database query are needed. It asks for the password
twice.
```sh
sudo -u gpforum gpforum admin create --email you@example.com --username you
```
Sign in at `https://$site/login`. To make another member an owner later:
`sudo -u gpforum gpforum admin grant their@example.com`.
Then check it all: `sudo -u gpforum gpforum doctor` goes from the settings
to the public address, one line each, with the command for anything to
fix. Under the settings it says what the host sized the forum for, such as
`sized for 2 CPUs, 2 GB: 4 web processes, 4096 cache entries a process`.
`sudo -u gpforum gpforum status` shows the running service's readiness
report ([ops/doctor.md](ops/doctor.md)).
Upgrading later is three commands, which `sudo -u gpforum gpforum upgrade`
prints for this host. The last one, `sudo -u gpforum gpforum doctor
--upgrade`, says what the upgrade left behind
([ops/upgrade.md](ops/upgrade.md)).
### The front door
Every command is a verb of `gpforum`, the one entrypoint in `bin/`. It
reads the service's environment file and the checkout's dependencies by
itself, so no shell sources the file first and nothing runs
`script/gpforum-carton exec`. `gpforum help --all` lists every verb, and
[ops/console-and-cli.md](ops/console-and-cli.md) gives each one's console
counterpart, or why there is none.
### Rotating a secret
`gpforum secret rotate session` writes a new `GPFORUM_SESSION_SECRET` into
the environment file and keeps the one before in `GPFORUM_SESSION_SECRETS`,
which still accepts the cookies it signed, so nobody is signed out. Restart
the service, and after 30 days, when every session has been renewed, drop
the old one:
```sh
sudo gpforum secret rotate session
sudo systemctl restart gpforum gpforum-outbox
sudo gpforum secret rotate session --finish # 30 days later
sudo systemctl restart gpforum gpforum-outbox
```
`gpforum secret rotate metrics` does the same for `GPFORUM_METRICS_TOKEN`,
with no restart, because the running service reads the tokens from the file
again (ADR 0124). Give every scraper the new token from the file, then run
`--finish`:
```sh
sudo gpforum secret rotate metrics
sudo gpforum secret rotate metrics --finish # once every scraper sends the new one
```
Both commands keep the file's owner and mode, print no secret, and say each
next step. `--dry-run` says what would change.
## Values production refuses
| Setting | Refused | Why |
| --- | --- | --- |
| `GPFORUM_PUBLIC_BASE_URL`, `GPFORUM_MAIL_FROM` | an address under `example.com`, `.net`, `.org`, `.example` or `.invalid`, in staging and production | The template's placeholders, left as copied, put a link nobody can follow in every mail, from a sender no server delivers for. |
| `GPFORUM_MAIL_TRANSPORT` | `test`, in production | It keeps mail in memory and sends none. |
| `GPFORUM_FORUM_READ_RATE_LIMIT` | anything but a whole number of at least 1 | It is now a setting like the others, and on the settings page; any other value used to be ignored without a word. |
## Preflight
The service files check the host before every start, with `gpforum
os-preflight --strict --json`; run the same check by hand before the first
one:
```sh
gpforum os-preflight # one line per finding
gpforum os-preflight --json # the same report as one JSON document
```
A check that fails -- no CPU count, no event backend -- stops the start, and
the command exits 1. A degraded one -- one CPU, a file descriptor limit below
65536, more web processes than the CPUs carry under
`GPFORUM_RUNTIME_WORKER_POLICY=configured` -- is reported and does not stop
it. Under the default `cap-to-cpu` policy the check counts the web processes
the manager is given, not the ones configured, so a 1-vCPU host starts.
`--strict`, which the systemd units and the FreeBSD rc script pass, also
writes each check that is not ok to stderr, one line each, so `journalctl -u
gpforum` shows it without the JSON around it:
```text
os-preflight: degraded recommended_worker_count: recommended worker count below configured threshold
```
### Upgrade note: the units run `bin/gpforum`
The service files run `bin/gpforum` alone, which finds the Perl and the
dependencies the checkout installed: `gpforum os-preflight --strict --json`
before the start, `gpforum start --service --foreground` for the prefork manager under
systemd, launchd and daemon(8), and `gpforum outbox`, `gpforum
scheduled-jobs` and `gpforum partitions` for the others. `script/` is the
maintainers' (owner decision D9). A unit copied before that ran
`script/os-preflight` and a `bin/gpforum-*` command through
`script/gpforum-carton exec`, and both are gone: `gpforum doctor` fails
such a unit and gives the `gpforum service print` that writes it again and
the restart that reads it.
## Identity mail delivery
Probe the transport before relying on verification, reset and email-change
mail: `gpforum mail-check --dry-run --human` checks it without sending
(`smtp`: a TCP connect, the password never printed; `sendmail`: the binary is
there), and `gpforum mail-check --send --to ADDRESS --human` delivers a
real verification probe to an address you read.
[ops/mail-check.md](ops/mail-check.md) explains each transport.
To sign in the forum before delivery, set `GPFORUM_DKIM_DOMAIN` to the From
domain, `GPFORUM_DKIM_SELECTOR` to the published selector and
`GPFORUM_DKIM_KEY_FILE` to a regular, readable private key with mode `0600`.
Publish its public key at `SELECTOR._domainkey.DOMAIN`. `gpforum doctor`
signs a probe and verifies it against DNS without sending it. A signing
failure makes the outbox retry. Leave the key path empty to keep MTA signing.
## Upload antivirus
Staging and production scan every upload with the operating system's ClamAV
and serve no new upload until it is clean (ADR 0108); attachments uploaded
before stay served until the hourly backfill has scanned them. Install the
system package (`apt install clamav-daemon clamav-freshclam`, `pkg install
clamav`, `brew install clamav`), set `StreamMaxLength 251M` in `clamd.conf`,
and prove it detects the EICAR test file with `gpforum antivirus-check`. To
run without an antivirus, set `GPFORUM_ANTIVIRUS=none` explicitly.
[ops/antivirus.md](ops/antivirus.md) has each system's steps and the socket
permissions.
## Connection admission
Hypnotoad recycles worker processes automatically after a randomized share of
10,000 accepted connections. `GPFORUM_RUNTIME_REQUESTS` instead limits requests
on each keep-alive connection; it does not recycle a worker.
At up to 250 configured clients per worker, listeners accept one connection per
event-loop turn. Larger budgets admit bounded groups through the native
Mojolicious listener API: up to 64 per turn for budgets through 4,096 clients,
and up to 512 above that. Cold bursts can then enter while existing keep-alive
connections remain busy. At these large budgets, a worker briefly yields
acceptance after its share of a new cohort, giving quieter workers a turn.
The same worker remains eligible for its complete native connection budget.
This keeps bursts moving
without letting one worker drain the entire listen queue. Native connection
limits and graceful shutdown still govern every resume. Explicit
`single_accept` listener settings take precedence; TLS listeners retain native
single-accept handshake behavior.
For budgets above 4,096, a prefork worker briefly pauses acceptance after its
share of a new connection cohort. Other workers can accept their share while
existing connections keep receiving responses. Each worker remains eligible
for its complete native connection budget after the pause.
The high-connection benchmark waits for warm-up sockets and pending accepts
to drain before starting each measured run. Its JSON records whether that
drain was observed; Linux provides both worker socket and listen-queue data.
A targeted evidence collection declares its required suites at initialization
(for example, `--suite http` for the connection gate). Missing planned cells
and failures still fail the gate; unplanned collectors remain marked skipped.
## Deployment Evidence
Run the Hypnotoad evidence gate before treating a deployment profile as
measured:
```sh
bin/gpforum hypnotoad-benchmark --check --profile small --workers 2 \
--iterations 20 --warmup 3 \
--route /categories \
--route /t/018f1004-0001-7000-8000-000000000001 \
--route /search?q=performance \
--route /health/ready \
--route /metrics
```
The benchmark starts an isolated temporary Hypnotoad runtime, records worker
metadata, compares against in-process evidence when enabled, observes DB query
counts through benchmark-only headers, and stops the server gracefully. See
`docs/DEPLOYMENT_EVIDENCE.md` for current local values and methodology.
## Perl dependencies
Good Phorum runs on **Perl 5.40 or newer**, independently of the host's Perl
package. Distribution packages, Homebrew, perlbrew, plenv, asdf, custom
prefixes are supported.
`GPFORUM_PERL` explicitly selects an executable path or command; otherwise
the active Perl on `PATH` is tried first, followed by common install paths.
An invalid explicit selection fails rather than choosing another runtime.
The historical script and `make system-perl` remain compatibility entry points.
```sh
export GPFORUM_PERL=/opt/perl/bin/perl # or activate a supported Perl on PATH
make perl-runtime
script/gpforum-system-perl --require
```
`local/` records the Perl version, architecture and ABI options used to
build its native dependencies. A mismatch requires
`script/bootstrap-deps --postgres --rebuild-local`, which preserves the old
tree. Equivalent interpreters at different prefixes remain compatible.
See [Perl runtime selection](ops/perl-runtime.md) for perlbrew,
supervisor environments and interpreter upgrades.
Host packages (before cpm):
- Debian/Ubuntu: `perl`, `build-essential`, `cpanminus`, `libpq-dev`,
`libssl-dev`, `zlib1g-dev`, `postgresql-client`
- FreeBSD: `perl5`, `p5-App-cpanminus`, `gtar`, and a PostgreSQL client matching
the server major. A self-contained PostgreSQL 18 host also needs
`postgresql18-server`, `postgresql18-contrib` (`pg_trgm`, `unaccent`) and
`postgresql18-pgvector` before migrations.
- macOS (Homebrew): `brew install perl cpanminus postgresql@18`. Homebrew
installs a versioned PostgreSQL keg-only, so put its client tools on
`PATH` with:
```sh
eval "$(script/gpforum-homebrew-env)"
script/gpforum-homebrew-env --check
```
That prepends `$(brew --prefix)/opt/postgresql@NN/bin` so `pg_config`,
`psql`, `pg_dump`, and `pg_restore` resolve from Homebrew. The helper
no-ops on Linux CI. After `brew upgrade perl`, rebuild `local/`
(`script/bootstrap-deps --postgres --rebuild-local`): its XS modules are
built for one Perl.
- Then: `cpanm -M https://cpan.metacpan.org/ App::cpm Carton` for the selected Perl
IO::Socket::SSL, for mail over TLS, builds Net::SSLeay against the host's
OpenSSL headers: `libssl-dev` on Debian and Ubuntu (`libpq-dev` does not
bring them), FreeBSD's base, and on macOS Homebrew's `openssl@3`, which
`postgresql@18` installs and `script/bootstrap-deps` finds (or set
`OPENSSL_PREFIX`).
Good Phorum recognizes runtime modules from `cpanfile`, PostgreSQL modules from
`cpanfile.postgres` (feature `postgres`), and exact distribution pins
from `cpanfile.snapshot`. Install through cpm under the selected Perl:
```sh
make install-deps-production
# equivalent: script/bootstrap-deps --postgres --production
```
That runs App::cpm (v1.1 or newer) through `script/gpforum-cpm`, with five
parallel workers, into `local/`. It reads the committed `cpanfile.snapshot`
using `--resolver snapshot --no-default-resolvers`: a missing or incompatible
pin fails instead of falling back to current CPAN releases. The lock is not
rewritten. Runtime and test requirements and the postgres feature are selected;
the develop tools (Perl::Critic, Perl::Tidy, Devel::Cover, Devel::NYTProf and
their dependencies) are omitted, per ADR 0109. Development and CI keep them
with `make install-deps-postgres`.
Set `GPFORUM_CPM_WORKERS=2 make install-deps-production` to limit concurrent
builds on a host with little memory. The default is five; the value must be a
positive integer. `PERL_CARTON_MIRROR=https://cpan.metacpan.org/` remains the
mirror setting; another HTTPS mirror or a local path is accepted.
`--home local` keeps source archives in `local/cache`, where Carton bundling
expects them. `--final-install all` retains build dependencies as
before. `--cached` uses only the `vendor/cache` mirror produced by
`script/gpforum-carton bundle`.
Upstream module tests remain disabled (`--no-test`), matching the previous
Carton install policy.
App::cpm and Carton are host tools for the selected Perl. Carton supplies
`Carton::Snapshot` to cpm and still handles `--update`, `check` and `bundle`.
`GPFORUM_CPM=/path/to/cpm` locates a cpm executable outside the PATH; the
wrapper still runs it with the validated selected Perl.
`script/bootstrap-deps --postgres --update` refreshes the lock on a development
host; production cannot combine `--production` with `--update`. Running
Good Phorum needs neither installer: the front door and service units put `local/`
on `@INC` under the Perl that installed it.
Reproduce an install:
1. Use the same Perl ABI and architecture that produced `local/` (do not delete
`local/` to paper over a mismatch after a distro Perl upgrade).
2. Keep `cpanfile`, `cpanfile.postgres`, and `cpanfile.snapshot` from the
same git commit.
3. Run `script/bootstrap-deps --postgres --production` then
`script/gpforum-carton check`.
4. Optional air-gap: `carton bundle` on a trusted host, copy `vendor/cache`,
then `script/bootstrap-deps --postgres --production --cached`.
## Linux With systemd
The units in `deploy/systemd/` are what the [install](#install-on-debian-or-ubuntu)
copies, as `gpforum service print systemd` writes them for the host:
`gpforum.service`, `gpforum-outbox.service`, the
outbox worker without which no mail leaves, and the two timers below. They
run as `gpforum` from the code directory (`/opt/gpforum` in the templates),
read the environment file (`/etc/gpforum/gpforum.env`), and stop
gracefully with `systemctl stop`. Restart after changing the environment
file; `systemctl reload` is refused on purpose
([ops/reload-and-restart.md](ops/reload-and-restart.md)).
The web unit uses `Type=notify` and supervises the foreground manager directly.
`systemctl start` waits for a healthy worker to answer `/health/ready`.
The manager then probes `/health/live` every ten seconds and sends watchdog
notifications; systemd restarts it if notifications stop for 30 seconds.
Graceful stop sends `TERM` to the manager and allows 150 seconds to drain.
Watchdog abort handling has a separate 15-second deadline before forced cleanup.
See [ADR 0155](adr/0155-host-readiness-mail-and-reproducible-data.md), including
the Linux validation requirement.
The systemd units create `/run/gpforum` with `RuntimeDirectory=gpforum`, and
The manager keeps its internal PID file there. systemd does not read that file.
The units set only `GPFORUM_ENV=production` and the log path; every other
setting takes its default unless
`/etc/gpforum/gpforum.env` sets it, so an override belongs in that file, not
in a copy of the unit.
Hourly operational sweeps are a timer, not a daemon
(`gpforum-scheduled-jobs.timer`, which the install starts with the rest).
The oneshot unit runs `gpforum scheduled-jobs --once --limit 100` through
`bin/gpforum`. It deletes stale sessions, rate-limit buckets,
identity tokens, completed outbox rows, and dead letters in bounded
batches, then calls attachment orphan cleanup and partition
policy/evidence. It does not loop and does not execute partition DDL.
See `docs/ops/scheduled-jobs.md`.
The monthly partitions of `audit_log`, `event_log` and `notifications` are
kept ahead by a daily timer of their own (ADR 0113),
`gpforum-partition-maintenance.timer`, also started by the install. It runs
`gpforum partitions --apply`, which creates the current month and the two
after it where they are missing. The parent tables stay open to writes;
reads that open the DEFAULT partition, and the event and audit writes that
start with one, wait for each attach, half a second at most plus the scan
of DEFAULT. Enable it on every node: the runs take an advisory lock, and
the ones that do not get it do nothing. It needs the role
that owns those tables -- the one the migrations run as; if the application's
role is not the owner, give the unit an `EnvironmentFile` with the migration
role's DSN. `gpforum migrate` does the same after every deploy's
migrations. See `docs/ops/partition-maintenance.md`.
## FreeBSD With rc.d
Example:
```text
deploy/freebsd/gpforum
deploy/freebsd/gpforum_outbox
deploy/freebsd/gpforum_jobs
deploy/nginx/gpforum.conf
```
Recommended operator actions:
- install under `/usr/local/www/gpforum`;
- run as a dedicated `gpforum` user: `sudo gpforum setup` makes it with
`pw useradd`, writes the environment file below and makes the database,
as on Debian;
- put the service's environment -- `GPFORUM_DATABASE_DSN`,
`GPFORUM_SESSION_SECRET` and the rest of `GPFORUM_*`, as on Linux -- in
`/usr/local/etc/gpforum/gpforum.env` (`gpforum_env_file` in rc.conf to move
it), owned by `root:gpforum` with mode `0640`. The rc script exports it into
the service and refuses to start if the file is readable by others or
writable by its group: rc.conf variables are never exported, so this file
is the only way the configuration reaches the application. Its
`GPFORUM_ENV` is the mode; rc.conf's `gpforum_env` is the mode of a file
that names none;
- put both rc scripts in `/usr/local/etc/rc.d/` and the jobs' crontab in
`/usr/local/etc/cron.d/`, and enable both services:
`sudo gpforum service print rc --to ~/gpforum-rc` writes the three for
this checkout and environment file, and ends with the copies and
`sysrc gpforum_enable=YES`, `sysrc gpforum_outbox_enable=YES` and the
starts. `gpforum_outbox` is the outbox worker, which sends the mail; it
reads the same environment file, and both create `/var/log/gpforum` for
the `gpforum` user;
- `service gpforum stop` sends TERM to daemon(8), disabling restart and
forwarding TERM to the foreground manager to drain requests; rc waits
for the supervisor tracked through `/var/run/gpforum/daemon.pid`;
the manager defaults to `/var/run/gpforum/app/gpforum.pid`, in a private
directory owned by its service account;
- tune file descriptor limits through login class or service wrapper;
- use nginx, Caddy, or another local reverse proxy for TLS/static transfer;
- consider jails for process isolation;
- the crontab (`deploy/freebsd/gpforum_jobs`) runs `gpforum scheduled-jobs
--once` hourly and `gpforum partitions --apply` daily as `gpforum`, the
systemd timers' counterparts, through `bin/gpforum`, which reads the
environment file.
## macOS With launchd
`gpforum service print launchd` writes these for the host -- the code
directory in place of `/opt/gpforum`, the logs under Homebrew's prefix:
```text
deploy/launchd/com.gpforum.app.plist
deploy/launchd/com.gpforum.outbox.plist
deploy/launchd/com.gpforum.scheduled-jobs.plist
deploy/launchd/com.gpforum.partition-maintenance.plist
```
`com.gpforum.outbox` keeps the outbox worker running, which sends the mail.
`com.gpforum.scheduled-jobs` is a 3600s interval sample for the same oneshot
command. It is not KeepAlive. `com.gpforum.partition-maintenance` runs
`gpforum partitions --apply` every 86400s, as the systemd timer does daily.
Each plist runs as the `gpforum` account, not as root, and through
`bin/gpforum`, which reads `$(brew --prefix)/etc/gpforum/gpforum.env` too.
launchd has no environment file, so each job reads it before it starts and
takes its mode from the file's `GPFORUM_ENV` -- `production` when it names
none, as under systemd -- and the plists set only the log and, for the web
service, the pid file. Clone the code where the `gpforum` account can read
it, such as `/opt/gpforum`, not under a home directory, which macOS keeps
closed to other accounts.
From a Mac with Homebrew to a signed-in owner behind TLS, this section is
the one document. First the packages -- PostgreSQL, nginx with certbot --
and App::cpm plus Carton for Homebrew's Perl:
```sh
brew install perl cpanminus postgresql@18 nginx certbot
brew services start postgresql@18
"$(brew --prefix)/bin/perl" "$(brew --prefix cpanminus)/bin/cpanm" -M https://cpan.metacpan.org/ App::cpm Carton
```
Then eight commands, with your forum's name for `forum.example.org`:
```sh
sudo git clone https://github.com/gipidevdor/GPForum.git /opt/gpforum
sudo /opt/gpforum/bin/gpforum setup
sudo gpforum service print --to /Library/LaunchDaemons
sudo launchctl bootstrap system /Library/LaunchDaemons/com.gpforum.*.plist
sudo certbot certonly --standalone -d forum.example.org --pre-hook 'brew services stop nginx' --post-hook 'brew services start nginx'
sudo gpforum service print nginx --to "$(brew --prefix)/etc/nginx/servers"
sudo nginx -t && sudo brew services restart nginx
sudo -u gpforum gpforum admin create --email EMAIL --username NAME
```
`/opt/gpforum/bin/gpforum setup` does what it does on Debian, the account
included: it installs the dependencies, links `gpforum` into Homebrew's
`bin`, makes the group and the account `gpforum` with `dscl`, under the
highest id below 500 that no user and no group has, and `var/`, where
the manager keeps its PID file and the uploads go, under the checkout. It
writes the environment file (`$(brew --prefix)/etc/gpforum/gpforum.env`,
0640 root:gpforum), makes the database as your own PostgreSQL role, which
Homebrew's trusts, and brings the schema up to date. `service print --to
/Library/LaunchDaemons` puts the plists in place and, as root, makes the
directory they log to, `$(brew --prefix)/var/log/gpforum`, the `gpforum`
account's, 0750; then they are loaded at once, and the site goes where
Homebrew's nginx reads it, after its certificate. Each command ends by
saying the next one for this host; nginx runs as root under `brew
services`, which reads the certificate certbot keeps root's, and certbot's
renewals stop and start it as the first one did.
The jobs are loaded too: a plist launchd has not loaded never runs. With
`gpforum --env-file FILE service print launchd`, each job hands that file to
`bin/gpforum` with `--env-file`, as the systemd units and the rc scripts
do, so the web service follows its metrics tokens with no restart.
macOS remains primarily a development and profiling platform. It is supported
for local persistent runs, but production throughput tuning should be measured
on the target FreeBSD or Linux deployment host.
## Reverse Proxy
Examples, which `gpforum service print nginx` and `gpforum service print
caddy` write for the host -- the forum's name from
`GPFORUM_PUBLIC_BASE_URL`, the address the application listens on, the
code directory and the attachment store -- and end with where this
operating system's nginx or Caddy reads them:
```text
deploy/nginx/gpforum.conf
deploy/caddy/Caddyfile
```
The proxy should preserve:
- `Host`
- `X-Forwarded-For`
- `X-Forwarded-Proto`
- WebSocket upgrade headers for `/realtime`
Static `/assets/` files live in the repository `assets/` tree
(`/opt/gpforum/assets` after install). Do not point the proxy at
`public/assets`; that path does not exist.
At the site root, nginx and Caddy search `assets/css`, `assets/img` and
`assets/js`, in the same order as the application. Missing files go to the
application. nginx explicitly buffers request bodies before forwarding them
and buffers upstream responses, isolating the application from slow client
transfers within the proxy's buffer and timeout limits.
The nginx site enables sendfile and gzip for static text assets. Its upstream
pool reuses normal HTTP connections: `Connection` is cleared for those
requests and set to `upgrade` only for an upgrade request. WebSocket
connections still use their explicit handshake. Application response
compression remains handled by GPForum. After updating the site, run
`nginx -t` before reloading nginx.
Web processes are sized automatically by default. Compare worker counts on
the target host before overriding `GPFORUM_WEB_PROCESSES`, and reassess the
PostgreSQL connection/memory budget across every web and background node.
Worker recycling is automatic from the first start and needs no environment
setting. Each web process is gracefully replaced after at most 10,000
accepted connections; The prefork manager randomizes the threshold to stagger
replacements. This is built-in application policy.
`GPFORUM_RUNTIME_REQUESTS=1000` limits requests on one keep-alive connection.
Lowering that request limit does not recycle the process and increases
connection churn.
`GET /attachments/:attachment_id/download` is an application route. Nginx must
proxy that URL to the application. A `location /attachments/ { internal; }` prefix
intercepts the download and returns 404.
For the filesystem backend, nginx can send the file once the application has
authorized the download. Set `GPFORUM_ATTACHMENT_ACCEL_REDIRECT=/internal-attachments/` in the
environment file: the application then answers with an `X-Accel-Redirect`
header instead of the bytes, and nginx serves the file from its
`/internal-attachments/` alias. The shipped alias is
`/opt/gpforum/var/attachments/`, the default `GPFORUM_ATTACHMENT_ROOT`; if you
move the attachment store, change both. nginx reads the files as its own user,
and the store belongs to `gpforum` with mode `0750`: put nginx's user in the
`gpforum` group (`sudo usermod -aG gpforum www-data`, then `sudo systemctl
restart nginx`), or every download answers 403. Leave the setting empty
behind Caddy, which has no internal alias in the sample: a proxy that does not
understand the header would pass it to the browser.
The default PostgreSQL attachment backend returns an application asset rather
than a filesystem path. Downloads use native `Mojo::Pg` promises and retain at
most one 1 MiB database chunk per response. The next HTTP chunk is read only
after the preceding write completes; disconnects stop further reads. HEAD,
ETag and range requests keep their usual semantics. `X-Accel-Redirect` applies
to filesystem assets only.
HTTP dispatch prepares configuration, validates the server session and resolves
viewer state asynchronously. Controllers use Mojolicious async/await; native
readers cover category and thread listings, search, thread extras, permission
checks, rate limits and session reads, and every other service a request
reaches answers the same way: reads and writes run on the worker's pooled
connections, never in a forked child (ADR 0176). A service without an
asynchronous twin fails its request instead of forking. CPU-bound work
(Argon2, images, rendering, antivirus) runs in a child that cannot open
PostgreSQL. A synchronous PostgreSQL checkout in the HTTP request context is
rejected before it can block the reactor. CLI service interfaces remain
synchronous.
Native transactions own one leased connection through BEGIN, savepoints,
COMMIT and rollback. They never borrow another workflow's transaction or
replay an ambiguous commit. Query cancellation (SQLSTATE 57014) rejects even
when DBD::Pg reports a zero-row result. Prepared-plan retirement runs
asynchronously; DBI statement destruction does not send synchronous DEALLOCATE.
Connection handshakes use libpq asynchronous connect and reactor readiness
watchers with a five-second deadline. OS hostname resolution runs in a
CPU-lane child that cannot open PostgreSQL; numeric addresses are pinned while
retaining the configured hostname for TLS. A libpq service definition with a
hidden hostname uses an isolated connection probe before the owned connection.
Cache invalidation and realtime share one dedicated, idle LISTEN connection.
Cache reads drain local notification queues without checking a database handle
out or pinging it. Realtime authorization, current visibility, badge counts,
session revocation and backstop polling complete asynchronously; registries
and sockets stay in the web process. OIDC uses approved public HTTPS peers and
reactor-driven DNS. WebSub resolves its operator-configured hub outside the
reactor and retains its native user agent until forwarding completes.
Argon2id, account transactions, avatar processing, upload validation and scans,
archive generation, uncached Markdown and QR generation run in bounded
`Mojo::IOLoop::Subprocess` children. Password hashing costs and validation rules
are unchanged. The manager warms the cost-keyed decoy hash before forking, so
unknown-account checks verify it without generating a second hash per child.
Queued work from a disconnected request is skipped; active mutations finish.
Legacy filesystem downloads and archives stream at most 256 KiB at a time,
with the next read admitted only after the preceding write drains. Database
media retains its existing one-chunk bound. HEAD, ranges and conditional
responses keep their semantics.
Each worker admits two concurrent native SQL operations, one CPU-lane child
and four outbound integration HTTP requests. Executor queues hold at most 256
additional operations for at most fifteen seconds. Saturation rejects work.
The database budget is three connections per web worker: two native query
leases and one shared listener, plus the existing operational headroom. No
child of a web worker opens a connection (ADR 0176). Worker startup warms the two query leases
asynchronously after fork. No operator setting is required for this behavior.
`Mojo::File` itself is synchronous. Small metadata operations and Mojolicious's
request parsing, logging and template machinery retain their normal framework
semantics. Keep nginx or Caddy in front for static assets, upload buffering,
TLS and slow clients. GlifiStore is not required or enabled by this migration.
nginx accepts request bodies up to 251 MiB (`client_max_body_size`), enough for
audio or video at Good Phorum's 250 MiB limit and the form around it. Images
and documents retain 25 MiB; profile originals retain 5 MiB.
`/metrics` answers the loopback only, in the nginx configurations and the
Caddyfile alike: the scraper runs on the same host.
PostgreSQL-backed media is streamed by Good Phorum; the internal attachment
alias applies to the filesystem compatibility backend.
For static assets and filesystem attachments, prefer web-server file transfer with cache
headers and sendfile support. Good Phorum can authorize access, but Perl should not
be the ordinary large-file transfer path.
## Health endpoints
`/health/live` answers `status`, `check` and `time` to anyone and depends on
nothing: point process supervisors and liveness probes at it.
`/health/ready` answers 200 while the node can serve (`ok` or `degraded`)
and 503 when it cannot (`fail`). Load balancers and probes act on that code
alone. Without the metrics token the body is the overall status only,
`{"check":"ready","status":"degraded"}`; the full report -- every check, its
error, the replication slots and partitions it names, the runtime -- goes
only to a request carrying the same token `/metrics` takes, as
`Authorization: Bearer ` or `X-GPForum-Metrics-Token: `, the
current `GPFORUM_METRICS_TOKEN` or one of `GPFORUM_METRICS_TOKENS` during a
rotation. On the host, `sudo -u gpforum gpforum status` asks for it with
the token from the environment file and writes it one line per check; a
scraper sends the header itself:
```sh
curl --unix-socket /opt/gpforum/var/run/gpforum.sock \
-sS -H "X-GPForum-Metrics-Token: $GPFORUM_METRICS_TOKEN" \
http://localhost/health/ready | jq '.checks[] | select(.status != "ok")'
```
A wrong or stale token gets the status alone, not a 401 as on `/metrics`:
the code must not change with the token, or a probe left with an old token
after a rotation would take every node out of service. `GET /health`, the
config/runtime/OS summary, answers `{"status":"ok"}` without the token and
the full summary with it. Development and test, where no token has to be
configured, serve the full reports to anyone, as they serve `/metrics`;
staging and production refuse to start without one. Both token-dependent
endpoints answer `Cache-Control: no-store`, so no shared cache replays a full
report to an anonymous client. Unlike `/metrics`, which the shipped nginx
configurations also restrict to `127.0.0.1`, the health endpoints stay
reachable from outside because load balancers need the code: the full reports
rest on the token alone, so keep it out of probe configurations that only need
the status.
## UNIX Socket Mode
Unix sockets are the default for both local PostgreSQL and proxy-to-application
connections. The default `GPFORUM_DATABASE_DSN=dbi:Pg:dbname=gpforum` uses
libpq's packaged socket directory when no libpq host environment override is
present. For an explicit local connection in `/etc/gpforum/gpforum.env`, use:
```sh
# Debian / Ubuntu
GPFORUM_DATABASE_DSN="dbi:Pg:dbname=gpforum;host=/var/run/postgresql"
# macOS / Homebrew or FreeBSD, when PostgreSQL uses its packaged default
# GPFORUM_DATABASE_DSN="dbi:Pg:dbname=gpforum;host=/tmp"
```
`host=` takes the **directory**, not the socket filename. A directory selects
Unix-domain communication; `host=localhost` or `host=127.0.0.1` selects TCP.
If a DSN also includes `port=5432`, that number selects `.s.PGSQL.5432` in
the directory; it does not turn a Unix socket connection into TCP. These are
[libpq connection parameters](https://www.postgresql.org/docs/18/libpq-connect.html#LIBPQ-PARAMKEYWORDS).
Keep `hostaddr` and `PGHOSTADDR` unset for this local configuration, and avoid
an inherited `PGHOST` when relying on the directory default.
Use the same socket directory for an explicit `GPFORUM_ADMIN_DATABASE_DSN`
and, when enabled, the separate `GPFORUM_MINION_PG_URL`. For example,
`postgresql://gpforum@%2Fvar%2Frun%2Fpostgresql/gpforum` selects the Debian
socket directory for Minion; its role still needs the corresponding local
authentication and job permissions. `gpforum doctor` prints the actual
`DIRECTORY/.s.PGSQL.PORT` endpoint and reports a deployed loopback TCP DSN.
A remote database takes an explicit TCP host as a separate topology. The
systemd units run with `PrivateTmp=true`; for a socket under
`/tmp` the printed units bind its directory in read-only, and for one in
`/tmp` itself they run without a private `/tmp` and say how to give the
server a directory of its own (`unix_socket_directories`); `gpforum doctor`
fails `database_socket` when an installed unit hides the socket (ADR 0137).
Over the socket a distribution authenticates by `peer`, which admits an
account only as the role of its name: the `gpforum` account as
`gpforum_runtime` (ADR 0127) and the operator as `gpforum` need a map.
`sudo gpforum setup` prints the `pg_hba.conf` and `pg_ident.conf` lines
this host needs, and `gpforum doctor` repeats them under a refusal; the
[runtime role guide](ops/sql-api-role.md#peer-authentication-on-the-socket)
explains them.
When root runs `setup`, database provisioning stays privileged; connection
checks, migrations and owner lookup use the service account. This matches
the identity PostgreSQL sees after the service starts, including peer auth.
The service's default is `http+unix://var%2Frun%2Fgpforum.sock`. The service
starts in the code directory and creates `var/run` with mode `0750` before
binding the socket. The socket, mode `0660`, is the `gpforum` group's to
connect to and closed to other users. The service itself runs under umask
`027`: the uploads it stores are `0640` and their directories `0750`, so the
proxy's account, which joins the group, reads them and cannot replace them.
nginx and Caddy configurations printed with
`gpforum service print` resolve the same path under the deployed code
directory, and say the command that adds the proxy's account (`www-data`,
`caddy` or `www`) to the group. `gpforum start --service` refuses a TCP
`GPFORUM_RUNTIME_LISTEN` and says what to set; the printed sites forward
to the socket whatever that setting says. There is one unit and one site: the `gpforum-unix-socket`
variants are gone, so a host that installed one installs `gpforum.service`
and `gpforum.conf` in its place (ADR 0137).
A connection on the socket carries no client address, so the service takes
the client from the last `X-Forwarded-For` entry: the one the proxy appends
(`$proxy_add_x_forwarded_for` in the nginx files, Caddy's default). A proxy
of your own must append it too, or every request is keyed by its connection
alone and the rate limits stop following a client. Entries before the last
are never read, so a client cannot choose its own address (ADR 0086).
The browser-facing development command `gpforum start --foreground` retains
its explicit HTTP listener on port 3000. Internal local connections use Unix
sockets; the public browser-to-proxy listener remains HTTP/HTTPS.
For an absolute socket path, the environment file can set:
```text
GPFORUM_RUNTIME_LISTEN=http+unix://%2Frun%2Fgpforum%2Fgpforum.sock
```
In a unit file each `%` must be written `%%`: systemd
reads `%2` as a specifier it does not know and drops the whole line, and the
service keeps its default listen address instead. Prefer the environment file,
which takes single `%` characters and does not duplicate the unit.
When using Unix sockets, `reuseport` is not applied. Add the proxy's worker
account to the `gpforum` group before starting it: on Debian, use
`sudo usermod -aG gpforum www-data` for nginx or
`sudo usermod -aG gpforum caddy` for Caddy, then restart the proxy. On FreeBSD,
use `sudo pw groupmod gpforum -m www` for nginx (or its configured worker
account). The code directory and every socket parent must allow that group
to traverse them. For a custom socket outside the code directory, provision
its parent with ownership `gpforum:gpforum` and mode `0750` before startup.
## Realtime
Every web process LISTENs on PostgreSQL and serves only its own websockets;
nodes need no sticky sessions and share no presence state. See
`docs/realtime.md`.
- Keep `GPFORUM_REALTIME_LISTENER_ENABLED=1` (the default) on every web
process. Notification badges reach sockets only through NOTIFY and the
listener, including those changed by the same process.
- The connection quota (8 per user) is counted per worker process. The
cross-node limit is the PostgreSQL-backed `realtime.connect` rate limit
(30 per minute per user).
- After a PostgreSQL restart or failover, each web process clears its L1
cache once, on its next cache read, and re-sends notification badge counts
to its sockets, one count query per connected user: expect a short rise in
queries.
- A web process that cannot LISTEN (pointed at a standby, say) clears its L1
on every cache read until it can, and `listen_failures` rises in
`/metrics`.
- After a deploy or a worker recycle, the outbox backstop starts at the head
of the outbox; nothing is replayed. It runs only in processes that have
websocket connections.
- Keep every host's clock on NTP. The backstop waits five seconds for an
outbox row to settle, and that window also absorbs the offset between a
worker's clock, which stamps the row, and the database's.
## GlifiStore (optional)
A single host needs no GlifiStore: with `GPFORUM_GLIFISTORE_URL` empty, the
default, each process keeps its own cache in front of PostgreSQL and readiness
says so in a note. Set it to share one cache between hosts. PostgreSQL remains
the only source of truth. An unreachable GlifiStore degrades readiness to
process-local L1 and PostgreSQL; it does not become authoritative.
After a failed call (unreachable, timed out, overloaded) each process skips
GlifiStore for 15 seconds, then tries again; the pause is fixed, not
configured. While it runs:
- readiness reports `local-fallback` without connecting;
- pages come from L1 or are rendered from PostgreSQL;
- cache invalidations do not reach GlifiStore, so an entry that should have
been invalidated stays in L2 until its TTL (`category_cache_ttl_seconds`).
L1 never keeps an entry filled from L2 longer than L2 would.
`/metrics` shows the pause under `local_caches[0].l2`: `retry_after_epoch`
while it runs, and `stats.skipped` for the calls it skipped. `stats.failures`
counts real failures only; erasing a key that is not there is not one.
Invalidating a tag is one ERASE of that tag's token. Entries cached by a
release before tokens carry none, so each misses once after the upgrade.
`GlifiStore::Client` is **not** a Carton or `cpanfile` pin: it is not published
on PAUSE, and this repository does not vendor a GlifiStore server or Perl
client. `Service::Operations::SharedCache` loads the client at runtime:
```perl
require GlifiStore::Client;
GlifiStore::Client->connect(%endpoint);
```
The expected client surface is `connect`, `get`, `put`, `erase`, and `ping`
(`t/lib/GPForum/Test/SharedCacheClient.pm` is the in-tree stand-in). There is
no installable GlifiStore binary or CPAN distribution in this project.
To use one:
1. Run a GlifiStore server on a Unix socket and set `GPFORUM_GLIFISTORE_URL`
to `unix://var/run/glifistore.sock` (or an absolute socket path).
2. Install `GlifiStore::Client` onto the same Perl that owns `local/` (private
distribution or extra `PERL5LIB`).
3. Set `GPFORUM_GLIFISTORE_URL` in `/etc/gpforum/gpforum.env`.
Without the operator-supplied server and client, shared L2 cannot connect;
leave the URL empty instead.
## PostgreSQL Baseline
Select and verify an explicit sized PostgreSQL profile before production:
[`gpforum postgres-performance`](ops/postgres-performance.md) inspects effective
settings, exports a plan, applies individually authorized changes to an identified
controlled cluster, and verifies activation. Production and staging connections
warn on drift, readiness exposes findings, and opt-in
`GPFORUM_POSTGRES_STRICT=1` refuses nonconforming connections. Remote/managed
servers require declared server resources; shared hosts default to conservative
memory reserves. Server restarts remain an operator action.
Minimum recommended posture:
- application role is not PostgreSQL superuser;
- migrations use a separate role;
- `statement_timeout` defaults to 15s on app connect
(`GPFORUM_DATABASE_STATEMENT_TIMEOUT_MS`; `gpforum migrate`
clears it);
- `idle_in_transaction_session_timeout` defaults to 10s on app connect
(`GPFORUM_DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS`);
- `lock_timeout` defaults to 3s on app connect
(`GPFORUM_DATABASE_LOCK_TIMEOUT_MS`);
- search and autocomplete set their own `statement_timeout` of 2s for the
transaction they run in (`GPFORUM_SEARCH_STATEMENT_TIMEOUT_MS`; 0 leaves
them under the 15s above) and rank only the newest 1000 matches
(`GPFORUM_SEARCH_CANDIDATE_LIMIT`);
- each connection names its process in `application_name` (`gpforum-web`,
`gpforum-outbox`, `gpforum-migrate`, ...) and gives up after
`connect_timeout=5`, unless the DSN sets them (ADR 0137);
- backups and restore tests exist before production launch: `gpforum
backup` nightly and `gpforum restore --check` on each
([ops/backup-and-restore.md](ops/backup-and-restore.md));
- with a streaming standby ([ops/standby-and-failover.md](ops/standby-and-failover.md)),
the application role is granted `pg_read_all_stats`
(`GRANT pg_read_all_stats TO gpforum;`): `/metrics` reports each standby's
replay lag and bytes behind under `replication`, and without the grant
PostgreSQL hides them (`standby_details_visible` is 0). Not `pg_monitor`:
it adds `pg_read_all_settings`, which reads a standby's `primary_conninfo`,
replication password included when one was given. `/health/ready` degrades
its `replication_slots` check when an inactive replication slot keeps more
than 1 GiB of WAL or a slot is lost: a standby that stopped following, whose
slot keeps the primary's WAL until the disk fills. The runbook is
[ops/standby-and-failover.md#watch-the-lag](ops/standby-and-failover.md#watch-the-lag).
For a repeatable local/staging migrate + `pg_dump`/`pg_restore` rehearsal on
throwaway databases, see `docs/ops/staging-drills.md`
(`bin/gpforum staging-drill`). For populated `var/attachments` filesystem
backup/restore plus static and host-available nginx/systemd checks, use
`bin/gpforum staging-drill-attachments`. Live service/TLS host bring-up is
documented in `docs/ops/staging-host.md` with non-destructive
`bin/gpforum staging-host-verify` (env-file key presence, systemd activity, HTTP
health). That verify does not install units or claim private-beta readiness.
## Tuning By OS
For read-only SQL cost, lock, vacuum/freeze, index and checkpoint diagnostics,
use [ops/postgres-diagnostics.md](ops/postgres-diagnostics.md) with a separate
operator connection. The extension is optional; absence and insufficient
visibility are explicit. Do not grant additional catalog access to the web
role merely for this command. The current PostgreSQL audit and decisions are
in [POSTGRESQL_RELIABILITY.md](POSTGRESQL_RELIABILITY.md).
Linux:
- use `epoll` through Mojolicious runtime selection;
- prefer systemd unit limits over application-side tuning;
- use nginx/Caddy for TLS, compression, static files, and sendfile;
- inspect sockets with `ss` and process limits with `/proc`.
FreeBSD:
- use `kqueue`;
- prefer rc.d and login class limits;
- consider jails for isolation;
- inspect runtime with `sockstat`, `systat`, `top`, and `netstat`.
macOS:
- use `kqueue`;
- keep runtime simple for development;
- do not treat macOS benchmark results as production throughput proof.
## Non-Goals
Good Phorum does not automatically:
- modify sysctl;
- require root;
- pin CPU affinity;
- install systemd/rc.d/launchd files;
- require Redis;
- require Kubernetes;
- replace PostgreSQL as authoritative storage.
## Optional Push sender
When `GPFORUM_PUSH_ENABLED=1`, `gpforum service print` also generates the
separate encrypted-notification sender for the selected service manager.
Its start, restart and doctor checks follow the configured service list.
Generate the persistent keys and configure the provider/contact prerequisites
before starting it; see [Web Push](ops/web-push.md). Stop the sender before
disabling an already deployed Push installation.