Deployment

Docker

This guide deploys HappyView from the prebuilt images published to GitHub Container Registry, using one of the two production Compose files in the repository. Nothing is compiled locally — the image already contains the server and the dashboard.

If you want to run the repository's development stack with hot reloading, see Local Development instead. To run the server directly with cargo run, see From Source.

Prerequisites

  • Docker and Docker Compose
  • A domain and a reverse proxy that terminates TLS — HappyView does not. See step 4

The image

ghcr.io/gamesgamesgamesgamesgames/happyview

Multi-arch manifests are published for linux/amd64 and linux/arm64 on every release.

TagMovesUse for
latestEvery stable releaseTrying HappyView out
2.13.0NeverProduction
2.13Every stable patch in that minorAutomatic patch updates
2Every stable minor in that majorAutomatic minor updates
sha-abc1234NeverPinning to an exact commit

Prereleases cut from the dev branch (e.g. 2.13.0-dev.1) are published under their full version only. They never move latest, 2.13, or 2.

1. Choose a database

Pick one Compose file. Both live at the repository root:

FileDatabaseBest for
docker-compose.prod.sqlite.ymlSQLiteSmall to medium instances. One container, one volume, no database to operate
docker-compose.prod.postgres.ymlPostgresMultiple HappyView replicas sharing a database, larger-than-memory working sets, or external tools reading the records table directly

See the database setup guide for the full comparison. Migrations run automatically on startup on either backend, and both directions are migratable later.

Make a directory for the deployment and download the one you picked as docker-compose.yml, so docker compose finds it without a -f flag:

mkdir -p happyview && cd happyview
# SQLite
curl -o docker-compose.yml https://raw.githubusercontent.com/gamesgamesgamesgamesgames/happyview/main/docker-compose.prod.sqlite.yml
# Postgres
curl -o docker-compose.yml https://raw.githubusercontent.com/gamesgamesgamesgamesgames/happyview/main/docker-compose.prod.postgres.yml

2. Generate secrets

Create a .env next to it. Compose auto-loads that filename, so these need no flag either:

cat > .env <<EOF
PUBLIC_URL=https://happyview.example.com
SESSION_SECRET=$(openssl rand -base64 48)
TOKEN_ENCRYPTION_KEY=$(openssl rand -base64 32)
HAPPYVIEW_VERSION=latest
EOF

For the Postgres stack, add a database password as well:

echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env

Compose refuses to start without these:

  • PUBLIC_URL — the public HTTPS URL users actually hit, scheme included. It's used to build OAuth redirect URIs, so a mismatch breaks login. Do not include BASE_PATH here.
  • SESSION_SECRET — signs the dashboard session cookie. An unset or too-short value doesn't stop the server booting; it silently disables cookie login, which is why the Compose files require it explicitly.
  • TOKEN_ENCRYPTION_KEY — the AES-256-GCM key protecting OAuth tokens, DPoP private keys, and plugin secrets at rest. Without it, DPoP sessions, spaces, and service identity are disabled. Rotating it makes everything already encrypted unreadable.
docker compose --env-file .env.prod -f docker-compose.prod.sqlite.yml up -d

3. Start the stack

docker compose up -d

HappyView runs its migrations on first boot, then starts serving on port 3000 inside the container.

Watch it come up:

docker compose logs -f

4. Put a reverse proxy in front

HappyView does not terminate TLS, and both Compose files publish to loopback only by default (127.0.0.1:3000) — the proxy in front is what should be exposed.

Using the bundled Caddy service

Each Compose file ends with a commented-out caddy service that handles TLS with certificates obtained and renewed automatically. Uncomment it, along with the caddy-data and caddy-config entries in the volumes block at the bottom, then create a Caddyfile beside the Compose file:

{$CADDY_DOMAIN} {
    reverse_proxy happyview:3000
}

Then, in .env and the Compose file:

  • Add CADDY_DOMAIN to .env — the hostname from PUBLIC_URL with the scheme stripped, so happyview.example.com for https://happyview.example.com. PUBLIC_URL itself stays the full URL.
  • Delete the ports block from the happyview service. Caddy reaches it over the Compose network, so publishing it to the host as well only widens the exposure. Leave HTTP_BIND unset.

Point DNS at the host before the first up. Caddy requests a certificate on startup, and a challenge for a hostname that doesn't resolve to this host fails and retries with backoff.

Other options

  • A proxy on the host — leave the default ports block and point nginx, Caddy, or Traefik at 127.0.0.1:3000
  • Cloudflare Tunnel — no inbound port needs opening at all. See Local Development for how the tunnel is wired up, then set PUBLIC_URL to the tunnel's hostname
  • HTTP_BIND=0.0.0.0:3000 — publishes on all interfaces. Only do this if something else is firewalling the port, since ports bypasses the host firewall on most Docker installs

