Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host PostHog's single-server hobby deploy, about 36 containers, on a 4 vCPU / 16 GB or larger VPS with images pinned by digest, built-in HTTPS, a long first-boot wait and tested backups.
PostHog is an all-in-one product analytics platform: event
and funnel analytics, session replay, feature flags, A/B experiments, surveys,
error tracking and a SQL data warehouse behind one SDK. If you are weighing it
against Google Analytics, see Google Analytics alternatives;
PostHog is the heavyweight option, built for product teams rather than simple
page-view counting. The core code is MIT-licensed, except the ee/
directory, which is under PostHog's proprietary enterprise license. It is rated
4 / 5 to deploy.
Be clear about what self-hosting means here. PostHog's own documentation says the free self-hosted deployment is the single-machine Docker Compose "hobby" setup, that PostHog offers no support for self-hosted instances, and that it gives no guarantees about how the product runs on your infrastructure. It also publishes no release tags, which is why this guide pins an exact commit and the container image digests instead. If you only need privacy-friendly page-view stats, a small tool like Plausible or Umami is a better fit; see the Plausible deploy guide.
PostHog's documentation asks for something equivalent to 4 vCPU, 16 GB of RAM and more than 30 GB of storage. The installer script itself says you really need 8 GB or more. Our measurements argue for the upper end.
On a 4 vCPU / 16 GB Ubuntu 26.04 server with no swap, the fully started stack of
about 35 containers had roughly 14.8 GB of memory in use and about 1.1 GB
available, with no real traffic. The two Python containers, web and worker,
used about 4.5 GB and 4.2 GB between them, and the rest is ClickHouse,
Elasticsearch, Kafka-compatible Redpanda, ZooKeeper, Temporal and a long tail of
small services. Disk used was about 30 GB after install and first boot.
A 16 GB server works for a trial or a low-traffic site, with swap as a safety net. For a real product with replay turned on, choose 24 GB or 32 GB. For help choosing a provider, see Best VPS for Self-Hosting.
Install Docker first by following
Docker and Compose on Ubuntu, and make
sure your user can run docker and sudo. The PostHog installer skips its own
Docker setup when Docker is already present, and that is the path you want:
docker --version
docker compose version
Open SSH plus the two ports Caddy needs. The stack publishes only ports 80 and 443:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
Add a swap file as a safety net for the tight memory:
sudo fallocate -l 16G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -m
PostHog's installer, deploy-hobby, works in the current directory: it clones
the repository into ./posthog, writes .env and docker-compose.yml next to it
and names the Compose project after the directory. Give it a directory of its own:
mkdir -p ~/posthog-hobby
cd ~/posthog-hobby
Choose the hostname. Point an A record for your domain (say
posthog.example.com) at the server. With no domain yet, an sslip.io name
resolves to your server's IP with no DNS setup, which is what this guide uses so
every command runs as written. Keep this shell session open (or re-run the line)
and replace the value with your real hostname later:
DOMAIN="posthog.$(curl -4s https://ifconfig.me).sslip.io"
echo "$DOMAIN"
Clone the repository at a pinned commit. The clone is shallow on blobs so it is quick:
git clone --filter=blob:none https://github.com/PostHog/posthog.git && git -C posthog checkout d945c980fbd024bcb75daa029893b152b92986ef
PostHog's compose files float eleven of their images on :master or :latest. A
pinned commit with floating images is not reproducible, so rewrite each one to the
exact digest we installed. The installer reuses this checkout, so the edits carry
through:
sed -i -e 's|image: caddy$|image: caddy@sha256:f2a1290d0463aad60660d4ec134943f183ee2a5f6c3eb7bf32dd984f2f020772|' -e 's|-node:${POSTHOG_NODE_TAG:-latest}|-node@sha256:863e76e040ad41aef79f605c33ec71f1529bddf94cfd88941c935da9bde97235|' -e 's|posthog/cymbal:master|posthog/cymbal@sha256:637ba14bc877adaf42b4cbbadca56eabe6e079124b707ba49a11cdf492b6eb78|' posthog/docker-compose.base.yml posthog/docker-compose.hobby.yml
sed -i -e 's|posthog/capture:master|posthog/capture@sha256:ba4c368efd4c0a000eb3d9969532cfa27d2da868c80821e2c06ab6b2a82b3128|' -e 's|posthog/capture-logs:master|posthog/capture-logs@sha256:45af6d72e28f064194f4453a08b90dd164e57398dbb22531f2a9ecf70d7e0c1d|' -e 's|posthog/property-defs-rs:master|posthog/property-defs-rs@sha256:50293c7208aff270f708c1d6f4b38913b1db0a01032009d9dde57ee6ecadf868|' posthog/docker-compose.base.yml posthog/docker-compose.hobby.yml
sed -i -e 's|posthog/livestream:master|posthog/livestream@sha256:2a903c1a9696ca8a86b776e19aba4e117cb42f4b69a88421a4b640d3f62be1a8|' -e 's|posthog/personhog-replica:master|posthog/personhog-replica@sha256:981a55b25dba403c9ce07b1f0e2d758ae416d8f9ab8f2f4c89dae793d6815341|' -e 's|posthog/personhog-router:master|posthog/personhog-router@sha256:bebc4e90bf9a2ebc05804d5a0f48b127a20ccdea1d4696c52b5cc5e5d0769e27|' posthog/docker-compose.base.yml posthog/docker-compose.hobby.yml
sed -i -e 's|posthog/feature-flags:master|posthog/feature-flags@sha256:a703b29359a10b10dd41d0150dad02a5e2cb04006b2dce23eb0d0be7d6451b2c|' -e 's|posthog/hypercache-server:master|posthog/hypercache-server@sha256:7f1d33d7f064a9e1a723528a25a95d9eb020111be6f94d6ee5e3e0909c5c40a0|' posthog/docker-compose.base.yml posthog/docker-compose.hobby.yml
Download the installer from the same commit and run it. SKIP_HEALTH_CHECK=1
matters, as explained right after this block. The script uses sudo, installs a
standalone docker-compose binary into /usr/local/bin, downloads a GeoIP
database and starts the stack. It also sends your domain name to PostHog as an
install-start event, which is worth knowing before you run it:
curl -fsSL -o deploy-hobby https://raw.githubusercontent.com/PostHog/posthog/d945c980fbd024bcb75daa029893b152b92986ef/bin/deploy-hobby
SKIP_HEALTH_CHECK=1 bash deploy-hobby d945c980fbd024bcb75daa029893b152b92986ef "$DOMAIN"
The first run pulls many gigabytes of images; on our server that alone took about 17 minutes.
Why the health check is skipped, and why the first start looks broken. The
installer waits only ten minutes for the web app to answer, then exits with an
error. On a 4 vCPU server the first boot ran database migrations for about 56
minutes after the containers started (about 75 minutes from the first command),
and during that time Caddy answers 502 because the web container is not up yet.
That is normal, not a failed install. Without the skip flag the installer would
give up long before the migrations finish. Wait for them to complete:
timeout 150m bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" localhost/_health)" = 200 ]; do sleep 60; done'
echo "health endpoint answered"
Do not restart containers while this runs. If you want to watch progress,
docker compose logs -f web prints each migration as it is applied.
HTTPS comes with the stack: a Caddy container listens on ports 80 and 443 and obtains a Let's Encrypt certificate for the hostname you passed to the installer (see Automatic HTTPS with Caddy for how that works). No extra reverse proxy is needed. Confirm the hostname answers with a trusted certificate:
out=000
for i in $(seq 1 24); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/_health") && [ "${out%% *}" = "200" ] && break
sleep 5
done
echo "health endpoint and certificate check: $out"
[ "${out%% *}" = "200" ] || false
A second number of 0 means the certificate verified. Check that only the two
web ports are published to the internet:
ss -tln | grep -E ':(80|443) '
A fresh instance has no account yet, and whoever opens the site first creates
it, so do this right after HTTPS works. Open https://posthog.example.com in a
browser and fill in the sign-up form. If you prefer the command line, the same
endpoint the form uses creates the first user and organization; this prints a
generated password once, so copy it:
PW="$(openssl rand -hex 12)Aa1!"
curl -s -X POST "https://$DOMAIN/api/signup/" -H 'Content-Type: application/json' \
-d "{\"first_name\":\"Admin\",\"email\":\"you@example.com\",\"password\":\"$PW\",\"organization_name\":\"My org\",\"role_at_organization\":\"engineering\"}"
echo
echo "password: $PW"
Replace you@example.com with your own address. Then send a test event with the
project's API key, which lives in Postgres, and confirm it lands in ClickHouse:
TOKEN=$(docker compose exec -T db psql -U posthog -tAc "select api_token from posthog_team limit 1")
curl -s -X POST "https://$DOMAIN/i/v0/e/" -H 'Content-Type: application/json' \
-d "{\"api_key\":\"$TOKEN\",\"event\":\"guide smoke test\",\"distinct_id\":\"guide-test\"}"
echo
for i in $(seq 1 24); do
n=$(docker compose exec -T clickhouse clickhouse-client -q "select count() from posthog.events where event = 'guide smoke test'" 2>/dev/null)
[ "${n:-0}" -ge 1 ] && break
sleep 5
done
echo "events stored: $n"
[ "${n:-0}" -ge 1 ] || false
{"status":"Ok"} from the first call means the capture service accepted the
event. In the web UI, the project settings page shows the same API key and the
snippet to paste into your site. Point SDKs at https://posthog.example.com as
the API host.
Three things hold your PostHog state, and a backup needs all three:
.env, which holds POSTHOG_SECRET and ENCRYPTION_SALT_KEYS. PostHog's
own installer is careful never to regenerate them, because new values would
invalidate sessions and corrupt encrypted data. Lose them and a restored
database is not fully usable.Start with a logical dump of Postgres, the same command PostHog's own upgrade script uses for its database migration:
mkdir -p ~/posthog-backup
docker compose exec -T db pg_dumpall --clean -U posthog | gzip > ~/posthog-backup/postgres-$(date +%F).sql.gz
ls -lh ~/posthog-backup
For a consistent copy of ClickHouse and everything else, stop the stack and archive
every volume of the Compose project, plus .env:
docker compose stop
for v in $(docker volume ls -q | grep '^posthog-hobby_'); do
docker run --rm -v "$v":/v:ro -v ~/posthog-backup:/b alpine tar czf "/b/$v.tgz" -C /v .
done
cp .env ~/posthog-backup/env.backup
docker compose up -d
timeout 30m bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" localhost/_health)" = 200 ]; do sleep 10; done'
ls -lh ~/posthog-backup
Copy ~/posthog-backup off the server to object storage or another machine on a
schedule. An untested backup is a guess, so restore into a throwaway server now and
then.
PostHog publishes no release tags, and its own upgrade path is an interactive
script, bin/upgrade-hobby, which asks several yes/no questions. We did not run it
for this guide, so read it first. Two things in its source matter for a pinned
install like this one: it runs git pull --prune inside posthog/, which cannot
work from the detached-HEAD checkout we pinned, and it regenerates
docker-compose.yml from the repository's compose file, which would drop the
digest pins. That is our reading of the script, not a tested upgrade.
Plan an upgrade as a deliberate migration: take the full backup above, check out
the new commit in posthog/, discard the old sed edits, re-pin the images you
care about, then follow PostHog's current upgrade instructions. PostHog also
recommends backing up all your data before upgrading and tracking its changelog.
Expect downtime and, as on first boot, a long wait while migrations run.
The site answers 502 for the first hour. The web container is still running
migrations. Watch docker compose logs --tail 5 web and wait for /_health to
return 200. Do not restart the stack.
The installer printed Health check timed out after 10 minutes. You ran it
without SKIP_HEALTH_CHECK=1. The containers are still running; wait for the
migrations and carry on.
Containers die, or the server becomes unresponsive. Memory. Check
free -m and docker stats --no-stream. Make sure the swap file is active, and
move to a larger server rather than trimming services.
No certificate is issued. The A record must resolve to the server and ports 80
and 443 must be open before the installer starts Caddy. Check
docker compose logs caddy and dig +short posthog.example.com.
Events from your site never appear. Confirm the SDK's API host is your
https:// address, not PostHog Cloud, and that ad blockers are not dropping the
requests. Run the smoke test above to prove the server side works.
After a reboot, some containers are not running. Most services use
restart: on-failure, so start the stack explicitly with docker compose up -d
from ~/posthog-hobby and wait for /_health.
You are done when https://posthog.example.com loads with a valid certificate,
you can sign in as the account you created, the smoke-test event is in ClickHouse,
only ports 80 and 443 are published, and a backup of Postgres, the volumes and
.env is landing off the server.
From here, add the SDK snippet to your site or app, define a few funnels, and turn on session replay only where you need it, since it is the heaviest feature. If the resource bill is more than you want to carry, revisit Plausible or Matomo.