Provisions the backend on a single Debian host with clean bash, a uWSGI Emperor vassal, and systemd: a dedicated non-root user, a dedicated PostgreSQL cluster (leaving any other clusters/databases on the box untouched), nginx as an HTTP reverse proxy, and off-host pgBackRest durability with point-in-time recovery. TLS is deliberately left to the operator.
Scope: deploying the current code. Broader design/maintainability cleanup and an application-logic audit are separate efforts, out of scope here.
| Path | Purpose |
|---|---|
/opt/session-pro-backend |
code checkout + .venv (owned by pro-backend) |
/etc/session-pro-backend/config.ini |
app config (db_url + platform toggles managed by the installer) |
/etc/session-pro-backend/keys/ |
Apple .p8, Google ADC JSON, Apple root certs |
/etc/uwsgi-emperor/vassals/pro-backend.ini |
uWSGI Emperor vassal (owned by pro-backend; tyrant runs it as that user) |
/run/pro-backend/pro-backend.sock |
uWSGI socket (dir created via tmpfiles.d) |
/var/log/pro-backend/ |
application log |
PostgreSQL cluster session_pro |
dedicated cluster, auto-assigned port, referenced by name |
- A Debian target host with root access (these servers ship without
sudo—deploy.shruns directly as root, and every command it invokes as another user usesrunuser, notsudo). - A DNS name pointing at the host (for nginx
server_nameand, later, TLS). - The PGDG and oxen apt repositories configured first (see "Prerequisite apt repositories"
below).
deploy.shchecks for them and refuses to run otherwise; it does not edit apt config. - A running uWSGI tyrant Emperor (
emperor-tyrant = true+cap = setgid,setuid) — shared fleet infrastructure this script does not manage. It only drops a vassal in, and refuses to install if the Emperor isn't tyrant (the vassal must run unprivileged, not as root). - For off-host backups (repo-host-initiated, "Option 2"): a separate repository host that does
not run PostgreSQL. You configure it after the primary deploy by running
scripts/pgbackrest/repo-host-setup.shthere — it installs pgbackrest, creates the repository, and schedules backups that pull PGDATA from the primary over SSH. SSH is bidirectional: the primary pushes WAL to the repo host, the repo host pulls backups from the primary.deploy.shprints the repo-host handoff values and the archive-push key to authorise. This path is verified end-to-end (continuous WAL archiving + a base backup) against a live primary + repo host on pgbackrest 2.58. - For live platforms (optional at first): Apple in-app-purchase key (
.p8) + IDs, and/or a Google Cloud service-account JSON authorised to the Pub/Sub subscription. You can deploy with platforms disabled and add these later.
deploy.sh installs pgbackrest from PGDG (pinned, so its version matches the repo host — the
distro version drifts across Debian releases and mismatched versions break remote backups) and
python3-session-util from the oxen repo. It does not create these repositories — they are
system apt config you own — it only checks they provide those packages and stops with instructions
if not. Set them up once per host ($(lsb_release -sc) fills in the codename — bookworm, trixie, …):
install -d -m 0755 /etc/apt/keyrings
# PGDG — pinned so ONLY pgbackrest is taken from it (everything else stays on the distro):
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/keyrings/pgdg.asc
echo "deb [signed-by=/etc/apt/keyrings/pgdg.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -sc)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
cat > /etc/apt/preferences.d/pgdg.pref <<'EOF'
Package: *
Pin: origin apt.postgresql.org
Pin-Priority: 1
Package: pgbackrest
Pin: origin apt.postgresql.org
Pin-Priority: 600
EOF
# oxen — python3-session-util (the Session onion-request binding):
curl -fsSL https://deb.oxen.io/pub.gpg -o /etc/apt/keyrings/oxen.gpg
echo "deb [signed-by=/etc/apt/keyrings/oxen.gpg] https://deb.oxen.io $(lsb_release -sc) main" \
> /etc/apt/sources.list.d/oxen.list
apt-get update
apt-cache policy pgbackrest python3-session-util # pgbackrest's candidate version must contain 'pgdg'Both the primary and the repo host need PGDG (so pgbackrest versions match); only the primary needs oxen.
git clone https://github.com/session-foundation/session-pro-backend
cd session-pro-backend
cp scripts/deploy.env.example deploy.env
$EDITOR deploy.env # set PRO_DOMAIN, PGBACKREST_REPO_HOST, platform toggles, ...
./scripts/deploy.shThe installer is idempotent — re-run it to pull new code, refresh config/units, and
restart the service. It never clobbers your [apple]/[google] secrets in config.ini
(it only manages db_url and the platform toggles).
- TLS (not done by the installer):
certbot rewrites the vhost in place, adding the
certbot --nginx -d <your-domain>
443server and HTTP→HTTPS redirect. - Platform credentials (if enabling Apple/Google): place secrets under
/etc/session-pro-backend/keys/, fill the[apple]/[google]sections of/etc/session-pro-backend/config.ini, setWITH_PLATFORM_*=trueindeploy.env(re-rundeploy.sh) or edit the toggles directly, then reload the vassal:touch /etc/uwsgi-emperor/vassals/pro-backend.ini. - Smoke test an unauthenticated endpoint:
curl -X POST https://<your-domain>/get_pro_revocations \ -H 'Content-Type: application/json' -d '{"version":0,"ticket":0}'
systemctl status uwsgi-emperor # Emperor state (manages the vassal)
journalctl -u uwsgi-emperor -f # app logs (also /var/log/pro-backend/backend.log)
touch /etc/uwsgi-emperor/vassals/pro-backend.ini # reload the app after config changes
runuser -u postgres -- psql --cluster <ver>/session_pro -d session_pro # DB shell (peer auth)<ver> is the installed PostgreSQL major version; pg_lsclusters shows it and the
auto-assigned port. The app connects via the cluster's unix socket with peer auth, so
there is no database password anywhere.
Backups use pgBackRest's repo-host-initiated model:
- The primary (configured by
deploy.sh) continuously archives WAL to the repository host (archive_timeout→ worst-case RPO, default 30 s). - The repository host (configured by
scripts/pgbackrest/repo-host-setup.sh) runs the base backups:stanza-create/checkonce, then a dailypro-backend-backup.timer(full Sundays, differential otherwise) that pulls PGDATA from the primary over SSH.
Inspect on the repository host (as the repo user):
pgbackrest --stanza=session_pro info # backup inventory
pgbackrest --stanza=session_pro check # verify archiving + connectivity
systemctl list-timers pro-backend-backup.timerThe repository is AES-256 encrypted; the passphrase is on the primary at
/etc/session-pro-backend/pgbackrest-cipher.pass (and must match the repo host's config) —
back it up, or you cannot restore. (Phase 1 keeps the signing key in the database, so it
rides along in these backups; the encryption protects it off-host.)
A portable single-database
pg_dumpis not automated in this version — run it manually if you want a cross-version snapshot; it can be wired back as a primary-side timer later.
Physical restores need PostgreSQL binaries of the same major version that produced the backup (minor differences are fine). The repo host itself needs no PostgreSQL. After a major version upgrade, take a fresh full base backup and keep the old binaries available to restore pre-upgrade backups.
mv /etc/uwsgi-emperor/vassals/pro-backend.ini /root/ # Emperor stops just this app
systemctl stop postgresql@<ver>-session_pro
# Latest state:
runuser -u postgres -- pgbackrest --stanza=session_pro --delta restore
# ...or to a specific instant (PITR):
runuser -u postgres -- pgbackrest --stanza=session_pro --delta \
--type=time --target="2026-07-13 14:23:00+00" restore
systemctl start postgresql@<ver>-session_pro # replays WAL to the target
mv /root/pro-backend.ini /etc/uwsgi-emperor/vassals/ # Emperor restarts the apprunuser -u postgres -- pg_restore --cluster <ver>/session_pro -d session_pro \
--clean --if-exists /path/to/session_pro_YYYYMMDD_HHMMSS.dumpA restore rewinds the revocation_ticket (a plain monotonic counter in globals). Clients poll
/get_pro_revocations with their last-seen ticket and treat "my ticket == the server's" as "nothing
changed", so a client holding a ticket higher than the rewound value silently stops fetching the
list — and worse, new post-restore revocations reuse ticket numbers it has already passed, so it never
sees them. After any restore, bump the ticket past anything that could have been issued in the lost
window so every client re-fetches the list once:
# from the app's install dir, as the app user:
python cli.py --config <config.ini> revoke bump-ticket 1000000(Generation ids and revocation tokens need no such fixup: a recovered generation id just mints a fresh
random token when reused, and the wire revocation tag is that token — never the id — so nothing a client
cached can collide. This replaces the old gen_index counter fixup, which the random-token design
retired.)
scripts/purge.sh tears down the primary so you can redeploy cleanly. It is destructive:
it drops the session_pro cluster (all its data) and removes the app user/files/services and the
primary's pgBackRest config. Shared packages and other clusters/databases are left untouched.
./scripts/purge.sh # prompts for confirmation
./scripts/purge.sh --yes # non-interactiveThe repository host is not touched. To discard the off-host backups, on the repo host run
pgbackrest --stanza=session_pro stanza-delete --force and remove its config + timer — needed
before re-deploying, since a fresh primary cluster gets a new PostgreSQL system identifier.
The dedicated cluster isolates Session Pro's WAL/PITR from the co-tenant databases on the shared server; pgBackRest (chosen over Barman) gives encrypted off-host backups at minutes-level RPO; and there is no live replica because the payment pipeline tolerates downtime — the goal is durability, not availability.