Before you start: what you are signing up for
Read this part twice, because PostHog does not soften it. PostHog labels self-hosting as officially unsupported. The disclaimer describes the open-source version as made for hobbyists, MIT licensed, with no guarantee, and expects you to be confident about security. One machine holds everything, so in PostHog's words it is unlikely to scale past "a couple 100ks events" without a lot of scaling work.
- Nobody to call. PostHog cannot take tickets from self-hosters, cannot debug your instance and cannot help you get lost data back. If you find a real bug in the deployment, the place for it is a GitHub issue.
- Fewer features. Only what the free plan includes; every paid-plan feature lives in PostHog Cloud. The disclaimer's list of losses includes group analytics, data pipelines, lifecycle insights, correlation analysis, downloading recordings, SAML, RBAC and audit logs.
- Always the newest build. PostHog does not tag releases or publish CVEs. Security fixes simply land in the latest Docker image, and it tells you to stay on that image.
- No Kubernetes. New Kubernetes installs are no longer supported; the Docker Compose hobby install is what is left.
Will one server carry it?
Yes, if it matches what PostHog asks for: a machine comparable to 4 vCPU with 16GB RAM and 30GB+ storage, on Ubuntu, with a domain whose A record points at it. The installer is blunter: its opening banner insists you need at least 8GB of RAM, and it stops on any system other than Ubuntu.
| Piece of the stack | What it does for you |
|---|---|
| Django web app and workers | The PostHog interface, the API and background jobs |
| ClickHouse | Stores and queries your events |
| PostgreSQL and Redis | Accounts, settings, caching |
| Kafka | Queues incoming events before they are stored |
| SeaweedFS object storage | Session recordings and exports |
| Caddy | The front door on ports 80 and 443, with a Let’s Encrypt certificate |
That is a summary of the main parts of docker-compose.hobby.yml; the file itself held 39 services when we read it, and all of them share one machine's memory. That is why 16 GB is a starting line rather than a comfortable size, and why the disk should have room to grow with every event you keep.
The server that fits
Choose an x86 server. The install block and the installer both use Docker's amd64 package source, and the X-Gene cartridge can't run anything newer than Ubuntu 18.04, which rules out the ARM machines (X-Gene and Ampere Altra). A rack server is the comfortable pick, because every rack server and blade has a console you open yourself, with the screen and the power button even when the system won't start. Among the small cartridge servers, the m510 and m710x bring that console too; the others give you power control and nothing more. If a firewall rule ever cuts off SSH, that console still reaches the machine. The two-processor servers on our dedicated server page start at 16 GB and go much higher, and you choose memory and disks before you pay.
Nobody meters the port on any of our servers, so busy tracking or replay traffic never turns into a bandwidth line on your invoice. A ready server is handed over within 30 minutes of approval; a build to order with bigger disks takes longer. You manage the server; we keep the hardware and the network running. On Linux servers you wipe and put the system back yourself, from the control panel, whenever a clean start helps.
Install it with the doors shut
Here is the risk this step removes. A brand-new PostHog lets whoever opens it first create the first account. On top of that, the hobby stack serves PostHog over unencrypted HTTP too, because its .env.services file switches PostHog's HTTPS redirect off. Docker-published ports also slip past ufw on IPv4. So the block shuts ports 80 and 443 for everyone with two rules in Docker's DOCKER-USER chain, the place Docker's own firewall guide says such rules belong, while ufw keeps SSH open. IPv6 is covered as well, since with Docker's IPv6 networking left off the IPv6 side of those ports goes through docker-proxy, which ufw blocks. For the moment, your domain needs its A record only.
# Run as root on a fresh Ubuntu 24.04 server. Change the domain first.
PH_DOMAIN=analytics.example.com
[ "$PH_DOMAIN" != analytics.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 ] && \
NIC=$(ip route show default | grep -o 'dev [^ ]*' | cut -d' ' -f2 | head -n1) && [ -n "$NIC" ] && \
iptables -I DOCKER-USER -i "$NIC" -p tcp -m conntrack --ctstate NEW --ctorigdstport 80 -j DROP && \
iptables -I DOCKER-USER -i "$NIC" -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 "web ports closed on $NIC" && bash deploy-hobby latest "$PH_DOMAIN"
The block also does nothing until you replace the example domain, and nothing if /opt/posthog is already there, so a second paste can't stack extra rules. The two rules live only in memory: a restart wipes them. So before you retry a half-finished install, and after any restart that happens before your account exists, look at iptables -S DOCKER-USER; if the rules are gone, set NIC to the interface name the block printed (a new shell forgets it) and paste the two iptables -I lines from the block again, and only then run bash deploy-hobby latest with your domain from inside that folder. Each line waits for the one before it, so the installer never starts on Docker older than Engine 28 (the version that keeps 127.0.0.1 port mappings truly private) or without the two rules in place. The official deploy-hobby script then clones the PostHog repository to /opt/posthog/posthog, creates .env with new secrets (an existing one is kept if you run it again), brings everything up and gives it as long as 10 minutes to pass a health check; PostHog's docs say to expect about 5-10 minutes. It offers to take Anthropic and OpenAI keys for the AI features; press Enter to skip. Worth knowing: the script tells PostHog when an install starts and finishes, including your domain.
Claim your account, then let visitors in
- On your own computer, open a tunnel through your normal login:
ssh -N -L 18080:127.0.0.1:80 yourname@server-address. Traffic through the tunnel arrives locally, so the closed ports don't affect it. - Visit
http://localhost:18080and create your organisation and account. If the browser doesn't keep you signed in over plain HTTP, that's fine: the account is saved, and you'll sign in properly in step 6. - Back on the server, set
NICto the interface name the install block printed, then remove every copy of the two rules by matching them exactly:for port in 443 80; do until ! 2>/dev/null iptables -D DOCKER-USER -p tcp -i "$NIC" -m conntrack --ctorigdstport "$port" --ctstate NEW -j DROP; do true; done; done. Check withiptables -S DOCKER-USERthat no DROP line remains. - Open the web ports in ufw too:
ufw allow 80,443/tcp. Without it, visitors and certificate checks arriving over IPv6 are turned away. - Ask Caddy to fetch the certificate again:
cd /opt/posthog && docker-compose restart proxy. Its first tries failed while the ports were shut. Let’s Encrypt tolerates at most 5 failed authorizations per hostname and account in an hour, giving one back each 12 minutes; if this try fails as well, leave it a while and restart the proxy again. - Go to your domain over
https://and sign in. This is also the moment to add an AAAA record, if you want one. - Optional: in
/opt/posthog, runprintf 'OPT_OUT_CAPTURE=true\n' >> .envand thendocker-compose up -d --no-build webto recreate the web container. PostHog documents this setting as switching off product usage data, but the hobby compose file gives it to the web container alone, so the daily usage report that the background worker sends is not affected. PostHog describes those reports as aggregated counts, without raw person or event data.
One more job is yours, and this guide doesn't do it for you. Webhooks and integrations let PostHog call addresses that users type in, and the self-hosted stack has nothing filtering them, so they could reach private, loopback or link-local addresses, including the cloud metadata address 169.254.169.254. PostHog's securing page suggests running an egress proxy such as Smokescreen, or firewalling those ranges off from the containers. Sort it out before anyone else gets an account that can set up webhooks.
Keeping a copy you can come back to
Before any upgrade, PostHog's self-host page asks you to make sure your data is backed up, and a regular copy is the safest habit anyway. This block pauses the stack, packs every Docker volume that belongs to the posthog project (events, accounts, queues, recordings and Caddy's certificates) and the whole /opt/posthog folder, which holds .env and its secrets, then starts PostHog again and checks both files can be read.
# Run as root. Your PostHog is offline for the length of the copy.
umask 077; PH=/opt/posthog; KEEP=/root/posthog-backups; STAMP=$(date +%F-%H%M%S)
mkdir -p "$KEEP" && VOLS=$(docker volume ls -q --filter label=com.docker.compose.project=posthog) && [ -n "$VOLS" ] && \
docker-compose -f "$PH/docker-compose.yml" stop && \
tar -C /var/lib/docker/volumes -czf "$KEEP/volumes-$STAMP.tgz" $VOLS && \
tar -C /opt -czf "$KEEP/app-$STAMP.tgz" posthog
docker-compose -f "$PH/docker-compose.yml" start
tar -tzf "$KEEP/volumes-$STAMP.tgz" >/dev/null && tar -tzf "$KEEP/app-$STAMP.tgz" >/dev/null && echo "copy $STAMP ok"
Look for copy ... ok at the end. Then move the two files somewhere else, such as another machine you control, because a copy on the same disks disappears with them.
Bringing a copy back onto the same server starts with a fresh copy of how things are right now: run the backup block, then put its stamp in NOW. The restore below checks both archives, stops if /opt/posthog.previous is already there, removes the running stack and its volumes, sets the old folder aside, unpacks the copy, lets Compose create empty volumes, fills them, and starts PostHog. Changed your mind? Give /opt/posthog.previous another name first, such as /opt/posthog.previous- with today's date, because the restore refuses while it exists; then run it again with OLD set to the NOW stamp and a new copy in NOW. Moving to another server instead: set up the firewall and Docker as in the install block but stop before the port rules, add the standalone docker-compose v2.33.1 that the installer uses, copy the archives into /root/posthog-backups, point your domain at the new address, run the restore, and finish by opening the web ports in ufw (ufw allow 80,443/tcp), letting IPv6 visitors in; your account is already in the copy.
# Run as root. OLD = the copy to bring back. NOW = a copy of the present state
# you made a moment ago and saw "ok" for (not needed on an empty server).
OLD=CHANGE-ME; NOW=CHANGE-ME; PH=/opt/posthog; KEEP=/root/posthog-backups
tar -tzf "$KEEP/volumes-$OLD.tgz" >/dev/null && tar -tzf "$KEEP/app-$OLD.tgz" >/dev/null && \
{ [ ! -e "$PH" ] || { tar -tzf "$KEEP/volumes-$NOW.tgz" >/dev/null && tar -tzf "$KEEP/app-$NOW.tgz" >/dev/null && \
[ ! -e "$PH.previous" ] && docker-compose -f "$PH/docker-compose.yml" down -v && mv "$PH" "$PH.previous"; }; } && \
tar -C /opt -xzf "$KEEP/app-$OLD.tgz" && \
docker-compose -f "$PH/docker-compose.yml" create --no-build --pull always && \
tar -C /var/lib/docker/volumes -xzf "$KEEP/volumes-$OLD.tgz" && \
docker-compose -f "$PH/docker-compose.yml" up -d --no-build && echo "back to $OLD"
A restore gives you your data back but not an older PostHog. The folder you unpack holds the compose files, .env.services and code from the day of the copy, while the images come from today's latest, and the start script written by deploy-hobby applies database migrations before the app. When the copy is older than a few days, follow the restore at once with the upgrade from the next section.
Staying current, and the licence
Make a fresh copy, then upgrade from inside /opt/posthog with PostHog's own upgrade script, fetched and run in two steps: curl -fsSLo upgrade-hobby https://raw.githubusercontent.com/PostHog/posthog/master/bin/upgrade-hobby && bash upgrade-hobby. It warns you about a few minutes of downtime and asks before doing anything, checks your free disk, updates the code, rebuilds the compose file while keeping your .env, and asks once more before starting the stack.
On the licence, this is how we read it, and it isn't legal advice. PostHog's LICENSE file covers the code outside its ee/ folder with the MIT (Expat) licence; ee/ is under the PostHog Enterprise licence, which is proprietary: in production that code needs a valid PostHog subscription, while copying and changing it for development and testing is allowed without one. MIT only asks that copies you hand on keep the copyright and licence notice. There is no duty to share your own changes.
Questions people ask
- Will PostHog help me if my self-hosted instance breaks?
- No. PostHog does not answer support tickets or debug self-hosted instances, and it says it cannot help recover lost data. Its troubleshooting guide and GitHub issues are the help that exists, so your own backups matter.
- How many events can one server handle?
- The disclaimer's wording is that one machine is unlikely to scale past "a couple 100ks events" without real effort. Its decision chart asks whether you expect more than 300k events, 1k recordings or 300k flag calls; if you do, it asks next whether running infrastructure is a core skill of your team, and if not, it points you to its Cloud.
- Why do the web ports stay shut during setup?
- Because the first visitor to a fresh PostHog gets to set up its first account, and the stack also answers over plain HTTP. Closing the ports until you have signed up through an SSH tunnel means that first visitor is you.
- Can I stop my server sending usage data to PostHog?
- Partly. The OPT_OUT_CAPTURE=true setting in .env is meant to switch off product usage data, yet in the hobby setup just the web container receives it, so the worker's daily usage report still goes out. The install script's start and finish messages, which include your domain, are sent before you get the chance.