PostHog hobby self-host: 19 GB of images and a 305-line install script

PostHog publishes a docker-compose.hobby.yml in the public posthog/posthog repo. We thought we'd run it. We did not get further than docker compose pull on the first try — the compose file is intentionally not standalone, it expects environment variables that are set by a 305-line bash installer, and without them you get an error a Docker primer can't help with. Here's exactly where the friction lives, what the real disk and RAM numbers look like, and why we'd reach for managed PostHog Cloud or a different self-host target before this stack on a single VPS.

Honest about what this is

We're the team behind SimpleReview, a Chrome extension that drafts code-fix PRs on whatever site you click. Not affiliated with PostHog. This page is a scouting note from one real test on one Linux box, dated above. We didn't bring the full stack to a green state — we hit and documented the first three friction points, sized the disk footprint, and stopped. If we got something wrong, open a GitHub issue and we'll fix it.

Friction 1 — the compose file is not standalone

The natural first step on any "docker-compose-based product" is docker compose pull. Skip the install script, see what we're getting into. With docker-compose.hobby.yml from PostHog main:

$ docker compose -f docker-compose.hobby.yml pull --quiet
time="2026-05-07T16:33:31+03:00" level=warning
   msg="The \"DOMAIN\" variable is not set. Defaulting to a blank string."
time="2026-05-07T16:33:31+03:00" level=warning
   msg="The \"REGISTRY_URL\" variable is not set. Defaulting to a blank string."
time="2026-05-07T16:33:31+03:00" level=warning
   msg="The \"POSTHOG_APP_TAG\" variable is not set. Defaulting to a blank string."
time="2026-05-07T16:33:31+03:00" level=warning
   msg="The \"ENCRYPTION_SALT_KEYS\" variable is not set. Defaulting to a blank string."
time="2026-05-07T16:33:31+03:00" level=warning
   msg="The \"TLS_BLOCK\" variable is not set. Defaulting to a blank string."

unable to get image '-node:latest':
   Error response from daemon: invalid reference format
exit 1
Broken default — observed locally

The compose file references ${REGISTRY_URL}-node:latest for the posthog-node service. When REGISTRY_URL is empty (the default), Docker tries to pull an image literally named -node:latest, which is not a valid image reference. The pull dies before any of the 23 services has a chance to download.

Setting REGISTRY_URL=posthog/posthog, DOMAIN=<your-domain>, POSTHOG_APP_TAG=latest, ENCRYPTION_SALT_KEYS=<random hex>, and TLS_BLOCK="" lets the pull proceed. None of these defaults appear in the compose file or in a checked-in .env.example; they are inlined into the deploy script.

Friction 2 — the "official" path is a 305-line bash script

The script lives at PostHog/posthog/bin/deploy-hobby. The first thing it does, before any docker activity, is print a warning that's worth quoting verbatim:

echo "Welcome to the single instance PostHog installer 🦔"
echo ""
echo "⚠️  You REALLY need 8GB or more of memory to run this stack ⚠️"
echo ""

The script then prompts interactively for:

  • The PostHog version tag (defaults to latest)
  • The exact public domain — "Make sure that you have a Host A DNS record pointing to this instance!"
  • An admin email for Let's Encrypt

It generates a random POSTHOG_SECRET, a random ENCRYPTION_SALT_KEYS, drops a .env file alongside the compose, fetches Caddy with auto-TLS, and then runs docker compose up -d. The script is not really optional — without it you reproduce the env-var dance by hand and you still need a real domain because the proxy service is wired to do TLS.

Friction 3 — the disk and image footprint

Once the env vars are right, docker compose pull ran for 161 seconds on a 1 Gbit link and added ~19 GB of new images to the host. The biggest single image is posthog/posthog:latest at 10.8 GB uncompressed. Per docker images:

ImageTagSize
posthog/posthoglatest10.8 GB
posthog/posthog-nodelatest1.75 GB
clickhouse/clickhouse-server26.3.9.8887 MB
temporalio/admin-tools1.26.2857 MB
cymbal (error-tracking)master476 MB
feature-flagsmaster449 MB
capturemaster439 MB
auto-setup (temporal)1.26.2426 MB
hypercache-servermaster392 MB
temporalio/ui2.47.2359 MB
property-defs-rsmaster325 MB
personhog-routermaster308 MB
cyclotron-janitormaster298 MB
personhog-replicamaster277 MB
zookeeper3.7.0277 MB
chrislusf/seaweedfs4.03193 MB
livestreammaster171 MB
caddylatest62 MB
Total (PostHog-specific)~19 GB

The posthog/posthog image alone is bigger than most full Linux server installs. It contains the Django app, the Webpack build, the Celery worker, the plugins runtime, and a slate of Python dependencies for ClickHouse, Kafka, Redis, S3, Temporal. It has to — the image is reused across at least seven of the 23 services in the compose file.

