PostHog's own position on self-hosting
PostHog states it plainly: self-hosted deployments are officially unsupported. Its disclaimer page says the open-source deployment is made for hobbyists, is MIT licensed and provided without guarantee, and that you should be confident in your security knowledge to run it. Because everything runs on a single machine, the same page warns that growth beyond a couple of hundred thousand events is unlikely without significant scaling effort.
- Support: self-hosted users cannot receive commercial support; PostHog cannot answer tickets, cannot help debug and is unable to support recovery from data loss. Bugs in the deployment itself go to GitHub issues or pull requests.
- Features: the self-host page's decision chart asks whether you are fine with the free-plan features only, because all paid-plan features are Cloud-only. The disclaimer lists what you lose, among them group analytics, data pipelines, correlation analysis, lifecycle insights, recording downloads, the paid plans' larger project allowance and SAML.
- Releases: there are no tagged releases. Every change, security fixes included, ships continuously from master to the latest Docker image, PostHog publishes no CVEs, and its advice is to run the latest image, as its Cloud does.
- Kubernetes: new deployments of the paid open-source product on Kubernetes are no longer supported. Docker Compose on one machine is the route that remains.
What the hobby stack runs and needs
| Item | Figure or fact | Stated in |
|---|---|---|
| Machine | Equivalent of 4 vCPU, 16 GB RAM, more than 30 GB storage | Self-host page, Requirements |
| Installer warning | You REALLY need 8GB or more of memory | deploy-hobby |
| Operating system | Ubuntu only; the script exits on anything else | deploy-hobby |
| DNS | A DNS A record pointing at the server; Let’s Encrypt certificate issued automatically | Self-host page |
| Services | 39 service entries: Django web and workers, ClickHouse, PostgreSQL, Kafka, Redis, SeaweedFS object storage, Temporal, Caddy and more | docker-compose.hobby.yml (read 2026-10-30) |
| Public ports | Caddy on 80 and 443; object storage, SeaweedFS and Temporal published on 127.0.0.1 only | docker-compose.hobby.yml |
| First boot | About 5-10 minutes for migrations and TLS | Self-host page |
Treat 16 GB as the floor PostHog names, not a target: ClickHouse, Kafka and the ingestion services all sit in the same memory. Disk is the figure that grows, because events land in ClickHouse and recordings in object storage on the same drives.
Choosing the machine
Pick an x86 rack server. The install block below and the installer's own Docker step both pin an arch=amd64 package repository, and the X-Gene cartridge boots nothing but Ubuntu 16.04 and 18.04: leave out both ARM lines, X-Gene and Ampere Altra. Every racked single-, dual- and quad-socket server on the dedicated server range carries an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC); among the cartridges only the m510 and m710x have one, and the rest have power control only. That matters here: this setup changes the firewall, and the console gets you back in if SSH stops answering. The dual-socket Xeon lines start at 16 GB and take far more, and memory and disks are chosen in the configurator before you pay.
The port is unmetered on every line, so ingestion traffic and replay uploads carry no transfer bill. Racked machines are live about 30 minutes once payment is approved. A custom build with larger disks takes 4 to 24 hours. Ubuntu 24.04 is among the free Linux images, and you can reinstall from the control panel as often as you need.
Installing with the sign-up fenced
The first person to open a fresh PostHog instance creates its first account, and the hobby stack serves the app on plain HTTP as well as HTTPS (its .env.services file sets DISABLE_SECURE_SSL_REDIRECT=true). Docker publishes Caddy's ports past ufw, so the block below adds two DOCKER-USER rules that drop new connections to 80 and 443 arriving on the public interface, the method Docker's packet-filtering docs give. ufw stays on for SSH; with Docker's own IPv6 networking off (the default) the IPv6 side of 80/443 is a docker-proxy listener that ufw filters, so it is closed too. Until the end of this setup, DNS carries an A record and nothing else.
# As root on Ubuntu 24.04 (x86). Put your own domain on the next line.
DOMAIN=posthog.example.com
[ "$DOMAIN" != posthog.example.com ] && [ ! -e /opt/posthog ] && \
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io && \
[ "$(docker version -f '{{.Server.Version}}' | cut -d. -f1)" -ge 28 ] && \
IF=$(ip route show default | grep -o 'dev [^ ]*' | cut -d' ' -f2 | head -n1) && [ -n "$IF" ] && \
iptables -I DOCKER-USER -i "$IF" -p tcp -m conntrack --ctstate NEW --ctorigdstport 80 -j DROP && \
iptables -I DOCKER-USER -i "$IF" -p tcp -m conntrack --ctstate NEW --ctorigdstport 443 -j DROP && \
mkdir /opt/posthog && cd /opt/posthog && \
curl -fsSLo deploy-hobby https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby && \
echo "80/443 fenced on $IF, starting the installer" && bash deploy-hobby latest "$DOMAIN"
Nothing runs unless every step before it succeeded: the domain must be changed from the example, and without Engine 28 or later there is no fence and no installer. The block refuses to start if /opt/posthog exists. The fence is not saved, so a reboot removes it: before retrying a failed install, or if the server restarted before you signed up, check that iptables -L DOCKER-USER -n still lists both DROP rules and if not, set IF=$(ip route show default | grep -o 'dev [^ ]*' | cut -d' ' -f2 | head -n1) first, since a new shell has no IF, and re-add them with the block's two iptables -I lines. Then rerun only cd /opt/posthog && bash deploy-hobby latest plus your domain. Engine 28 matters because it keeps the 127.0.0.1 port mappings local-only. The script then clones the repository into /opt/posthog/posthog, writes .env with generated secrets (keeping an existing one on a re-run), starts the stack and waits up to 10 minutes on /_health. It asks for optional Anthropic and OpenAI keys; Enter skips both. Note what deploy-hobby itself does: it sends an install-start and an install-complete event carrying your domain to PostHog.
First account, then open the doors
- From your workstation:
ssh -N -L 8080:127.0.0.1:80 you@your-server, through your own login. Loopback traffic never touches the fence. - Open
http://localhost:8080and create the organisation and your account. If your browser drops the session cookie on plain HTTP, the account still exists; sign in over HTTPS in step 6. - On the server, delete the fence rules by their exact text, repeating until none is left (a retried install may have added copies):
IF=$(ip route show default | grep -o 'dev [^ ]*' | cut -d' ' -f2 | head -n1); for p in 80 443; do while iptables -D DOCKER-USER -i "$IF" -p tcp -m conntrack --ctstate NEW --ctorigdstport $p -j DROP 2>/dev/null; do :; done; done. Afterwardsiptables -L DOCKER-USER -nshould list no DROP rule. ufw allow 80,443/tcp, so IPv6 visitors and certificate checks over an AAAA record get through docker-proxy.docker-compose -f /opt/posthog/docker-compose.yml restart proxymakes Caddy retry the certificate now; its earlier attempts failed behind the fence. Let’s Encrypt allows up to 5 authorization failures per hostname per account each hour, refilling one every 12 minutes, so if this attempt fails too, wait and restart it again.- Open
https://plus your domain and sign in. Add an AAAA record now if you want one. - Optional:
echo OPT_OUT_CAPTURE=true >> /opt/posthog/.env, followed by a recreate of web:docker-compose -f /opt/posthog/docker-compose.yml up -d --no-build web. The environment-variables page documents the setting as disabling product usage data sent to PostHog, but docker-compose.hobby.yml hands it to thewebcontainer only; the daily usage report, a scheduled task in the Celeryworkercontainer, is not covered by it. The egress page says those reports carry aggregated counts, no raw person or event data.
PostHog's securing page says the self-hosted stack ships with no egress filter: webhooks and integrations call URLs that users supply, and without a filter they can reach private, loopback and link-local addresses, the 169.254.169.254 metadata address included. It recommends running Stripe's Smokescreen as an egress proxy, or blocking those ranges for the containers at the firewall. This page does not set that up; do it before you give anyone a login that can create webhooks.
Backups and restore
PostHog's self-host page says to back up all your data before upgrading. The block below covers all of it: it stops the stack, archives every Docker volume labelled with the posthog project (Postgres, ClickHouse, Kafka, object storage, Caddy's certificates) plus all of /opt/posthog (.env with its secrets, the compose files, the checkout), starts the stack again and checks both archives.
# As root. PostHog is offline while this runs.
umask 077; D=/opt/posthog; B=/root/posthog-backups; S=$(date +%F-%H%M%S)
mkdir -p "$B" && V=$(docker volume ls -q --filter label=com.docker.compose.project=posthog) && [ -n "$V" ] && \
docker-compose -f "$D/docker-compose.yml" stop && \
tar -C /var/lib/docker/volumes -czf "$B/volumes-$S.tgz" $V && \
tar -C /opt -czf "$B/app-$S.tgz" posthog
docker-compose -f "$D/docker-compose.yml" start
tar -tzf "$B/volumes-$S.tgz" >/dev/null && tar -tzf "$B/app-$S.tgz" >/dev/null && echo "backup $S ok"
It must end with backup ... ok. Copy both files off the machine afterwards, for example with scp from another host you control; an archive that only lives on the same disks protects against nothing.
To restore on this machine, first run the backup block once more so the current state is saved, and put that stamp in CUR. The restore checks both archives, refuses if /opt/posthog.previous already exists, removes the current stack and its volumes with down -v, moves /opt/posthog aside, unpacks the old tree, lets Compose create fresh volumes, fills them and starts everything. Your rollback is the CUR archive: rename /opt/posthog.previous first (for example to /opt/posthog.previous- plus the date), then rerun the restore with S set to the CUR stamp and a fresh backup in CUR. On a new machine, run the firewall and Docker lines of the install block without the fence or installer, install the same standalone docker-compose v2.33.1 binary that deploy-hobby downloads, copy the archives to /root/posthog-backups, move DNS, restore, and then open the web ports with ufw allow 80,443/tcp (the restored copy already holds your account).
# As root. S = the backup to restore. CUR = a backup of the current state
# that you took just now and saw "ok" for (ignored on an empty machine).
S=CHANGE-ME; CUR=CHANGE-ME; D=/opt/posthog; B=/root/posthog-backups
tar -tzf "$B/volumes-$S.tgz" >/dev/null && tar -tzf "$B/app-$S.tgz" >/dev/null && \
{ [ ! -e "$D" ] || { tar -tzf "$B/volumes-$CUR.tgz" >/dev/null && tar -tzf "$B/app-$CUR.tgz" >/dev/null && \
[ ! -e "$D.previous" ] && docker-compose -f "$D/docker-compose.yml" down -v && mv "$D" "$D.previous"; }; } && \
tar -C /opt -xzf "$B/app-$S.tgz" && \
docker-compose -f "$D/docker-compose.yml" create --no-build --pull always && \
tar -C /var/lib/docker/volumes -xzf "$B/volumes-$S.tgz" && \
docker-compose -f "$D/docker-compose.yml" up -d --no-build && echo "restored $S"
This brings data back; it does not downgrade PostHog. The restored tree carries the old compose files, .env.services and checkout, while latest pulls current images, and the start script deploy-hobby writes runs migrations before the web server. If the copy is more than a few days old, run the upgrade step below right after the restore so the files match the images.
Upgrades and the licence
Upgrade from /opt/posthog with the documented one-liner, /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/upgrade-hobby)", after a fresh backup. The script announces a few minutes of downtime, asks before it starts, checks disk space, pulls the repository, regenerates docker-compose.yml, keeps your .env and asks again before restarting.
Licence (our general reading; not legal advice): the repository's LICENSE puts everything outside the ee/ directory under the MIT (Expat) licence, and ee/ under the PostHog Enterprise licence in ee/LICENSE, a proprietary licence: that code may be used in production only with a valid PostHog subscription, though copying and modifying it for development and testing needs none. MIT is permissive: redistributing it means keeping the copyright notice and licence text with each copy, and you are not required to publish what you change.
When this box is the wrong answer
If your volume is past what PostHog says one machine handles, if you need group analytics, more projects or SAML, or if no engineer on staff will own a ClickHouse restore at night, the hobby deploy is not built for you, and PostHog's own chart sends that reader to its Cloud. For a lighter stack, compare Plausible on its own server or Umami; for raw event storage, ClickHouse on its own.
Questions
Does PostHog support self-hosted installs?
No. Its docs call self-hosted deployments officially unsupported: no customer support, no ticket answers, no debugging help and no help recovering lost data. You can file a GitHub issue or pull request for a bug in the deployment itself.
How much hardware does PostHog need when you host it yourself?
The self-host page asks for the equivalent of 4 vCPU, 16 GB of RAM and more than 30 GB of storage on Ubuntu. The installer warns that 8 GB is the bare minimum. Disk grows with every event and recording you keep.
Which features are missing from self-hosted PostHog?
All paid-plan features are Cloud-only. The disclaimer page lists group analytics, data pipelines, correlation analysis, lifecycle insights, recording downloads, multi-environment flags, the paid project allowance, SAML, RBAC and audit logs among them.
Why fence ports 80 and 443 during the install?
Whoever reaches a fresh instance first creates its first account, and the hobby stack answers on plain HTTP too. Docker-published ports bypass ufw on IPv4, so the DOCKER-USER rules drop outside connections until your own account exists, created over your SSH tunnel.
Does a self-hosted PostHog send data back to PostHog?
The install script reports install start and completion with your domain, and the running instance sends usage reports that its egress page says hold aggregated counts rather than raw person or event data. OPT_OUT_CAPTURE=true in .env is documented as disabling product usage data. In the hobby compose file, though, only web receives it; the daily report from the Celery worker is left untouched.