Backing services, toolchains, ports, and sizing for self-hosting Pug.
Toolchains
What you need depends on whether you build from source or run prebuilt container images.
| Tool | Version | Needed for | Why |
|---|---|---|---|
| Go | >= 1.26 | Building the backend | Compiles the pug server, workers, and CLI. The repo targets 1.26.3. |
| Bun | Latest | Building the dashboard | Installs deps and builds the React SPA. Node 22 is the runtime baseline. |
| Docker | Any recent release | Local infra or container builds | Runs the four backing services locally, and builds the backend images. |
If you deploy from published container images instead of building, you need neither Go nor Bun - only a container runtime. See Deployment.
git clone git@github.com:pug-sh/pug.git # backend: server, workers, pug CLI
cd pugThe dashboard lives in a separate repo (pug-sh/app) and is covered in Dashboard.
Backing services
The backend requires four backing services: PostgreSQL, ClickHouse, NATS, and Dragonfly. The versions below are what the project develops and tests against (from the dev Docker Compose); you may run managed equivalents (e.g. Amazon RDS for Postgres, ClickHouse Cloud) at compatible versions. The Pug server itself appears in the table only for its listening port.
| Component | Image / Version | Container port(s) | Role |
|---|---|---|---|
| PostgreSQL | postgres:18 |
5432 |
Auth, orgs, projects, dashboards, and config. Dev host mapping: 5433->5432. |
| ClickHouse | clickhouse/clickhouse-server:26.5 |
8123 (HTTP / /ping), 9000 (native), 9009 (interserver) |
Analytics event storage and queries. Requires ulimits.nofile >= 262144. |
| NATS | nats:2.14-alpine |
4222 (client), 6222 (cluster), 8222 (monitoring) |
JetStream message queue. Must be started with --jetstream. |
| Dragonfly | docker.dragonflydb.io/dragonflydb/dragonfly:v1.38.1 |
6379 |
Redis-compatible cache / rate-limit. Dev host mapping: 6380->6379. |
| Pug server | - | 3000 |
The Connect RPC API server. Override with PUG_SERVER_PORT. |
NATS JetStream is required. Start NATS with
--jetstream(and--store_dirfor a durable volume). The monitoring server (-m 8222) exposes/healthzon port 8222. Without JetStream, event ingestion will not function.
For how the dev Compose file wires these up locally (health checks, volumes, and the observability overlay), see Development.
Ports summary
Only the Pug server (and your static dashboard host) need to be reachable from outside your private network. All backing services should be kept on an internal network:
| Port | Service | Exposure |
|---|---|---|
3000 |
Pug server (Connect RPC) | Public: behind a TLS terminator |
8090 |
Worker health / readiness | Internal only: probes (configurable via PUG_WORKER_HEALTH_ADDR) |
5432 |
PostgreSQL | Internal only |
4222 |
NATS client | Internal only |
8123 / 9000 |
ClickHouse HTTP / native | Internal only |
6379 |
Dragonfly | Internal only |
Sizing guidance
These are starting-point ballparks, not hard requirements. Actual resource needs depend heavily on event volume and retention.
Development / staging
| Component | CPU | RAM | Disk |
|---|---|---|---|
| Pug server + workers | 2 vCPU | 2 GB | - |
| PostgreSQL | 1 vCPU | 2 GB | 20 GB |
| ClickHouse | 2 vCPU | 4 GB | 50 GB |
| NATS | 1 vCPU | 1 GB | 10 GB (JetStream store) |
| Dragonfly | 1 vCPU | 1 GB | - |
Production (small - up to ~1 k events/s)
| Component | CPU | RAM | Disk |
|---|---|---|---|
| Pug server | 2 vCPU | 2 GB | - |
| Workers (per type) | 2 vCPU | 2 GB | - |
| PostgreSQL | 2 vCPU | 8 GB | 100 GB SSD |
| ClickHouse | 4 vCPU | 16 GB | 500 GB SSD |
| NATS | 2 vCPU | 2 GB | 20 GB SSD |
| Dragonfly | 1 vCPU | 2 GB | - |
The dashboard is static files - it needs no compute of its own, only a web server or CDN.
Production (larger workloads)
Scale the events worker horizontally for higher ingest throughput - each replica is an independent JetStream consumer. Give ClickHouse more CPU, RAM, and disk as event volume and retention grow. NATS benefits from a 3-node cluster for JetStream HA.
Optional: geo enrichment (Cloudflare)
Geo auto-properties ($country, $region, $city, $continent, and related fields) are resolved at ingestion by the server - it reads them from Cloudflare proxy headers (CF-IPCountry, CF-Region, CF-IPCity, CF-IPContinent, etc.) on the inbound request. So this needs Cloudflare (or a proxy that sets the same headers) in front of the server; the visitor IP itself is never persisted. Because the values come from the inbound request, they describe the client that sent the event - see Events sent from your own backend below.
CF-IPCountryis added automatically when Cloudflare IP Geolocation is enabled on your zone.CF-Region,CF-IPCity, and the remaining location headers require the “Add visitor location headers” Managed Transform to be enabled.
Events sent from your own backend
Those headers describe whoever opened the connection to Pug, which is the visitor only when the event comes from their browser. An event your backend sends is located at your backend, and forwarding headers cannot change that: Cloudflare sets CF-Connecting-IP and the CF-IP* headers itself from the connecting address and overwrites whatever a client sent for them. X-Forwarded-For does survive - Cloudflare appends to it rather than replacing it - but Pug consults CF-Connecting-IP first, so behind Cloudflare the forwarded value is never read.
Send the location on the event instead. A request authenticated with a private (prv_) API key is your own server, so Pug skips geo enrichment there entirely - it keeps the location the event carries and adds none of its own:
"autoProperties": {
"$country": { "stringValue": "DE" },
"$city": { "stringValue": "Berlin" }
}From @pug-sh/node 0.0.6+ this is track()’s location option rather than hand-written autoProperties, which the SDK does not otherwise let you set:
pug.track('user-123', 'order.completed', { amount: 49 }, {
location: { country: 'DE', city: 'Berlin' },
})Because nothing is merged, your $city is never paired with the edge’s $country - and an event that carries no location stores none. Pug will not fall back to the headers, since your backend’s city is not your visitor’s. Send $country as an ISO 3166-1 alpha-2 code: Pug stores what you give it without normalizing, so USA or us becomes a breakdown value of its own and will not render on the map. The SDK’s location option checks the code and fixes its case before sending, so that one only bites hand-written payloads.
This applies to private keys only - a public (pub_) key ships inside client apps where anyone can copy it, so geo on those requests stays server-derived. $ip is stripped from every event either way.
Geo lookup sits behind a small Provider interface (internal/geo), and Cloudflare headers are the only implementation today. The interface is designed to also accept a local database lookup, e.g. MaxMind (MMDB), from the client IP in a future release. Until then, if Pug runs behind a different proxy or without these headers, geo auto-properties are simply absent from events; there’s no built-in GeoIP database yet.
Further reading
- Configuration: environment variables and secrets
- Deployment: production deployment guide
- Development: local dev stack setup