What the 23 services actually are

Counted from the compose file at the test commit, after stripping x- blocks:

  • Storage: db (Postgres 15.12), redis7, clickhouse, elasticsearch, seaweedfs, objectstorage
  • Streaming: kafka, zookeeper
  • Workflow: temporal, temporal-admin-tools, temporal-ui, temporal-django-worker
  • App: web, worker, plugins, asyncmigrationscheck, cyclotron-janitor
  • Ingestion: ingestion-general, ingestion-sessionreplay, ingestion-error-tracking, ingestion-logs, ingestion-traces, recording-api
  • Edge: proxy (Caddy with auto-TLS)

Translation: PostHog is, by 2026, a multi-process distributed system held together by Kafka, Temporal, and a Django monolith. It is not a "small Postgres-backed app" anymore. The hobby compose is the production architecture, just on a single host.

Where the line is

Based on what we measured before stopping:

You wantVerdict
Self-host PostHog on a $20/month VPSNo. The script's 8 GB warning is conservative; in practice you want 16 GB and SSDs. Plan for $40–80/month.
Try PostHog locally without a public domainHard. The hobby compose has TLS termination wired in. You can edit the compose to drop Caddy and bind the web service to localhost, but you're now off the supported path.
Pin a known-good version for complianceYes — set POSTHOG_APP_TAG to a specific image tag, but accept that the master-tagged sidecars (capture, cymbal, etc.) move independently and aren't versioned in the compose file.
Production analytics for 50+ engineersPlausible only if you have an ops team. The 23-service stack means migrations, upgrades, and Kafka rebalances are part of your week.

Things we'd change in the README

  1. Ship a runnable .env.example next to the compose file, with sane defaults for every variable the file references. ${REGISTRY_URL}-node:latest failing on an empty default is a footgun, not a feature.
  2. Add a "no-TLS local mode" compose that drops Caddy, binds web to 127.0.0.1:8000, and lets curious developers see the UI without owning a domain.
  3. Be explicit about disk. 19 GB of images on a "hobby" deploy needs a callout in the docs, not a discovery in the wild.
  4. Write down the canonical RAM/CPU floor. The script's "really need 8GB" line is the only quantitative guidance we found. Production-style docs deserve concrete tiers.

What we'd actually do

If the goal is product analytics and we don't have a dedicated ops headcount: pay for PostHog Cloud or evaluate a smaller-footprint alternative (Plausible, Umami) for the use case. The PostHog hobby compose is engineered to mirror their cloud architecture; that buys parity but it's a heavy lift for a single-tenant box. We'd revisit self-host once we cared specifically about session replay or event-level data residency — and even then, we'd budget for a 16 GB VPS, daily ClickHouse backups, and a maintenance window for upgrades.

Where this fits

One short, honest write-up per self-hostable analytics/CMS/forum tool we run on a real Linux box. Adjacent: Cal.com 6.16.1 — migrations don't run on boot, Dify 1.14.0 — the profile flag the README forgets, Open WebUI on Linux Docker — first 90 seconds, measured. SimpleReview is the Chrome extension that turns whatever element you click on a broken admin into a draft code-fix PR — including PostHog's various dashboards once you do bring the stack up.

Demo: SimpleReview on the empty-env-var trap

~/posthog/docker$ docker compose pull
SimpleReview
$ docker compose -f docker-compose.hobby.yml pull --quiet
warning The "DOMAIN" variable is not set. Defaulting to a blank string.
warning The "REGISTRY_URL" variable is not set. Defaulting to a blank string.
warning The "POSTHOG_APP_TAG" variable is not set.
⨯ unable to get image '-node:latest'
Error response from daemon: invalid reference format
cause: ${REGISTRY_URL}-node:latest with empty REGISTRY_URL → -node:latest
✓ 18 images pulled · 19 GB · 161 s
$ export REGISTRY_URL=posthog/posthog
$ export POSTHOG_APP_TAG=latest
$ docker compose pull --quiet
posthog Pulled · clickhouse Pulled · kafka Pulled
temporal Pulled · zookeeper Pulled · seaweedfs Pulled
capture Pulled · feature-flags Pulled · cymbal Pulled
→ pull complete
Comment×
REGISTRY_URL not set|
Fix it ✓ Done
waiting for selection…
Detected
Compose ref-node:latest
Causeempty env
Fix plan
Add .env.example with sane defaults for REGISTRY_URL, DOMAIN, POSTHOG_APP_TAG
Result
Pull works without the 305-line bash installer.
✓ Fix ready
docs(deploy): ship .env.example for hobby
1 file · docker/.env.example
Click SimpleReview → select the invalid reference format error → Fix it → env defaults shipped