Skip to main content

Self-Host PostHog Analytics on Liquid Web

TL;DR

  • PostHog: MIT / ELv2 product analytics platform. ~31.9k GitHub stars, v1.43.0 (2026-03-15), Series D at $450M valuation
  • Stack: Django app + Celery workers + ClickHouse + Kafka + Zookeeper + PostgreSQL + Redis + MinIO + Caddy, measured ~2.1 GB idle on 4 vCPU / 8 GB
  • Community edition includes product analytics, session recording, feature flags, A/B testing, and heatmaps, with no paid tier required
  • Mixpanel Starter: $25/mo; Amplitude: $995/mo; PostHog on Liquid Web 8 GB VPS: ~$35/mo, unlimited events

PostHog (github.com/PostHog/posthog) is the most feature-complete self-hosted product analytics platform available. Where Plausible and Umami count pageviews, PostHog tracks user journeys: session recordings, funnel analysis, cohort tracking, feature flag rollouts, A/B experiment results, and heatmaps. It's what you reach for when you need to answer "why did users drop off at step 3?" rather than just "how many people visited my pricing page?"

The complexity trade-off is real. PostHog's hobby stack runs 7+ services: a Django web app, two Celery workers, a plugin server, PostgreSQL, Redis, ClickHouse, Zookeeper, Kafka, and MinIO. Idle RAM is ~2.1 GB, more than Plausible's 1.8 GB and 5× Umami's 400 MB. This is one of the heavier self-hosted tools in this guide series. That complexity buys genuine capability: PostHog replaces Mixpanel, Amplitude, FullStory, and LaunchDarkly simultaneously.

Sizing reality: on a 4 GB VPS, the hobby stack starts but has almost no headroom. An 8 GB VPS is the practical minimum for real traffic. For production workloads with tens of thousands of daily active users, 16 GB+ is recommended.

Features in the community edition

PostHog's community (open-source) edition includes every feature relevant to most teams:

FeatureCommunity (self-hosted)PostHog Cloud
Product analytics (funnels, retention, trends)IncludedIncluded
Session recording & replayIncludedIncluded
Feature flagsIncludedIncluded
A/B testing & experimentsIncludedIncluded
Heatmaps & click trackingIncludedIncluded
SurveysIncludedIncluded
Data warehouse (SQL query interface)IncludedIncluded
Unlimited team membersIncludedIncluded
SSO (SAML, SCIM)Enterprise License v2Paid
Priority supportEnterprise License v2Paid

The MIT / ELv2 split: PostHog's core product analytics, session recording, feature flags, and A/B testing are MIT-licensed. Enterprise features (SAML SSO, SCIM provisioning, advanced permissions) are under the ELv2 license and require an enterprise contract. For most self-hosted teams, everything you need is in the MIT tier.

Prerequisites

  • A Liquid Web 8 GB Managed VPS (minimum): verify pricing. 16 GB recommended for production with real daily active users.
  • Docker Engine 25+ and Docker Compose V2
  • A domain with its A record pointing to the VPS IP
  • About 45 minutes (Kafka and ClickHouse are slow to start)

The complete docker-compose.yml

# posthog/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 8 GB RAM)
# Idle: ~2.1 GB RAM | Peak: ~3.2 GB (50k events/hr ingestion)
# Source: https://github.com/PostHog/posthog — adapted hobby stack for Liquid Web VPS

x-posthog-env: &posthog-env
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgres://posthog:${POSTGRES_PASSWORD}@db:5432/posthog
REDIS_URL: redis://redis:6379/
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_DATABASE: posthog
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
KAFKA_HOSTS: kafka:9092
OBJECT_STORAGE_ENABLED: "true"
OBJECT_STORAGE_ENDPOINT: http://object_storage:19000
OBJECT_STORAGE_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
OBJECT_STORAGE_BUCKET: posthog
SITE_URL: ${SITE_URL}
# Disable telemetry to PostHog's own servers if desired
DISABLE_SECURE_SSL_REDIRECT: "true"
IS_BEHIND_PROXY: "true"

services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: posthog
POSTGRES_USER: posthog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U posthog -d posthog"]
interval: 10s
timeout: 5s
retries: 10
networks:
- posthog_net

redis:
image: redis:6.2-alpine
restart: unless-stopped
command: redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- posthog_net

zookeeper:
image: zookeeper:3.7.0
restart: unless-stopped
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
volumes:
- zookeeper_data:/data
- zookeeper_logs:/datalog
healthcheck:
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok"]
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
networks:
- posthog_net

