Skip to content

Requirements

Hardware, dependency versions and network prerequisites for a self-hosted Pug deployment.

Updated View as Markdown

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 pug

The 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_dir for a durable volume). The monitoring server (-m 8222) exposes /healthz on 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-IPCountry is 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

Navigation

Type to search...

↑↓ navigate↵ selectEsc close