Picture of the author
Published on

How to Self-Host PostHog with Docker Compose

A practical field guide to running PostHog yourself with the official Docker Compose setup, production domains, first-party analytics, session recordings, backups, and lower resource usage.

Table of Contents

  • Why self-host PostHog?
  • What we are building
  • What PostHog actually runs
  • Server requirements
  • Start from the official Docker Compose setup
  • Add a real .env.example
  • Configure the public PostHog domain
  • Configure reverse proxy domains
  • Configure trusted origins
  • Add first-party analytics through /ingest
  • Configure object storage for session recordings
  • Make the setup restart-safe
  • Reduce CPU and memory usage
  • Tune ClickHouse
  • Tune Kafka and ZooKeeper
  • Backups
  • Deployment checklist
  • Gotchas and errors we hit
  • Conclusion
  • Sources and more reading

Why self-host PostHog?

PostHog is an open-source product analytics platform.

It gives you product analytics, web analytics, dashboards, feature flags, experiments, surveys, and session recordings.

You can use PostHog Cloud, and for most teams that is the easiest path. But sometimes you want to self-host.

Maybe you want your analytics data on your own server. Maybe you want first-party analytics under your own domain. Maybe you are running internal products. Maybe you just want to control cost.

Self-hosting PostHog is possible, but it is not like self-hosting a small CRUD app.

PostHog is a full analytics stack.

You are not just running one web container. You are running databases, queues, workers, object storage, and a reverse proxy.

The main lesson is simple:

Start from the official PostHog Docker Compose setup, then make your production changes durable.

Do not create a tiny custom compose file from scratch and hope it behaves like PostHog Cloud.

That is how you end up with analytics partially working, recordings broken, Kafka failing validation, or workers crashing after every redeploy.

What we are building

The final setup looks like this:

https://posthog.example.com

for the PostHog web UI.

And optionally:

https://yourwebsite.com/ingest

for first-party analytics ingestion.

The goal is a setup where:

  • PostHog runs with Docker Compose.
  • The web UI works.
  • Analytics ingestion works.
  • Feature flags work.
  • Session recordings work.
  • The stack survives restarts.
  • The stack survives redeploys.
  • Config lives in Compose, env vars, mounted config files, or proxy/domain config.
  • ClickHouse does not eat the whole server.
  • Kafka and ZooKeeper are not oversized for a small install.
  • Backups include Postgres, ClickHouse, object storage, env, and compose files.

This matters because there is a big difference between:

PostHog is running right now

and:

PostHog is self-hosted properly

The second one means you can restart the server, redeploy the stack, or move it to another server with a plain Docker Compose workflow and still have a working system.

What PostHog actually runs

A real self-hosted PostHog setup usually includes:

web
worker
plugins
postgres
clickhouse
kafka
zookeeper
redis
temporal
object storage
migrations

Each service has a role.

Postgres stores application metadata.

ClickHouse stores analytics events.

Kafka handles event ingestion.

ZooKeeper coordinates Kafka in older Kafka setups.

Redis handles cache and background coordination.

Temporal handles workflows.

Workers process events and background jobs.

Object storage stores blobs, including session recording data.

The web service serves the UI and API.

If one piece is misconfigured, the site can still load while some important feature fails.

For example:

  • PostHog UI works, but Kafka validation fails.
  • Events work, but session recordings return 500.
  • /e works, but /array/.../config redirects to /login.
  • Workers start, then crash because Temporal is unreachable.
  • ClickHouse works, but uses too much CPU in the background.

That is why you need to test the whole stack, not just the homepage.

Server requirements

For a small production PostHog install, I would start with:

4 CPU cores
8 GB RAM
100 GB SSD
Ubuntu 22.04 or 24.04

You can run smaller, but you will need to tune more aggressively.

The heavy services are usually:

  • ClickHouse
  • Kafka
  • PostHog workers
  • plugin server
  • object storage during recording traffic