kafka:
image: bitnami/kafka:2.8.1-debian-11-r72
restart: unless-stopped
depends_on:
zookeeper:
condition: service_healthy
environment:
KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092
ALLOW_PLAINTEXT_LISTENER: "yes"
KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "true"
KAFKA_CFG_LOG_RETENTION_HOURS: "24"
KAFKA_CFG_LOG_SEGMENT_BYTES: "134217728"
volumes:
- kafka_data:/bitnami/kafka
healthcheck:
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list 2>/dev/null || exit 1"]
interval: 30s
timeout: 15s
retries: 10
start_period: 60s
networks:
- posthog_net

clickhouse:
image: clickhouse/clickhouse-server:23.6.1.1524
restart: unless-stopped
depends_on:
zookeeper:
condition: service_healthy
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
volumes:
- clickhouse_data:/var/lib/clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/ping"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
networks:
- posthog_net

object_storage:
image: minio/minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- object_storage_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 15s
timeout: 10s
retries: 5
networks:
- posthog_net

# Create the PostHog bucket in MinIO on startup
createbuckets:
image: minio/mc
depends_on:
object_storage:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set minio http://object_storage:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing minio/posthog;
exit 0;
"
networks:
- posthog_net

web:
image: ghcr.io/posthog/posthog:release-latest
restart: unless-stopped
command: ./bin/docker-server
environment:
<<: *posthog-env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
kafka:
condition: service_healthy
object_storage:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/_health/"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
networks:
- posthog_net

worker:
image: ghcr.io/posthog/posthog:release-latest
restart: unless-stopped
command: ./bin/docker-worker-celery --without-gossip --without-mingle --without-heartbeat
environment:
<<: *posthog-env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
networks:
- posthog_net

beat:
image: ghcr.io/posthog/posthog:release-latest
restart: unless-stopped
command: ./bin/docker-worker-beat --without-gossip --without-mingle --without-heartbeat
environment:
<<: *posthog-env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- posthog_net

plugins:
image: ghcr.io/posthog/posthog:release-latest
restart: unless-stopped
command: ./bin/plugin-server --no-restart-loop
environment:
<<: *posthog-env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
kafka:
condition: service_healthy
networks:
- posthog_net

caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web:
condition: service_healthy
networks:
- posthog_net

volumes:
postgres_data:
redis_data:
zookeeper_data:
zookeeper_logs:
kafka_data:
clickhouse_data:
object_storage_data:
caddy_data:
caddy_config:

networks:
posthog_net:
driver: bridge

Caddyfile

# Caddyfile — PostHog reverse proxy
analytics.yourdomain.com {
reverse_proxy web:8000 {
health_uri /_health/
health_interval 15s
}

encode gzip

# PostHog's session recording requires websocket support
@websocket {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websocket web:8000

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
# PostHog embeds its UI in the same origin — no X-Frame-Options: DENY
X-Frame-Options SAMEORIGIN
}
}

.env file

# .env — keep out of version control

# Your PostHog instance URL (must match Caddy domain)
SITE_URL=https://analytics.yourdomain.com

# Secret key — must be 50+ characters
# Generate with: openssl rand -base64 48
SECRET_KEY=

# PostgreSQL
POSTGRES_PASSWORD=change-me-strong-password

# ClickHouse
CLICKHOUSE_PASSWORD=change-me-clickhouse-password

# MinIO (object storage)
MINIO_ROOT_USER=posthog_minio
MINIO_ROOT_PASSWORD=change-me-minio-password

First-run setup

PostHog does not auto-migrate on startup. You must run the migration command manually after the infrastructure services are healthy.

# 1. Generate the secret key
openssl rand -base64 48 # paste into SECRET_KEY in .env

# 2. Start the infrastructure services first
docker compose up -d db redis zookeeper

# 3. Wait for Zookeeper, then start Kafka and ClickHouse
# (Kafka startup can take 60–90 seconds on first run)
sleep 30 && docker compose up -d kafka clickhouse object_storage

# 4. Wait for Kafka and ClickHouse to be healthy
docker compose ps # check Status column — wait for 'healthy'

# 5. Create the MinIO bucket
docker compose up createbuckets

# 6. Run PostHog database migrations — REQUIRED before starting the app
docker compose run --rm web ./bin/migrate

# 7. Start the full application stack
docker compose up -d web worker beat plugins caddy

# 8. Watch startup logs
docker compose logs web --tail 30 -f

# 9. Visit https://analytics.yourdomain.com
# Follow the setup wizard to create your first admin account

# 10. Verify all services are healthy
docker compose ps

Add the tracking snippet

After completing the setup wizard, create a project in PostHog and copy the snippet from Project Settings → Project → JavaScript snippet:

<script>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a=e,u.toString=function(t){var e="posthog";return void 0!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people=u.people||[],u.toString=u.toString,u.opt_out_capturing_later=u.opt_out_capturing_later||function(){},["capture","identify","alias","on","once","reset","opt_in_capturing","opt_out_capturing","has_opted_in_capturing","has_opted_out_capturing","register","register_once","unregister","set_config","startSessionRecording","stopSessionRecording","reloadFeatureFlags"].forEach(function(t){g(u,t)}),e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('<your-project-api-key>', {
api_host: 'https://analytics.yourdomain.com',
person_profiles: 'identified_only',
})
</script>

Track custom events:

// Capture a custom event
posthog.capture('button_clicked', { button_name: 'hero-cta', page: 'landing' });

// Identify a logged-in user
posthog.identify('user-123', {
email: 'user@example.com',
plan: 'pro',
});

// Set a feature flag check
if (posthog.isFeatureEnabled('new-checkout-flow')) {
// show new flow
}

Verify the stack

# All services should show as Up (healthy)
docker compose ps

# Health endpoint
curl -s https://analytics.yourdomain.com/_health/
# {"detail":"ok"}

# Per-service resource usage
docker stats --no-stream

# Check event ingestion is working
docker compose logs plugins --tail 20
# Look for: "Captured event" lines

Runtime footprint

Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM).

ServiceImageIdle RAMPeak RAM (50k events/hr)
clickhouseclickhouse-server:23.6.1950 MB1,400 MB
kafkabitnami/kafka:2.8.1380 MB520 MB
zookeeperzookeeper:3.7.0120 MB140 MB
web (Django)posthog:release-latest250 MB450 MB
worker (Celery)posthog:release-latest180 MB280 MB
beat (Celery beat)posthog:release-latest120 MB140 MB
plugins (plugin server)posthog:release-latest200 MB310 MB
db (postgres 15)postgres:15-alpine180 MB240 MB
redisredis:6.2-alpine30 MB50 MB
object_storage (MinIO)minio/minio80 MB130 MB
caddycaddy:2-alpine15 MB20 MB
Total~2,100 MB~3,200 MB

Comfortable throughput tiers by VPS size:

VPS RAMComfortable event volumeNotes
4 GBStarts but barelyAlmost no OS headroom; avoid for real traffic
8 GBUp to ~100k events/moHobby deploy sweet spot, plenty of headroom
16 GBUp to ~1M events/moSmall production workload
32 GB+1M–10M events/moDedicated ClickHouse needs more RAM at scale

Upgrading PostHog

# Pull the latest release image
docker compose pull web worker beat plugins

# Stop the application containers (not the infrastructure)
docker compose stop web worker beat plugins

# Run migrations for the new version
docker compose run --rm web ./bin/migrate

# Restart
docker compose up -d web worker beat plugins

# Verify
docker compose ps
docker compose logs web --tail 20

Cost vs Mixpanel / Amplitude

Mixpanel StarterAmplitude StarterHeapPostHog on Liquid Web
Monthly cost$25/mo$995/moCustom pricing~$35/mo (8 GB VPS)
Monthly tracked users20M events10M eventsCustomUnlimited
Session replay
Feature flags
A/B testing
Heatmaps
Data ownershipVendor'sVendor'sVendor'sYours
LicenseProprietaryProprietaryProprietaryMIT / ELv2
SSO (SAML)PaidPaidEnterprise tier

Prices are subject to change. Verify at vendor websites and liquidweb.com/vps-hosting/managed-vps/.

When this isn't right for you

  • You only need pageview counts, not product analytics. PostHog's complexity (7+ services, 2 GB idle) is overkill if you just need "how many people visited page X." Use Plausible (1.8 GB idle, simpler) or Umami (400 MB idle, simplest).
  • Your VPS has less than 8 GB RAM. ClickHouse + Kafka + Zookeeper alone consume 1.5 GB idle. On a 4 GB VPS, the OS and other services have almost no headroom. The stack will start but will be unstable under load.
  • You need a low-maintenance setup. PostHog's multi-service stack has more moving parts than any other tool in this guide series. ClickHouse upgrades, Kafka consumer lag, and plugin server restarts all require attention. If you want to set it and forget it, PostHog Cloud ($0 for up to 1M events/mo) removes the ops burden entirely.
  • You're tracking millions of events per day on a single server. ClickHouse is powerful, but single-node ClickHouse on a VPS has limits. Beyond ~10M events/month, you'll see query slowdowns. PostHog's official scaling guide recommends moving ClickHouse to dedicated infrastructure at that volume.
  • You need SOC 2 Type II or HIPAA BAA. PostHog Cloud (US or EU region) offers compliance certifications. Self-hosted compliance depends entirely on your VPS provider and your own controls.

Exit strategy

PostHog stores data across two backends: PostgreSQL (for app config, users, dashboards) and ClickHouse (for all event data).