Whatever you use, PUBLIC_URL must match the resulting public URL exactly. To serve HappyView at a subpath alongside other services, see Reverse proxy subpath. BASE_PATH is applied at container start, so prebuilt images work at any subpath without a rebuild.

5. Log in

Open PUBLIC_URL in a browser. The image serves the dashboard at the root, and the first handle to authenticate on a fresh instance is automatically bootstrapped as the super user with all permissions — so use the handle you want to own the instance.

From there, follow the Quickstart from step 3 to add your first lexicon.

Configuration

Beyond the required secrets, both Compose files expose these with production defaults. Override them in .env.

VariableDefaultNotes
HAPPYVIEW_VERSIONlatestImage tag to deploy
HTTP_BIND127.0.0.1:3000Host address the container publishes on
BASE_PATH(none)Subpath prefix, e.g. /hv. PUBLIC_URL must not include it
RUST_LOGhappyview=info,tower_http=info,sqlx=warnThe dev default is very noisy in production
JETSTREAM_URLwss://jetstream1.us-east.bsky.networkReal-time record stream
RELAY_URLhttps://bsky.networkUsed for backfill repo discovery
PLC_URLhttps://plc.directoryDID resolution
EVENT_LOG_RETENTION_DAYS300 keeps event logs indefinitely
DEFAULT_RATE_LIMIT_CAPACITY100Per-client token bucket capacity
DEFAULT_RATE_LIMIT_REFILL_RATE2.0Tokens per second

SQLite stack only:

VariableDefaultNotes
SQLITE_JOURNAL_SIZE_LIMIT67108864 (64 MiB)Caps the -wal file after a checkpoint. Deleting rows grows the WAL for the duration of the delete, so this bounds a delete's peak disk usage

Postgres stack only:

VariableDefaultNotes
POSTGRES_VERSION17Image tag for the postgres service
POSTGRES_USERhappyview
POSTGRES_DBhappyview
POSTGRES_MAX_CONNECTIONS200Raised from the stock 100; see the note below
DATABASE_MAX_CONNECTIONS32Main pool ceiling

The full environment variable reference is in Configuration. A few that aren't wired into the Compose files — ATTESTATION_PRIVATE_KEY, APP_NAME, LOGO_URI, TOS_URI, POLICY_URI, and the BACKFILL_CONCURRENT_* tuning knobs — are present as commented-out lines you can uncomment.

Operations

Upgrading

Bump HAPPYVIEW_VERSION in .env (or leave it on a moving tag), then:

docker compose pull
docker compose up -d

Migrations run automatically on the new container's first boot. Back up the volume first; see Backups. If you're coming from HappyView 1.x, read Upgrading to v2 first.

Health checks

Both stacks ship a container healthcheck. The runtime image carries no curl or wget, so it probes /health over bash's /dev/tcp. The SQLite stack allows a 120-second start period, because a VACUUM scheduled from the dashboard runs at boot before the server binds. The Postgres stack uses 60 seconds, which covers migrations on a first boot. Scheduling a vacuum is rejected there, so there's nothing longer to wait out.

For an external probe, GET /health returns 200 ok once HappyView can bind its listener, and stays at the domain root even when BASE_PATH is set. For a deeper check that exercises the database and lexicon registry, use GET /xrpc/com.atproto.server.describeServer.

Stopping and restarting

Both stacks set init: true. The entrypoint execs the server, making it PID 1, where the kernel drops signals that have only their default disposition, and the server installs no SIGTERM handler. Without an init, every stop, restart, and down would hang for the full grace period and end in SIGKILL (exit 137). init: true puts tini at PID 1 to forward the signal so the server exits promptly.

This is prompt, not graceful: there is no graceful-shutdown handler, so in-flight requests are dropped either way, and an interrupted job is re-queued on the next boot.

The Postgres service additionally uses stop_signal: SIGINT. Docker's default SIGTERM is a Postgres smart shutdown, which waits for every client to disconnect, so it would hang for the full grace period and take a SIGKILL, leaving the next boot to run crash recovery. SIGINT is the fast shutdown: roll back open transactions, checkpoint, exit.

Logs

Container stdout is the only log sink, capped at 5 × 10 MB per service so json-file logs can't grow unbounded. Ship stdout to your usual aggregator if you need retention.

Backups

The SQLite stack keeps everything in the happyview-data volume; the Postgres stack in pgdata. Those are the only stateful paths. See Backups for what is and isn't recoverable from the network: most records can be re-indexed via backfill, but user accounts, permissions, API keys, plugin secrets, and the Jetstream cursor cannot.

Next steps