Production runbook for the backend - build it, migrate the stores, run each process, and harden for production traffic. The dashboard has its own build and deploy; see Dashboard.
Before deploying: meet all Requirements and set every variable in Configuration. The make commands below run from the backend repo root.
1. Build
You can ship the backend as binaries (systemd, bare processes) or container images (Kubernetes, Compose). Pick one.
Option A: binaries
make buildmake build compiles the following into bin/. bin/pug is the unified CLI; every other binary is a single-purpose build of one process: an alternative to the matching pug sub-command for deployment tools that prefer one binary per unit.
| Binary | Equivalent to | Role |
|---|---|---|
bin/pug |
- | Unified CLI: all sub-commands in one binary |
bin/pug-server |
pug server |
API server |
bin/pug-migrate-postgres |
pug postgres migrate |
Postgres migration runner (up only) |
bin/pug-migrate-nats |
pug nats migrate |
NATS stream initialiser |
bin/pug-migrate-clickhouse |
pug clickhouse migrate |
ClickHouse migration runner (up only) |
bin/pug-worker-events |
pug worker events |
Events worker: writes events to ClickHouse |
bin/pug-worker-email |
pug worker email |
Transactional email worker |
bin/pug-worker-compliance |
pug worker compliance |
Compliance worker (GDPR/DPDP erasure) |
bin/pug-worker-profile-identify |
pug worker profile identify |
Profile identify worker |
bin/pug-worker-profile-alias |
pug worker profile alias |
Profile alias worker |
bin/pug-worker-profile-upsert |
pug worker profile upsert |
Profile upsert worker |
bin/pug-worker-demo |
pug worker demo |
Demo-traffic generator (only for public demos) |
bin/pug-cron-purge |
pug cron purge |
Scheduled job: erases deleted projects |
bin/pug-cron-usage |
pug cron usage |
Scheduled job: meters event usage |
bin/pug-cron-billing-reconcile |
- | Pug Cloud billing job; a self-hosted instance doesn’t run it |
bin/pug-cron-billing-meter |
- | Pug Cloud billing job; a self-hosted instance doesn’t run it |
Run the binaries from a directory that holds
schema/. The workers andnats migratereadschema/nats/*.yaml, and the Postgres and ClickHouse migrate runners readschema/{postgres,clickhouse}/migrations, all relative to the working directory. Ship the repo’sschema/folder with the binaries and start each unit from the folder above it (systemdWorkingDirectory=). The NATS files can also be pointed at directly withNATS_STREAMS_CONFIGandNATS_CONSUMERS_CONFIG; the migration folders can’t.
There is no
make migrateormake devtarget. Migrations run via thepugCLI or the standalone migrate binaries (below); local all-in-one runs via thepug devcommand. See the CLI reference.
Option B: container images
Each image’s entrypoint is one single-purpose binary, so you run the container with no arguments. They run as distroless nonroot, with /app as the working directory and whatever schema/ files the role needs already inside.
Published images
Every release publishes one image per role to the GitHub Container Registry, for linux/amd64 and linux/arm64:
docker pull ghcr.io/pug-sh/pug-server:0.0.36Each image is tagged with its full version (0.0.36), its minor line (0.0) and latest. Pin the full version in production, and roll every role to the same one.
| Role | Image (ghcr.io/pug-sh/...) |
CMD |
Target |
|---|---|---|---|
| Server | pug-server |
server |
app |
| Events worker | pug-worker-events |
workers/events |
worker |
| Email worker | pug-worker-email |
workers/email |
worker |
| Compliance worker | pug-worker-compliance |
workers/compliance |
worker |
| Profile identify / alias / upsert | pug-worker-profile-identify, pug-worker-profile-alias, pug-worker-profile-upsert |
workers/profile/identify, workers/profile/alias, workers/profile/upsert |
worker |
| Demo worker | pug-worker-demo |
workers/demo |
worker |
| Purge / usage jobs | pug-cron-purge, pug-cron-usage |
cron/purge, cron/usage |
app |
| Billing jobs (Pug Cloud only) | pug-cron-billing-reconcile, pug-cron-billing-meter |
cron/billing-reconcile, cron/billing-meter |
app |
| Postgres / NATS / ClickHouse migrate | pug-migrate-postgres, pug-migrate-nats, pug-migrate-clickhouse |
migrate/postgres, migrate/nats, migrate/clickhouse |
migrate |
Build your own
The repo ships one parameterized Dockerfile that builds any of the images above. Two build args select the role, from the CMD and Target columns:
--build-arg CMD=<path under ./cmd>: whichmainto compile.--target <app|worker|migrate>: the runtime shape (what, besides the binary, lands in the image).
The three targets exist because the roles need different files at runtime:
| Target | Contains | Used by |
|---|---|---|
app |
distroless + the binary only | The server and the scheduled jobs (read nothing from schema/) |
worker |
binary + schema/nats |
Workers (read schema/nats/consumers.yaml at startup) |
migrate |
binary + schema/ migration assets |
The migrate runners |
# API server
docker build --build-arg CMD=server --target app -t pug-server .
# a worker (swap the CMD path for any worker in the table)
docker build --build-arg CMD=workers/events --target worker -t pug-worker-events .
# a migration runner
docker build --build-arg CMD=migrate/postgres --target migrate -t pug-migrate-postgres .A plain docker build produces an image for the host’s architecture. The Dockerfile cross-compiles, so docker buildx build --platform linux/amd64,linux/arm64 ... builds both without QEMU.
2. Migrations
Run migrations once against each store before the first boot, and again on every upgrade. They are idempotent - re-running them on an up-to-date store is safe.
# unified CLI
./bin/pug postgres migrate
./bin/pug nats migrate
./bin/pug clickhouse migrate
# or the standalone binaries / migrate images
./bin/pug-migrate-postgres
./bin/pug-migrate-nats
./bin/pug-migrate-clickhouseAll migration commands read connection details from the environment (DATABASE_URL, NATS_URL, CLICKHOUSE_URL). postgres migrate and clickhouse migrate apply every pending migration.
To roll back, use the unified CLI: --direction down --num N undoes N steps. Always pass --num - --direction down on its own rolls back every migration, dropping every table. The standalone binaries and migrate images only migrate up and take no flags.
nats migrate creates or updates the JetStream streams and consumers to match schema/nats/, with the replica count from NATS_STREAM_REPLICAS (see NATS JetStream HA). It never deletes a stream.
3. Run
Production topology: one process per role, each as its own replica. pug dev runs every component in a single process with no independent scaling or restart boundaries - acceptable for a small low-volume box (below), not for production traffic at any real volume.
API server
./bin/pug server # or ./bin/pug-server, or the pug-server imageListens on :3000 (override with PUG_SERVER_PORT). Place a TLS-terminating load balancer in front - see Production hardening.
Workers
There are 6 worker processes in a normal deployment (the demo worker is only for public demos). Run each as its own replica, using whichever form fits your tooling:
| Process | Unified command | Standalone binary | Docker CMD |
|---|---|---|---|
| Events | pug worker events |
pug-worker-events |
workers/events |
pug worker email |
pug-worker-email |
workers/email |
|
| Compliance | pug worker compliance |
pug-worker-compliance |
workers/compliance |
| Profile identify | pug worker profile identify |
pug-worker-profile-identify |
workers/profile/identify |
| Profile alias | pug worker profile alias |
pug-worker-profile-alias |
workers/profile/alias |
| Profile upsert | pug worker profile upsert |
pug-worker-profile-upsert |
workers/profile/upsert |
| Demo (optional) | pug worker demo |
pug-worker-demo |
workers/demo |
Scale the events worker horizontally for high ingest - run multiple replicas (see Horizontal scaling):
./bin/pug worker events # or ./bin/pug-worker-eventsScheduled jobs
Two jobs run on a schedule. Each runs one pass and exits - non-zero when the pass failed - so a scheduler runs it: a Kubernetes CronJob, a systemd timer, or cron. Nothing starts them for you, pug dev included.
| Job | Unified command | Standalone binary / image | Schedule | If it never runs |
|---|---|---|---|---|
| Project purge | pug cron purge |
pug-cron-purge |
Every 5 minutes | A deleted project is hidden and its keys stop working, but its data stays. The server logs an error at startup while a deletion has been open for over a day. |
| Usage metering | pug cron usage |
pug-cron-usage |
Hourly | The dashboard shows event usage as unknown. Nothing else reads it. |
Each pass takes a Postgres advisory lock, so a run that overlaps the previous one exits cleanly instead of doing the work twice. On Kubernetes, set concurrencyPolicy: Forbid and an activeDeadlineSeconds shorter than the interval - Pug Cloud uses 270 seconds for purge and 3000 for usage. Give the jobs the server’s environment; cron usage also reads PUG_USAGE_RESCAN_DAYS.
The two billing jobs, pug-cron-billing-reconcile and pug-cron-billing-meter, belong to Pug Cloud’s billing, which is off by default. A self-hosted instance doesn’t schedule them.
Single node
Running a single small node?
pug dev(aliaspug start) runs the server and all workers in one process. It’s built for local dev, but it’s a legitimate way to stand up a low-volume single-box instance - you just give up independent scaling and restarts. Schedule the jobs alongside it. See the CLI reference.
4. Production hardening
TLS termination
pug server listens on plain HTTP (:3000). Terminate TLS at a load balancer (nginx, Caddy, AWS ALB, Cloudflare) and proxy to port 3000. Do not expose port 3000 directly to the internet - the SDKs and dashboard send credentials (x-api-key, Authorization JWT), so TLS is required.
Secrets management
Do not put secrets in unencrypted files. Inject the following via your secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets, etc.):
| Secret | Variable | Notes |
|---|---|---|
| JWT signing key | PUG_JWT_SECRET_KEY |
openssl rand -hex 32. Rotating it doesn’t sign anyone out - see Ending sessions. |
| Email provider encryption key | PUG_EMAIL_PROVIDER_SECRET_KEY |
openssl rand -base64 32. Required for per-tenant email providers. |
| SSO connection encryption key | PUG_SSO_SECRET_KEY |
openssl rand -base64 32. Required for org SSO connections. Changing it makes the stored client secrets unreadable. |
| Sign-in provider secrets | PUG_CONFIG_FILE |
The file holds each provider’s clientSecret. Mount it from a secret, read-only. |
| Email provider credentials | PUG_RESEND_API_KEY, or AWS_SECRET_ACCESS_KEY for SES |
SES can use an IAM role instead of a static key. |
| Turnstile secret | PUG_TURNSTILE_SECRET_KEY |
Only when Turnstile is on. |
| Postgres credentials | DATABASE_URL |
Use a dedicated Pug database user. The first migration creates the moddatetime extension, so create it beforehand as a superuser, or run that migration with a role allowed to. |
| ClickHouse credentials | CLICKHOUSE_URL |
Include in the connection URL (clickhouse://user:pass@host:9000/pug). |
| Dragonfly credentials | REDIS_URL |
Include in the connection URL (redis://:pass@host:6379). |
| NATS credentials | NATS_URL, NATS_CREDS_FILE, or NATS_JWT + NATS_SEED |
User/password or token auth goes in NATS_URL. For JWT auth, mount a .creds file and set NATS_CREDS_FILE, or set the user JWT and seed. An NKey seed on its own isn’t supported. Add TLS per your NATS deployment. |
Ending sessions
A dashboard session is a refresh token stored in Postgres, on a 90-day sliding expiry; the JWT it mints lasts 24 hours. When a JWT is rejected, the dashboard quietly trades the refresh token for a new pair, so rotating PUG_JWT_SECRET_KEY only kills the JWTs already issued. To sign everyone out, revoke the refresh tokens, then rotate the key so the outstanding JWTs die too:
update refresh_tokens set revoked_at = now() where revoked_at is null;CORS origins
Set PUG_CORS_ORIGINS to the exact origin(s) your dashboard runs on (e.g. https://app.example.com), comma-separated with no spaces. Unset, it defaults to *, which lets any website call the dashboard API from a visitor’s browser; the server logs a warning when it starts that way. The dashboard keeps its token in an Authorization header, not a cookie, so * doesn’t break sign-in - it only removes a layer of defence. The SDK ingest services accept any origin regardless.
NATS JetStream HA
Run a 3-node NATS cluster with JetStream on each node - a single node is a single point of failure for all ingestion. Start each with --jetstream --store_dir /data/nats, give the cluster a name and configure its routes. Then set NATS_STREAM_REPLICAS=3 and re-run pug nats migrate, so one node’s loss doesn’t lose data.
pug nats migrate refuses a replica count above 1 against a server that reports no cluster name, since a standalone server only loses the streams at its next restart. It also owns the stream settings: one changed by hand (nats stream edit) is reset on its next run.
ClickHouse HA and storage
A single ClickHouse node is fine for low-to-medium volumes. For higher volumes or fault tolerance:
- Replication: Pug’s migrations create plain
MergeTree-family tables with noON CLUSTER, so a self-managed replicated cluster isn’t supported yet. For fault tolerance today, use ClickHouse Cloud, which replicates every table, or back up the node’s storage. - Tiered storage: an S3-compatible object store as the “cold” tier for high-retention deployments.
ulimits: ensurenofile>= 262144 on all ClickHouse nodes (required).
Horizontal scaling
The events worker is the primary bottleneck at high ingest rates. Its replicas share one durable JetStream consumer, so each one you add takes a share of the stream. Other workers process lower-volume streams; start with one replica each and scale as needed.
Monitoring
Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector’s OTLP/gRPC endpoint (usually port 4317) to export traces, metrics, and logs. Export is plaintext unless you set OTEL_EXPORTER_OTLP_INSECURE=false, which a TLS endpoint needs. OTLP over HTTP isn’t supported, so a backend that only accepts HTTP needs an OpenTelemetry Collector in between. With no endpoint set, Pug logs to stdout as text. See Telemetry in Configuration.
Health checks
The server and every worker except demo expose Kubernetes-style probes. Wire your orchestrator’s liveness/readiness probes (or load-balancer health checks) to these:
| Process | Endpoint | Port | Behavior |
|---|---|---|---|
pug server |
GET /healthz |
:3000 |
Liveness: 200 ok while the process is serving. Does not check dependencies (a dependency blip must not restart-cascade the fleet). |
pug server |
GET /readyz |
:3000 |
Readiness: pings Postgres (reader + writer), ClickHouse, Dragonfly, and NATS. 200 JSON when all are reachable, 503 otherwise (pod is pulled from rotation, not killed). |
| Each worker | GET /healthz |
:8090 |
Liveness: 200 while its consume loops run, 503 if wedged -> restart. |
| Each worker | GET /readyz |
:8090 |
Readiness: 200 once started and connected to NATS, 503 otherwise -> gate rollouts. |
The worker probe address is PUG_WORKER_HEALTH_ADDR (default :8090; set to off to disable). A worker opens it only once its first JetStream consumer exists, so a probe sent earlier gets a refused connection rather than a 503 - give the probes a startup allowance (initialDelaySeconds, or a startupProbe). When several worker binaries share a host, give each a distinct port to avoid a bind clash. pug dev disables worker probes (it’s for local use). The demo worker has no probes.
Backing services keep their own health endpoints - probe those for infrastructure liveness:
| Service | Health endpoint |
|---|---|
| NATS | GET http://<host>:8222/healthz |
| ClickHouse | GET http://<host>:8123/ping |
| PostgreSQL | pg_isready / TCP connect to :5432 |
| Dragonfly | redis-cli ping |
Further reading
- Dashboard: build and serve the UI
- Connect your SDKs: point your apps at this instance
- CLI reference: every
pugcommand and flag - Configuration: all environment variables