Self-host in production¶
The quickstart gets you running. This page gets you to a deployment you can trust with real books.
Posture¶
- Single-tenant appliance. One customer's books on one host. Multi-tenant is an Enterprise option; it increases blast radius and operational complexity. Start single-tenant.
- Reverse proxy in front. Never expose ports 8080, 8042, or 50051 to the public internet. TLS terminates at the proxy.
- Pin to a tagged release.
mainis fine for dev. In prod you want to know which version posted yesterday's journal. Pin by checking out a release tag in both repos before building.
Pinning to a release¶
Because images are built from source, you pin a release by checking out the same tag in both repos:
cd ~/projects/saebooks && git fetch --tags && git checkout v0.6
cd ~/projects/saebooks-web && git fetch --tags && git checkout v0.6
cd ~/projects/saebooks/deploy/sap
docker compose up -d --build
If you want a named local image for your own registry or CI pipeline, build and tag directly:
# From the saebooks repo root
docker buildx build --tag saebooks-api:v0.6 .
# From the saebooks-web repo root
docker buildx build --tag saebooks-web:v0.6 .
Postgres¶
Use the bundled Postgres, or your own¶
The bundled docker-compose.yml ships a db service (Postgres 16, volume
saebooks_db). For a single-host appliance this is fine. For anything with HA
requirements, point DATABASE_URL at a managed Postgres (AWS RDS, Supabase,
your own Patroni cluster).
Tuning¶
Defaults are conservative. For a single-tenant SME instance with 4 GB RAM:
shared_buffers = 1GB
work_mem = 32MB
maintenance_work_mem = 256MB
effective_cache_size = 3GB
wal_compression = on
The bigger lever in practice: make sure the data directory is on SSD. synchronous_commit and WAL fsync dominate performance at small scale.
Alembic migrations¶
Migrations run automatically on container start via the entrypoint script. They are designed to be additive-then-cutover so the old app version can serve traffic while the new schema is being applied. If a migration fails, the container exits non-zero — do not restart into the old image. Diagnose first.
# Check what version the DB is at
docker compose exec api alembic current
# Apply manually (if auto-apply is disabled)
docker compose exec api alembic upgrade head
Compose hardening¶
The SAP bundle at deploy/sap/ is the base. For a hardened production overlay,
extend it with docker-compose.override.yml:
services:
api:
build:
context: ../.. # saebooks repo root
restart: unless-stopped
environment:
SAEBOOKS_EDITION: community # or enterprise
SAEBOOKS_SECRET_KEY: ${SAEBOOKS_SECRET_KEY}
SAEBOOKS_WEB_SECRET_KEY: ${SAEBOOKS_WEB_SECRET_KEY}
DATABASE_URL: ${DATABASE_URL}
SAEBOOKS_FIELD_ENCRYPTION_KEY: ${SAEBOOKS_FIELD_ENCRYPTION_KEY}
SAEBOOKS_LOG_JSON: "1"
# SAEBOOKS_DEV_API_TOKEN must NOT be set — its presence disables JWT auth
ports: [] # no host ports — reverse proxy only
networks: [internal, revproxy]
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
web:
build:
context: ../../../saebooks-web
restart: unless-stopped
environment:
SAEBOOKS_API_URL: http://api:8000
SAEBOOKS_WEB_SECRET_KEY: ${SAEBOOKS_WEB_SECRET_KEY}
SAEBOOKS_WEB_LOG_LEVEL: INFO
networks: [internal, revproxy]
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- /var/lib/saebooks/pg:/var/lib/postgresql/data
networks: [internal]
networks:
internal:
revproxy:
external: true # shared network that your Caddy / nginx joins
The SAEBOOKS_DEV_API_TOKEN env var is a static bearer token that bypasses JWT auth — it must never be set in production.
Reverse proxy (Caddy)¶
Caddy is the simplest option. Drop this in your Caddyfile:
books.example.com {
reverse_proxy localhost:8080 # web UI
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options DENY
}
}
# Optional: expose the REST API directly (for integrators who need direct API access)
api.books.example.com {
reverse_proxy localhost:8042
# Consider restricting to known IP ranges if only internal tools use this
}
Caddy will auto-issue and renew a Let's Encrypt cert. Traefik, nginx, and HAProxy all work the same way. Never expose port 8080 or 8042 directly.
Environment variables¶
api service¶
| Variable | Required | Notes |
|---|---|---|
POSTGRES_PASSWORD |
yes | Set before first up. python3 -c "import secrets; print(secrets.token_urlsafe(32))" |
SAEBOOKS_SECRET_KEY |
strongly recommended | JWT signing secret. If unset the API silently defaults to an empty string — tokens sign but fail verification on any restart or across instances. openssl rand -hex 32 |
SAEBOOKS_EDITION |
no | community (default) | offline | business | pro | enterprise |
SAEBOOKS_FIELD_ENCRYPTION_KEY |
recommended for prod | Fernet key for field-level encryption. Generate: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". Empty = field encryption disabled. |
SAEBOOKS_LOG_LEVEL |
no | INFO (default); DEBUG for diagnostics |
SAEBOOKS_LOG_JSON |
no | Set 1 for structured JSON logs |
SENTRY_DSN |
no | Error reporting |
SMTP_HOST |
no | Empty = emails written to /app/mail-outbox (dev mode) |
SMTP_PORT |
no | 587 (default) |
SMTP_USER / SMTP_PASSWORD / SMTP_FROM |
for email | Required when SMTP_HOST is set |
SEED_COMPANY_NAME / _LEGAL_NAME / _ABN |
no | Pre-fills company details on first DB init only |
SAEBOOKS_ADMIN_EMAIL |
no | Seeds an initial admin account |
web service¶
| Variable | Required | Notes |
|---|---|---|
SAEBOOKS_API_URL |
yes | Internal URL of the api service — http://api:8000 |
SAEBOOKS_WEB_SECRET_KEY |
yes | Session cookie signing secret. python3 -c "import secrets; print(secrets.token_hex(32))" |
SAEBOOKS_WEB_PORT |
no | 8080 (default) |
SAEBOOKS_WEB_LOG_LEVEL |
no | INFO (default) |
Secrets management¶
Keep secrets out of version control. Recommended approaches:
- 0600
.envfile on the host, owned by the service user, never committed. The reference pattern used in SAE Engineering's own deployment:
- Docker secrets / external secret store — if your organisation runs Vault or
AWS Secrets Manager, inject secrets via environment variables at runtime rather
than
.envfiles.
Never commit .env to Git. The .gitignore in deploy/sap/ already excludes it.
Backups¶
Postgres is the only stateful component. The volume saebooks_db is unaffected
by docker compose down — your data survives container rebuilds. Three layers,
in order of importance:
- WAL archiving —
archive_commandshipping WAL segments to off-host storage every minute. Enables point-in-time recovery. - Nightly logical dump —
pg_dumpwith 30 days retention: - Filesystem snapshot — ZFS/btrfs/LVM snapshot of the data volume, shipped off-host.
Restore drills are non-optional. A backup you've never restored from is not a backup. Run a cold-site drill at least quarterly and verify the trial balance reconciles.
Observability¶
| Path | Auth | Purpose |
|---|---|---|
/api/v1/healthz |
none | Liveness — 200 OK |
/healthz (web) |
none | Web frontend liveness |
/metrics |
SAEBOOKS_METRICS_TOKEN bearer |
Prometheus endpoint |
Logs are structured JSON on stdout when SAEBOOKS_LOG_JSON=1. Ship to whatever
you already have (Loki, Elasticsearch, systemd-journal).
The deploy/grafana/ directory in the repo ships a dashboard JSON covering
request rate, latency percentiles, DB connections, and per-tenant journal-post
throughput.
Upgrades¶
# 1. Read CHANGELOG.md for migration warnings
# 2. Snapshot Postgres
docker compose exec -T db pg_dump -U saebooks saebooks | gzip > pre-upgrade.sql.gz
# 3. Pull both repos (or checkout a specific tag)
cd ~/projects/saebooks && git pull # or: git checkout <new-tag>
cd ~/projects/saebooks-web && git pull # or: git checkout <new-tag>
# 4. Rebuild and restart (Alembic runs on startup)
cd ~/projects/saebooks/deploy/sap
docker compose up -d --build
# 5. Smoke-test: login, post a test journal, verify BAS prepare runs
If a migration fails, the container exits non-zero. Diagnose the migration output before doing anything else. Restoration path: pipe the pre-upgrade dump back in and restart from the previous checkout.
For zero-downtime, run two api containers behind the proxy and roll one at a time. Schema migrations are designed to run additive-then-cutover style; release notes call out any blocking migration.
Enterprise licence¶
Enterprise installs validate against license.saebooks.com.au on start and once
daily thereafter. Validation is local (cryptographic — Ed25519 signature check).
The daily refresh is a network call. Offline operation is supported for up to 30
days on the cached token, after which the install falls back to Community behaviour.
For air-gapped networks, contact SAE Engineering for an extended-offline key.
Reference: the bosun pattern¶
SAE Engineering's own deployment uses a per-service compose pattern called "bosun":
~/projects/saebooks/deploy/sap/
├── docker-compose.yml
└── .env # 0600, owned by operator user, never committed
Data volumes on a separate mount point from the OS. This pattern is worth
copying — service isolation via compose project, secrets in 0600 .env files,
data directories separate from the application.
Next¶
- Migrations — what Alembic is doing
- Tenancy & RLS — the Postgres posture
- Editions — enabling Enterprise features