ClickHouse is usually the one that surprises people. It can use CPU even when the app looks idle because it does background merges and internal work.

For a more comfortable setup:

8 CPU cores
16 GB RAM
200+ GB SSD

If you are testing, smaller is fine.

If you are running production analytics and recordings, give the stack breathing room.

Start from the official Docker Compose setup

Clone PostHog:

git clone https://github.com/PostHog/posthog.git
cd posthog

Use the official self-hosting docs and compose setup as your base.

The exact file layout can change over time, so always check the current PostHog docs and repository.

The important principle is this:

Let PostHog's official compose setup define the required services. Your job is to configure it for your server.

Do not remove services blindly.

Do not replace Kafka with a random queue.

Do not remove object storage and expect recordings to work.

Do not hardcode container IPs.

Use Docker service names:

postgres
clickhouse
kafka
zookeeper
redis
temporal
objectstorage

Inside Docker Compose, service discovery by name is what you want.

Add a real .env.example

Keep a .env.example in the repo.

It should show the shape of the configuration without real secrets.

Example:

SITE_URL=https://posthog.example.com
SECRET_KEY=change-me

IS_BEHIND_PROXY=true
TRUSTED_PROXIES=*

POSTGRES_DB=posthog
POSTGRES_USER=posthog
POSTGRES_PASSWORD=change-me

CLICKHOUSE_DATABASE=posthog
CLICKHOUSE_USER=posthog
CLICKHOUSE_PASSWORD=change-me

REDIS_URL=redis://redis:6379

KAFKA_HOSTS=kafka:9092
ZOOKEEPER_HOSTS=zookeeper:2181

OBJECT_STORAGE_ENABLED=true
OBJECT_STORAGE_ENDPOINT=http://objectstorage:19000
OBJECT_STORAGE_BUCKET=posthog
OBJECT_STORAGE_ACCESS_KEY_ID=change-me
OBJECT_STORAGE_SECRET_ACCESS_KEY=change-me

CSRF_TRUSTED_ORIGINS=https://posthog.example.com,https://yourwebsite.com

Generate secrets:

openssl rand -hex 32

Do not commit your real .env.

The purpose of .env.example is reproducibility.

Someone should be able to do:

cp .env.example .env
nano .env
docker compose up -d

and understand what values are required.

Configure the public PostHog domain

Pick a public domain:

posthog.example.com

Create DNS:

A    posthog.example.com    YOUR_SERVER_IP

Then set:

SITE_URL=https://posthog.example.com

This must match the real public URL.

If the public URL and PostHog's configured URL disagree, you can get:

  • too many redirects
  • login redirects
  • wrong cookies
  • CSRF failures
  • broken links
  • HTTPS/HTTP confusion

If PostHog is behind a reverse proxy, set:

IS_BEHIND_PROXY=true

Your proxy must forward the usual headers:

X-Forwarded-Proto
X-Forwarded-Host
X-Forwarded-For

Configure reverse proxy domains

In production, PostHog should be behind HTTPS.

You can use:

  • Dokploy
  • Traefik
  • Caddy
  • Nginx
  • Coolify
  • Cloudflare Tunnel

Conceptually, the public domain should route to the PostHog web service:

posthog.example.com -> web:8000

With Traefik labels, this looks like:

labels:
  - traefik.enable=true
  - traefik.http.routers.posthog.rule=Host(`posthog.example.com`)
  - traefik.http.routers.posthog.entrypoints=websecure
  - traefik.http.routers.posthog.tls.certresolver=letsencrypt
  - traefik.http.services.posthog.loadbalancer.server.port=8000

With Caddy:

posthog.example.com {
    reverse_proxy web:8000
}

With Dokploy, add a domain for the compose web service and point it at the internal web port.

Important: after changing domains in Dokploy, redeploy.

The practical rule is:

Update domain -> redeploy -> check logs -> test in browser

Configure trusted origins

