Product and growth teams often move faster than platform teams can standardize analytics tooling. The result is predictable: event schemas drift, dashboard definitions fragment, and executive metrics stop matching between tools. A self-hosted PostHog deployment can fix this, but only when it is delivered as a production service rather than a demo container. This guide walks through a practical Ubuntu deployment using Docker Compose behind Traefik, with PostgreSQL and ClickHouse storage split cleanly, secure secret handling, verification steps, and an operational runbook your on-call team can actually use.
The approach here is designed for teams that need reliable ingestion, controlled upgrades, and clear recovery procedures. Instead of focusing on a quick “it works on my VM” setup, we focus on production concerns: deterministic configuration, reverse-proxy hardening, persistence boundaries, backup scope, observability touchpoints, and safe rollout strategy. If you are replacing ad-hoc client-side analytics scripts with a centralized platform, this architecture gives you enough guardrails to support both experimentation and governance without turning every release into incident risk.
Architecture and flow overview
In this deployment, Traefik terminates TLS and routes public traffic to the PostHog web service over an internal Docker network. PostHog writes transactional and metadata records to PostgreSQL while event and analytical workloads are handled by ClickHouse. Redis supports caching and queue-related workflows. This separation is important in production because analytical workloads can spike heavily during campaign launches or backfills; isolating the storage layers helps preserve UI responsiveness and reduces noisy-neighbor behavior.
From an operations perspective, think of this as four zones: edge routing (Traefik), application runtime (PostHog container), durable state (PostgreSQL + ClickHouse volumes), and automation guardrails (backups, health checks, and update runbooks). You should avoid sharing these volumes with unrelated stacks. Keep the PostHog network private, expose only Traefik entry points, and treat your environment file as a secret artifact that never lands in source control. The flow is straightforward: browser/client SDK → Traefik HTTPS endpoint → PostHog API/app → PostgreSQL/ClickHouse/Redis.
Prerequisites
- Ubuntu 22.04 or 24.04 host with at least 4 vCPU, 8 GB RAM, and fast SSD storage (16+ GB RAM recommended for higher event volume).
- A DNS record (e.g.,
analytics.example.com) pointing to your server public IP. - Docker Engine + Compose plugin and a Traefik instance already handling HTTPS certificates.
- Firewall policy allowing 80/443 inbound, and SSH restricted by source where possible.
- A secure secret-management process (Vault, SOPS, or at minimum encrypted storage for
.envfiles).
Step-by-step deployment
Start by updating the host and installing required platform packages. Keep base OS maintenance explicit in your runbook so security patching does not get deferred indefinitely.
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg ufw git
sudo timedatectl set-timezone America/Chicago
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Install Docker and the Compose plugin from the official repository. This ensures predictable package versions and avoids distribution lag on critical runtime components.
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Create a dedicated project directory. Persisting all state under /opt/posthog simplifies backup targets and makes disaster recovery scripting easier.
sudo mkdir -p /opt/posthog && cd /opt/posthog
sudo mkdir -p env data/postgres data/clickhouse data/redis data/kafka data/zookeeper
sudo chown -R $USER:$USER /opt/posthog
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Create an environment file with strong, unique credentials. Rotate all placeholder values before go-live.
cat > /opt/posthog/env/.env <<'EOF'
POSTHOG_SECRET_KEY=replace-with-a-long-random-secret
POSTHOG_SITE_URL=https://analytics.example.com
POSTGRES_USER=posthog
POSTGRES_PASSWORD=replace-with-strong-postgres-password
POSTGRES_DB=posthog
CLICKHOUSE_USER=posthog
CLICKHOUSE_PASSWORD=replace-with-strong-clickhouse-password
REDIS_PASSWORD=replace-with-strong-redis-password
EOF
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Define your core stack in Compose. Keep data services isolated with explicit volume mounts and avoid exposing internal ports publicly.
cat > /opt/posthog/docker-compose.yml <<'EOF'
version: '3.9'
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
clickhouse:
image: clickhouse/clickhouse-server:24
environment:
CLICKHOUSE_USER: ${CLICKHOUSE_USER}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
volumes:
- ./data/clickhouse:/var/lib/clickhouse
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server","--requirepass","${REDIS_PASSWORD}"]
volumes:
- ./data/redis:/data
restart: unless-stopped
posthog:
image: posthog/posthog:latest
env_file:
- ./env/.env
depends_on:
- postgres
- clickhouse
- redis
restart: unless-stopped
networks:
default:
name: posthog_net
EOF
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Configure Traefik routing for your analytics hostname, then apply your reverse-proxy dynamic config according to your Traefik deployment model.
cat > /opt/posthog/traefik-dynamic.yml <<'EOF'
http:
routers:
posthog:
rule: Host(`analytics.example.com`)
service: posthog
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
posthog:
loadBalancer:
servers:
- url: "http://posthog:8000"
EOF
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Bring up the stack and watch initial logs until migrations complete and services stabilize.
cd /opt/posthog
docker compose --env-file ./env/.env up -d
docker compose ps
docker compose logs --no-color --tail=100 posthog
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Configuration and secrets handling best practices
Treat PostHog credentials like production database secrets, not app-level convenience values. The POSTHOG_SECRET_KEY should be long and random, and never reused across environments. Store your .env file outside Git and enforce file permissions (chmod 600) so only intended operators can read it. If your team uses Infrastructure as Code, keep secret references abstracted and inject values at deploy time through CI/CD secrets or a vault workflow.
For network hardening, avoid exposing PostgreSQL, ClickHouse, or Redis ports to the public interface. Containers should only communicate over the internal Docker network. If you run host monitoring agents, scope their access carefully and avoid broad Docker socket mounts unless strictly required. For compliance-sensitive environments, document retention periods for event data and implement pruning policies early, before storage growth causes emergency cleanup decisions.
Finally, define a clear rotation policy: database passwords quarterly, API keys on role changes, and emergency rotation procedures for suspected leakage. Production incidents become manageable when secret lifecycle management is pre-written rather than improvised.
Verification checklist
Verification should include both user-facing checks and storage-layer checks. Confirm HTTPS, health endpoints, and backend readiness before onboarding product teams.
curl -I https://analytics.example.com
curl -sS https://analytics.example.com/_health | jq .
docker compose exec postgres psql -U posthog -d posthog -c '\dt'
docker compose exec clickhouse clickhouse-client --query 'SELECT version()'
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
- ✅ TLS certificate is valid and auto-renewal is active via Traefik.
- ✅ PostHog UI loads without repeated 5xx responses.
- ✅ PostgreSQL tables and ClickHouse connectivity are healthy.
- ✅ Event ingestion test appears in dashboard with expected properties.
- ✅ System logs show no authentication loops or queue backpressure warnings.
Common issues and fixes
1) Login page loads, but events are delayed or missing
Check ClickHouse health first; delayed writes often correlate with storage pressure or container restart loops. Validate free disk space and verify ClickHouse startup logs before tuning application settings.
2) 502/504 errors through Traefik
This usually indicates routing mismatch or app startup lag. Confirm the service name in your dynamic config and ensure PostHog is reachable from Traefik on the internal network.
3) Database authentication failures after credential updates
Credential rotation can leave stale env values in running containers. Recreate services after updating .env and verify there are no shell-history typos in secret values.
4) Disk usage grows faster than expected
Analytics workloads can spike abruptly. Add retention and archival policies, monitor ClickHouse data directory growth, and schedule periodic capacity reviews tied to campaign calendars.
5) Upgrades introduce schema or migration surprises
Always test image updates in staging with production-like datasets. Keep rollback commands and last-known-good image tags documented before running docker compose pull in production.
FAQ
Can I run PostHog without ClickHouse in production?
You can for very small or temporary deployments, but it is not recommended for sustained production analytics. ClickHouse is central to performant event querying at scale.
Should I colocate PostHog with unrelated app stacks on the same host?
Only if you have strict resource controls and clear blast-radius boundaries. Dedicated hosts or well-isolated nodes are safer for predictable analytics performance.
How often should I back up PostgreSQL and ClickHouse data?
Daily backups are a practical baseline for most teams, with additional snapshots before upgrades or major schema changes. Validate restore drills regularly.
What is the minimum secure setup for secrets?
At minimum: unique strong values, encrypted-at-rest storage for secret files, restricted file permissions, and no plaintext secrets in git, chat, or tickets.
Can I put PostHog behind SSO later?
Yes. Many teams start with local auth, then integrate SSO once identity policies are standardized. Plan this early so role mapping and onboarding flows stay consistent.
How do I handle zero-downtime upgrades?
Use staged rollouts, pre-flight backups, and upgrade windows with rollback criteria. For strict uptime requirements, consider multi-node patterns and externalized data services.
Related internal guides
- Production Guide: Deploy Argo CD with Kubernetes + Helm + ingress-nginx on Ubuntu
- Production Guide: Deploy GlitchTip with Docker Compose + Caddy + PostgreSQL on Ubuntu
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy + PostgreSQL on Ubuntu
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.
Before closing the deployment, add backup and upgrade commands to your operational runbook. Even if you already have centralized backup tooling, explicit service-level commands reduce recovery ambiguity during incident response.
# Backup PostgreSQL daily
mkdir -p /opt/posthog/backups
docker compose exec -T postgres pg_dump -U posthog posthog > /opt/posthog/backups/posthog-$(date +%F).sql
# Backup ClickHouse metadata + data snapshot
tar -czf /opt/posthog/backups/clickhouse-$(date +%F).tgz /opt/posthog/data/clickhouse
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
# Rolling update playbook
cd /opt/posthog
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
If the copy button does not work in your browser, select the code block manually and copy with Ctrl/Cmd+C.
Document recovery objectives (RTO/RPO), owner responsibilities, and a quarterly game-day schedule. A production analytics platform is not complete until recovery has been tested with realistic failure scenarios.