Herald

Deployment

Run Herald on a single host with docker compose — three containers for the backend, database, and cache.

Run Herald on a single host with docker compose — three containers for the backend, database, and cache.

Architecture

Internet ──→ Caddy (80/443) ──→ herald (3000)
                                      │
                           PostgreSQL + Redis

External traffic enters through Caddy. Caddy terminates TLS and forwards to the herald container on port 3000. The backend binary inside that container serves both API requests (/api/*) and frontend static files (/app/frontend/dist), so the frontend is not deployed separately.

The three containers sit on one compose network and address each other by service name (herald reaches PostgreSQL at db:5432). Caddy is an optional reverse proxy — add it only when you need HTTPS or a custom domain.

Prerequisites

  • Linux server (Ubuntu 22.04+ or Debian 12+), at least 2GB RAM
  • Docker Engine 24+ and Docker Compose v2
  • A domain with a DNS A record pointing at the server IP
  • Firewall open on 80 (HTTP) and 443 (HTTPS)

If you just want to get it running without HTTPS first, skip Caddy and expose port 3000 directly.

Prepare the config files

The deploy files live under docker/deploy/ in the repo. Copy them to the server, e.g. /opt/herald/:

mkdir -p /opt/herald
cd /opt/herald
# copy docker-compose.yml, config.toml, .env.example from docker/deploy/ here
cp .env.example .env

Three files to edit: config.toml (app config), .env (runtime secrets), and Caddyfile (reverse proxy, optional).

Generate secrets

The app config (config.toml) and runtime env (.env) each need a few secrets. Generate them up front:

openssl rand -base64 48   # config.toml [jwt] secret
openssl rand -hex 32      # config.toml [custom_domain] ask_key
openssl rand -base64 24   # .env ADMIN_PASSWORD
openssl rand -hex 24      # .env INTERNAL_API_KEY
openssl rand -base64 48   # .env TOTP_SECRET_KEY (at least 32 bytes)

Herald reads app config only from a TOML file (ApiConfig::load in backend/api/src/config.rs), so the JWT secret goes in config.toml. But the admin bootstrap and a few service keys are read from env vars at runtime — those go in .env. You need both.

App config: config.toml

config.toml controls the database connection, Redis address, JWT secret, etc. The image bundles a production.toml with placeholder values; your config.toml overrides it. Set these:

[frontend]
# Change to your real domain — drives the CORS allowlist
url = "https://your-domain.com"

[jwt]
# Required. Use the value from `openssl rand -base64 48` above.
secret = "replace-with-your-jwt-secret"

[custom_domain]
# Required — a startup guard rejects the placeholder (backend/api/src/lib.rs:164).
# Use the value from `openssl rand -hex 32` above.
ask_key = "replace-with-your-custom-domain-key"
cname_target = "custom.your-domain.com"

Leave the database and Redis addresses at their defaults (db:5432, redis:6379) — they match the compose service names. If [custom_domain].ask_key stays change-me-in-production, the server refuses to start. See Configuration.

Runtime env: .env

.env injects env vars into the herald container. In production these are all required — the service will not start if any is missing:

# Image to run. Pin a version for reproducible deploys, e.g. ghcr.io/timzaak/herald:0.4.1
HERALD_IMAGE=ghcr.io/timzaak/herald:latest

# ADMIN_PASSWORD is required in production or startup bails (backend/core/src/admin/user.rs:62).
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-your-admin-password
ADMIN_REALM_ID=admin

# Internal API key for service-to-service calls (/api/internal/* X-Internal-Api-Key).
INTERNAL_API_KEY=replace-with-your-internal-api-key

# TOTP HMAC key, at least 32 bytes (backend/domain/src/user_totp/service.rs:182).
TOTP_SECRET_KEY=replace-with-your-totp-key

On first boot, when the database is empty, Herald creates the admin account from ADMIN_EMAIL / ADMIN_PASSWORD. Later boots do not re-create it.

Caddyfile (optional, for HTTPS)

Add Caddy when you need HTTPS or a custom domain. Create /opt/herald/Caddyfile:

your-domain.com {
    reverse_proxy herald:3000
}

herald is the compose service name — Caddy reaches it over the compose network. Replace your-domain.com with your domain. Caddy fetches and renews the Let's Encrypt certificate automatically; no manual cert config.

Start

Once the config is in place, bring the three containers up with one command:

docker compose up -d

Start order is controlled by depends_on: PostgreSQL and Redis must pass their healthchecks before herald starts. Herald runs database migrations on boot (sqlx::migrate! is embedded in the binary), so you do not create tables manually.

Check status:

docker compose ps

Wait until the herald row shows healthy. The first run takes a minute or two to pull images and run migrations.

Verify

curl http://localhost:3000/health

A JSON response like this means the backend, database, and Redis are all wired up:

{"status":"healthy","database":true,"redis":true,"version":"0.4.1","uptime":45,"timestamp":"..."}

Open https://your-domain.com (with Caddy) or http://YOUR-SERVER-IP:3000 (without) in a browser and sign in to the admin console with ADMIN_EMAIL / ADMIN_PASSWORD.

Add Caddy for HTTPS

The base setup runs three containers without Caddy. To add HTTPS, bring Caddy in as a fourth service. Append to docker-compose.yml:

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    depends_on:
      - herald
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config

volumes:
  pgdata:
  caddy-data:
  caddy-config:

Then docker compose up -d caddy. On first start Caddy requests a certificate from Let's Encrypt. If DNS has not propagated yet or port 80 is blocked, the request fails and Caddy retries — you will see acme: error in the logs.

After editing the Caddyfile, no container restart is needed:

docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Upgrade

An upgrade swaps only the image; PostgreSQL and Redis are untouched. Expect a few seconds of downtime.

# Back up first — migrations are irreversible.
docker compose exec db pg_dump -U herald herald > backup_$(date +%Y%m%d).sql

# Bump HERALD_IMAGE in .env to the new version, then recreate herald.
docker compose up -d herald

docker compose up -d herald pulls the new image, stops the old herald container, and starts a new one. Other containers are unaffected.

To roll back, set HERALD_IMAGE in .env back to the old version tag and run docker compose up -d herald again.

Backup and restore

The app itself is stateless. The only persistent data is PostgreSQL:

# Back up
docker compose exec db pg_dump -U herald herald > backup.sql

# Restore
cat backup.sql | docker compose exec -T db psql -U herald herald

Redis holds cache and sessions; losing it is not serious — the app rebuilds the cache. The pgdata volume survives docker compose down, but docker compose down -v deletes the volume too — do not add -v.

CI/CD

The project builds images automatically with GitHub Actions, defined in .github/workflows/cd.yml.

Pushing a v-prefixed tag triggers it (e.g. git tag v0.4.1 && git push origin v0.4.1). The pipeline does four things: builds on amd64 and arm64, merges a multi-arch manifest, pushes ghcr.io/timzaak/herald:<tag> and ghcr.io/timzaak/herald:latest, and creates a GitHub Release.

The Dockerfile is a five-stage build (cargo-chef to cache Rust deps → backend release → frontend build with OpenAPI generation → alpine runtime). The final image runs as a non-root user with a built-in /health check every 30 seconds. To build locally:

docker build -f docker/Dockerfile -t herald-app:local .

Then set HERALD_IMAGE=herald-app:local in .env to use your own image.

Troubleshooting

herald exits immediately

Check the logs:

docker compose logs herald

Most likely a secret is missing. In production, all of these are required: [jwt] secret and [custom_domain] ask_key in config.toml (not placeholders), and ADMIN_PASSWORD, INTERNAL_API_KEY, TOTP_SECRET_KEY in .env.

herald cannot reach the database

The logs show connection refused. Check that herald's depends_on waits for the db healthcheck, and confirm the database URL in config.toml is db:5432, not localhost. Containers talk to each other by service name, not localhost.

Caddy certificate request fails

The logs show acme: error. Check that the domain's DNS points at the server IP (dig your-domain.com), that the firewall allows 80 and 443, and that nothing else is listening on port 80 (ss -tlnp | grep :80).

realm-admin role not found

This should not happen — migrations run before seeding. If it does, restart herald; migrations are idempotent:

docker compose restart herald

On this page