Skip to content

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. main is 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).

DATABASE_URL=postgresql+asyncpg://saebooks:yourpassword@db:5432/saebooks

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 .env file on the host, owned by the service user, never committed. The reference pattern used in SAE Engineering's own deployment:
~/projects/saebooks/deploy/sap/.env   # 0600, owned by operator user
  • Docker secrets / external secret store — if your organisation runs Vault or AWS Secrets Manager, inject secrets via environment variables at runtime rather than .env files.

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:

  1. WAL archiving — archive_command shipping WAL segments to off-host storage every minute. Enables point-in-time recovery.
  2. Nightly logical dump — pg_dump with 30 days retention:
    docker compose exec -T db pg_dump -U saebooks saebooks \
      | gzip > /var/lib/saebooks/backups/saebooks-$(date +%Y%m%d).sql.gz
    
  3. 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