Skip to content

Deployment

Build or pull the Pug backend, run its migrations, processes and scheduled jobs, and harden it for production.

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 build

make 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 and nats migrate read schema/nats/*.yaml, and the Postgres and ClickHouse migrate runners read schema/{postgres,clickhouse}/migrations, all relative to the working directory. Ship the repo’s schema/ folder with the binaries and start each unit from the folder above it (systemd WorkingDirectory=). The NATS files can also be pointed at directly with NATS_STREAMS_CONFIG and NATS_CONSUMERS_CONFIG; the migration folders can’t.

There is no make migrate or make dev target. Migrations run via the pug CLI or the standalone migrate binaries (below); local all-in-one runs via the pug dev command. 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.36

Each 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>: which main to 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-clickhouse

All 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 image

Listens 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
Email 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-events

Scheduled 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 (alias pug 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 no ON 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: ensure nofile >= 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

Navigation

Type to search...

↑↓ navigate↵ selectEsc close