If your website sends analytics from:

https://yourwebsite.com

PostHog must trust that origin.

Otherwise you can get logs like:

Forbidden (Origin checking failed - https://yourwebsite.com does not match any trusted origins)

That is not a Kafka problem.

That is not a browser problem.

That is PostHog rejecting the request because the origin is not trusted.

Set trusted origins:

CSRF_TRUSTED_ORIGINS=https://posthog.example.com,https://yourwebsite.com

If you have multiple frontend sites:

CSRF_TRUSTED_ORIGINS=https://posthog.example.com,https://site1.com,https://site2.com

Then redeploy:

docker compose up -d

This is one of the gotchas with analytics platforms. Requests come from the website being tracked, not only from the PostHog UI domain.

Add first-party analytics through /ingest

You can send analytics directly to PostHog:

posthog.init("PROJECT_API_KEY", {
  api_host: "https://posthog.example.com",
})

But a better production setup is often first-party ingestion:

https://yourwebsite.com/ingest

The browser sends analytics to your own domain, and your reverse proxy forwards those requests to PostHog.

Frontend config:

posthog.init("PROJECT_API_KEY", {
  api_host: "https://yourwebsite.com/ingest",
  ui_host: "https://posthog.example.com",
})

This helps with ad blockers and browser privacy behavior.

But the proxy must be correct.

PostHog JS uses more than one endpoint. You may see requests to paths like:

/e
/s
/batch
/flags
/array
/i/v0/e
/static

If you proxy only one path, you get partial success.

Example broken behavior:

/ingest/e                     200
/ingest/flags                 200
/ingest/array/.../config      302 -> /login

That means your proxy is incomplete.

If /array/.../config redirects to /login, that request is being treated like a normal authenticated UI route instead of an ingestion/config route.

You want all relevant /ingest/* traffic to route consistently to PostHog.

Configure object storage for session recordings

Session recordings need object storage.

Events can work while recordings fail.

That is important.

If recordings show in the UI but replay returns 500, check object storage first.

You can use:

  • local object storage from the compose stack
  • MinIO
  • AWS S3
  • Cloudflare R2
  • Backblaze B2
  • Contabo Object Storage
  • any S3-compatible storage

Example local object storage config:

OBJECT_STORAGE_ENABLED=true
OBJECT_STORAGE_ENDPOINT=http://objectstorage:19000
OBJECT_STORAGE_BUCKET=posthog
OBJECT_STORAGE_ACCESS_KEY_ID=posthog
OBJECT_STORAGE_SECRET_ACCESS_KEY=change-me

Example S3-compatible config:

OBJECT_STORAGE_ENABLED=true
OBJECT_STORAGE_ENDPOINT=https://s3.example.com
OBJECT_STORAGE_BUCKET=posthog-recordings
OBJECT_STORAGE_ACCESS_KEY_ID=your-key
OBJECT_STORAGE_SECRET_ACCESS_KEY=your-secret
OBJECT_STORAGE_REGION=auto

Make sure the object storage volume is persistent.

If object storage data disappears, recordings disappear.

Check logs:

docker compose logs web
docker compose logs objectstorage

Common recording failures:

  • bucket missing
  • wrong endpoint
  • wrong credentials
  • object storage disabled
  • object storage volume not persisted
  • old recordings captured before object storage was fixed

Make the setup restart-safe

Every important fix should live in one of these places:

docker-compose.yml
.env
mounted config files
reverse proxy config
Dokploy domain config

Avoid manual container edits.

Bad:

docker exec -it posthog-web bash
nano /some/file

That fix disappears after a rebuild or redeploy.

Good:

volumes:
  - ./clickhouse/config.d/low-resource.xml:/etc/clickhouse-server/config.d/low-resource.xml:ro

Good:

SITE_URL=https://posthog.example.com
IS_BEHIND_PROXY=true
CSRF_TRUSTED_ORIGINS=https://posthog.example.com,https://yourwebsite.com

Good:

restart: unless-stopped

The target is:

docker compose down
docker compose up -d

and PostHog still works.

Even better:

clone repo
copy .env
docker compose up -d

and PostHog works on a new server.

Avoid custom Docker subnet problems

One mistake is adding a custom Docker network with a fixed subnet.

Example:

networks:
  posthog-internal:
    ipam:
      config:
        - subnet: 10.0.1.0/24

This can fail with:

invalid pool request: Pool overlaps with other one on this address space

That means Docker already has a network using overlapping address space.

Unless you truly need a fixed subnet, do this instead:

networks:
  posthog-internal:

Let Docker choose the subnet.

You still get service discovery:

temporal:7233
kafka:9092
clickhouse:9000
redis:6379
postgres:5432

Use service names, not hardcoded container IPs.

Reduce CPU and memory usage

PostHog can be heavy on a small server.

The goal is not to make every service tiny.

The goal is:

everything works, but no single service starves the rest

The usual resource targets are:

  • ClickHouse
  • Kafka
  • ZooKeeper
  • workers
  • plugin server
  • optional monitoring tools

Remove containers you do not need.

For example, Netdata can be useful while debugging. But if you are done using it, remove it from Compose. On a small VPS, every container matters.

Use Docker limits carefully.

Docker documents CPU limits such as --cpus, and Compose supports service-level resource settings. In many plain Compose deployments, cpus and mem_limit are the most direct settings to verify with docker stats.

Example:

clickhouse:
  cpus: "1.0"
  mem_limit: 1g

Then verify:

docker stats

Do not trust config blindly.

Check what the running containers actually show.

Tune ClickHouse

ClickHouse is often the biggest CPU user.

It runs queries, stores analytics events, performs background merges, and can maintain internal logs.

For a small PostHog install, add a low-resource config file.

Example:

<clickhouse>
    <profiles>
        <default>
            <max_threads>1</max_threads>
            <max_concurrent_queries_for_user>2</max_concurrent_queries_for_user>
            <max_concurrent_queries_for_all_users>4</max_concurrent_queries_for_all_users>
        </default>
    </profiles>

    <max_concurrent_queries>20</max_concurrent_queries>
    <background_pool_size>1</background_pool_size>

    <query_log remove="1"/>
    <trace_log remove="1"/>
    <metric_log remove="1"/>
    <asynchronous_metric_log remove="1"/>
    <part_log remove="1"/>
    <text_log remove="1"/>
</clickhouse>

Mount it:

clickhouse:
  volumes:
    - ./clickhouse/config.d/low-resource.xml:/etc/clickhouse-server/config.d/low-resource.xml:ro
  cpus: "1.0"
  mem_limit: 1g

Why these settings?

max_threads limits how many threads a single query can use.

max_concurrent_queries_for_user limits how many heavy queries one user can run at once.

background_pool_size reduces background merge pressure.

Disabling internal logs reduces ClickHouse's own background write and merge work.

This is especially useful on a small server where ClickHouse can otherwise compete with Kafka, Redis, PostHog web, and workers.

Check active queries:

docker compose exec clickhouse clickhouse-client --query "SHOW PROCESSLIST"

Check settings:

docker compose exec clickhouse clickhouse-client --query "
SELECT name, value
FROM system.settings
WHERE name IN ('max_threads', 'max_concurrent_queries_for_user')
"

The point is not to cripple ClickHouse.

The point is to stop ClickHouse from taking over the whole VPS.

Tune Kafka and ZooKeeper

Kafka can use a lot of memory by default.

For a small setup, set heap limits.

Example:

kafka:
  environment:
    KAFKA_HEAP_OPTS: "-Xms256m -Xmx512m"
  cpus: "0.75"
  mem_limit: 768m

ZooKeeper:

zookeeper:
  environment:
    JVMFLAGS: "-Xms128m -Xmx256m"
  cpus: "0.25"
  mem_limit: 384m

Do not go too low.

Kafka is part of ingestion. If Kafka is unhealthy, events will not process correctly.

Check logs:

docker compose logs kafka
docker compose logs zookeeper

If the PostHog validation page says Kafka has an error, start there.

Backups

Back up the whole stack.

Not just Postgres.

You need:

Postgres volume
ClickHouse volume
object storage volume
.env
docker-compose.yml
mounted config files
reverse proxy or Dokploy domain config

Postgres stores app metadata.

ClickHouse stores event data.

Object storage stores session recordings and blobs.

If you only back up Postgres, you do not have a full PostHog backup.

A simple cold backup approach:

docker compose down
tar -czf posthog-backup-$(date +%F).tar.gz /path/to/posthog/data
docker compose up -d

A better production approach:

  • dump Postgres
  • back up ClickHouse properly
  • sync object storage
  • copy .env and compose config securely
  • upload backups off-server

Example off-server sync:

rclone copy /backups/posthog contabo-s3:posthog-backups/

Also test restore.

A backup that has never been restored is just a file.

Deployment checklist

Before calling the setup done, check more than the homepage.

Use a checklist like this:

PostHog UI opens
Login works
Project page opens
Validation page passes
Postgres validates
ClickHouse validates
Kafka validates
Redis validates
Temporal worker stays running
Frontend can send /e requests
Frontend can send /flags requests
Frontend can load /array config
Session recordings are captured
Session recordings replay
Docker volumes are persistent
docker compose down/up works
Server reboot works
Dokploy redeploy works
Backups run
Backup restore has been tested
docker stats looks reasonable

If you use first-party ingestion, test from the browser network tab.

You want to see 200 responses for the relevant analytics routes.

Gotchas and errors we hit

This is the most useful part.

PostHog self-hosting failed in several specific ways before the setup became stable.

Temporal worker crashed with connection refused

The Temporal worker crashed with an error like:

RuntimeError: Failed client connect:
tcp connect error
temporal:7233
Connection refused

The worker was trying to connect to:

temporal:7233

but the Temporal service was not reachable.

This usually means:

  • Temporal is not running.
  • Temporal is unhealthy.
  • The service name is wrong.
  • The worker and Temporal are not on the same Docker network.
  • The worker started before Temporal was ready.

The fix is to use proper Compose networking and service names.

Do not hardcode container IPs.

Use:

temporal:7233

and make sure the worker and Temporal share a network.

Custom network subnet overlapped

We tried adding a custom Docker subnet and deployment failed with:

invalid pool request:
Pool overlaps with other one on this address space

That means the subnet overlapped with an existing Docker network.

The fix was to remove the fixed subnet and let Docker choose.

Use:

networks:
  posthog-internal:

not a hardcoded subnet unless you absolutely need one.

Kafka validation failed

The validation page showed Kafka errors.

Kafka issues usually come from:

  • bad advertised listeners
  • wrong internal host
  • Kafka not healthy
  • ZooKeeper not healthy
  • not enough memory
  • wrong Docker network

Inside Compose, PostHog should talk to Kafka by service name:

kafka:9092

not the public domain.

If Kafka fails, check:

docker compose logs kafka
docker compose logs zookeeper

Migrations looked stuck

Migrations took time.

Logs showed things like:

Running migrations
No migrations to apply
All migrations completed successfully

This can look stuck on small servers.

Do not kill it too early.

PostHog startup can take time because Django, ClickHouse, workers, and background services all initialize.

Too many redirects

The public web URL hit redirect loops.

This usually means one of these is wrong:

  • SITE_URL
  • proxy HTTPS headers
  • Cloudflare SSL mode
  • IS_BEHIND_PROXY
  • public domain routing

Set:

SITE_URL=https://posthog.example.com
IS_BEHIND_PROXY=true

Make sure the proxy forwards:

X-Forwarded-Proto
X-Forwarded-Host
X-Forwarded-For

Analytics requests failed with CSRF origin errors

The logs showed:

Forbidden (Origin checking failed - https://elitemotionluxury.com does not match any trusted origins.)

The frontend site was sending analytics, but PostHog did not trust the origin.

The fix was to include the frontend domain:

CSRF_TRUSTED_ORIGINS=https://posthog.example.com,https://elitemotionluxury.com

This is easy to miss because people think only the PostHog UI domain matters.

For analytics, the tracked website domain matters too.

First-party ingestion partially worked

Some requests worked:

/ingest/e       200
/ingest/flags   200

But others failed:

/ingest/array/.../config   302 -> /login

That meant the proxy was only forwarding part of the PostHog JS traffic.

The fix was to proxy the full ingestion path consistently.

If any analytics config route redirects to /login, your proxy is wrong.

Session recordings returned 500

The replay endpoint returned:

500 Internal Server Error
/api/environments/.../session_recordings/.../snapshots

Events were working, but recordings were not.

That pointed to object storage.

Session recordings need blob storage. If object storage is missing, misconfigured, or not persistent, recordings break.

Check:

docker compose logs web
docker compose logs objectstorage

Django admin did not behave like a normal Django app

Trying to access admin redirected to a PostHog route and returned 404.

PostHog is not a standard Django project where you should rely on Django admin as your normal admin interface.

Use the PostHog UI unless you have confirmed the correct admin route and behavior for your exact PostHog version.

Web container became unhealthy after a config change

Deployment failed with:

dependency failed to start:
container ... web ... is unhealthy

When that happens, start with the web logs:

docker compose logs web
docker compose ps

Then check dependencies:

docker compose logs postgres
docker compose logs redis
docker compose logs clickhouse
docker compose logs kafka

Usually web is unhealthy because one dependency or env value is wrong.

ClickHouse used too much CPU

ClickHouse was the main resource problem.

The fix was:

  • reduce query threads
  • reduce concurrent query limits
  • reduce background pool size
  • disable internal ClickHouse logs
  • add Docker CPU and memory limits

The most important settings were:

<max_threads>1</max_threads>
<max_concurrent_queries_for_user>2</max_concurrent_queries_for_user>
<max_concurrent_queries_for_all_users>4</max_concurrent_queries_for_all_users>
<background_pool_size>1</background_pool_size>
<max_concurrent_queries>20</max_concurrent_queries>

And disabling logs:

<query_log remove="1"/>
<trace_log remove="1"/>
<metric_log remove="1"/>
<asynchronous_metric_log remove="1"/>
<part_log remove="1"/>
<text_log remove="1"/>

Then limits:

clickhouse:
  cpus: "1.0"
  mem_limit: 1g

Netdata was useful, then removed

Netdata helped while debugging resource usage.

After that, it was just another container using resources.

On a small server, remove monitoring containers you are not actively using.

You can always add monitoring back deliberately.

Manual fixes did not count

The most important operational lesson was this:

If the fix matters, put it in Docker Compose, env vars, mounted config files, or proxy config.

Manual changes inside running containers do not count.

They disappear.

The final setup should survive:

docker compose down
docker compose up -d

and a full redeploy.

Conclusion

Self-hosting PostHog is not about getting one container to start.

It is about running a small analytics platform.

Use the official PostHog Docker Compose setup as the base. Then add the production pieces:

  • public domain
  • HTTPS reverse proxy
  • correct SITE_URL
  • proxy headers
  • trusted origins
  • first-party ingestion
  • object storage
  • persistent volumes
  • ClickHouse tuning
  • Kafka and ZooKeeper limits
  • backups
  • restart-safe config

The biggest rule is:

Every durable fix belongs in Compose, env, mounted config, or domain/proxy configuration.

That is how you get from "it works on this server right now" to "I can redeploy this and it still works."

Sources and more reading