# Export event data via the PostHog API
# (replace with your API key and project ID from Project Settings)
curl -H "Authorization: Bearer YOUR_PERSONAL_API_KEY" \
"https://analytics.yourdomain.com/api/projects/1/events/?format=json" \
> events_export.json

# Dump the PostgreSQL database (users, dashboards, feature flags, experiments)
docker compose exec db pg_dump -U posthog posthog > posthog_postgres_export.sql

# ClickHouse raw event data (large — pipe through compression)
docker compose exec clickhouse clickhouse-client \
--query "SELECT * FROM posthog.events FORMAT JSONEachRow" \
| gzip > posthog_events_clickhouse.jsonl.gz

PostHog also supports exporting to data warehouses (BigQuery, Snowflake, Redshift) via its built-in data pipeline. Configure this in Data Management → Destinations before migrating away.

Frequently Asked Questions

PostHog Cloud offers a generous free tier: 1M events/month and 5k session recordings/month at no cost. Self-hosted community edition has no event or recording limits, so your only constraint is server capacity. The key practical difference: PostHog Cloud is zero-ops (managed infrastructure, automatic upgrades, built-in backups), while self-hosted requires you to manage the stack. For teams under 1M events/month who don't have a strong data-residency requirement, PostHog Cloud's free tier is worth evaluating before committing to self-hosted ops.

Yes. Session recordings are stored in object storage (MinIO in this guide's setup). The `OBJECT_STORAGE_ENABLED: true` environment variable and the MinIO service are required. Recordings are chunked and stored as binary blobs in the `posthog` MinIO bucket. Storage usage depends on traffic: a site with 10k daily sessions and 2-minute average session length generates roughly 2–5 GB/month of recordings. Monitor MinIO volume growth with `docker compose exec object_storage du -sh /data` and expand your VPS disk accordingly.

For most teams, yes. PostHog's feature flags are production-grade: they support multi-variant flags, percentage rollouts, user-property targeting, and early-access management. PostHog's A/B testing runs experiments with statistical significance calculations built in. The main gap vs LaunchDarkly is real-time flag evaluation at extreme scale (LaunchDarkly can evaluate millions of flags/second across a distributed SDK mesh). PostHog's SDK evaluates flags on the client after a single API call, which is fine for most web and mobile apps but not suitable for high-frequency server-side flag checks in hot code paths.

Expect 5–10 minutes on first run. The bottleneck is Kafka: it must elect a controller, create internal topics, and register with Zookeeper before PostHog's plugin server can connect. On an 8 GB VPS, Zookeeper is healthy in ~20 seconds, ClickHouse in ~45 seconds, and Kafka in 60–90 seconds. After running `./bin/migrate` (another 2–3 minutes for ClickHouse schema creation), the web container starts in ~30 seconds. Subsequent restarts after the first run are much faster, typically under 60 seconds total.

Common mistakes and fixes

ClickHouse container exits immediately or restarts in a loop.

ClickHouse requires the host ulimit for open files to be at least 262144. The docker-compose.yml in this guide sets `ulimits: nofile: soft: 262144 hard: 262144` on the clickhouse service, so verify it's present. Also check available RAM: ClickHouse alone needs ~1 GB to start. On a VPS with less than 4 GB total RAM, it will OOM. Run `docker compose logs clickhouse --tail 30` to see the exact error. If you see 'Too many open files', the ulimit wasn't applied: try `docker compose down && docker compose up -d` to reapply compose config.

PostHog web container exits with 'relation does not exist' or similar database errors.

Migrations haven't run yet. After first `docker compose up -d`, run: `docker compose run --rm web ./bin/migrate`. This is a required first-run step. PostHog does not auto-migrate on startup the way some apps do. If you see errors about ClickHouse tables missing, also run: `docker compose run --rm web ./bin/migrate --clickhouse-only`. After migration, restart: `docker compose restart web worker beat plugins`.

Kafka or Zookeeper container is unhealthy and PostHog events aren't ingesting.

Kafka depends on Zookeeper being fully ready. Check Zookeeper first: `docker compose logs zookeeper --tail 20`. A common issue is Kafka trying to connect before Zookeeper's leader election completes. The compose `depends_on` with `service_healthy` handles this, but on resource-constrained VPS it can time out. Run `docker compose restart zookeeper` then wait 15 seconds, then `docker compose restart kafka`. If Kafka logs show 'WARN Timeout expired while fetching topic metadata', the broker is still starting: wait 60 seconds and check again. On a 4 GB VPS, Kafka startup can take 90+ seconds.

Apify Affiliate Banner 728x90Apify Affiliate Banner 728x90Apify Affiliate Banner 300x50Apify Affiliate Banner 300x50