The default advice for website analytics is “just add GA4.” We didn’t want to. GA4’s consent-banner tax means you lose 40–60% of your data to cookie rejections before you’ve measured anything, its script is ~135KB against Plausible’s ~2KB, and the data ends up in someone else’s ad ecosystem instead of yours. Managed Plausible fixes the consent problem but costs $9+/month forever.
So we deployed Plausible Community Edition on hardware we already own — a Proxmox LXC in our homelab — and solved the part everyone hand-waves: how do you serve analytics publicly without opening a single inbound port? The answer is Cloudflare Tunnel, an outbound-only connection that gives you a public endpoint with nothing exposed to attack. Total setup time was about 30 minutes, and the marginal software cost is $0/month.
Tested with: Plausible CE v3.1.0, Postgres 16-alpine, ClickHouse 24.3.3-alpine, Docker CE 29.7.2 / Compose v5.5.0, cloudflared 2026.8.2, Debian 12 LXC (2 vCPU / 2GB RAM / 16GB disk) on Proxmox. Live instance: https://plausible.atiqlabs.com. Every command below was run and verified during that deployment on 2026-08-24.
The architecture
Visitor browser
→ Cloudflare edge
→ cloudflared tunnel (outbound-only — zero inbound ports)
→ LXC "plausible" (192.168.1.252, Debian 12)
→ Plausible CE bound to 127.0.0.1:8000 + Postgres 16 + ClickHouse 24.3
The security property that makes this worth writing about: the Plausible container binds to 127.0.0.1:8000 only. It is not reachable from the LAN, not reachable from the internet, not reachable at all except through the tunnel. The tunnel itself is an outbound connection from the LXC to Cloudflare’s edge — visitors hit the nearest CF edge node, and the cloudflared daemon inside your network picks up the traffic over that persistent outbound connection. No port forwards on your router, no firewall rules, no fail2ban, no exposed surface for scanners to poke. There is literally nothing listening on an inbound port to attack.
If you’re already running Cloudflare in front of your site (we are), this composes naturally: same DNS zone, same dashboard, and the beacon loads fast because the analytics origin sits behind the same edge network as your pages.
Prerequisites
- Any Docker-capable host: an LXC or VM on Proxmox, a VPS, or a spare box. Any Debian 12 machine works.
- A domain managed in Cloudflare (the tunnel’s DNS record lives there).
- About 20–30 minutes.
Part 1 — The host (Proxmox LXC, or any Debian 12 box)
On Proxmox, we created a small unprivileged LXC from the standard Debian 12 template:
pct create 112 local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
--hostname plausible \
--cores 2 --memory 2048 --swap 512 \
--rootfs local-lvm:16 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.252/24,gw=192.168.1.1 \
--features nesting=1 \
--onboot 1 \
--unprivileged 1
Two flags matter: nesting=1 because Docker needs it inside an LXC, and a static IP so the tunnel config never breaks when DHCP reshuffles addresses. Then start it, get a shell, and install Docker CE plus Compose from Docker’s official apt repository (standard instructions; any current guide produces the same result). Not on Proxmox? Skip straight here — a plain Debian 12 install with Docker is functionally identical from this point forward.
Part 2 — The Docker Compose stack
Everything lives in /opt/plausible/. Two files:
docker-compose.yml (versions pinned — see caveats below):
services:
plausible:
image: ghcr.io/plausible/community-edition:v3.1.0
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on:
- postgres
- clickhouse
ports:
- 127.0.0.1:8000:8000
env_file:
- plausible-conf.env
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: plausible
POSTGRES_PASSWORD: change-me-postgres-password
POSTGRES_DB: plausible
volumes:
- pl-db-data:/var/lib/postgresql/data
restart: unless-stopped
clickhouse:
image: clickhouse/clickhouse-server:24.3.3.102-alpine
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- pl-events-data:/var/lib/clickhouse
- pl-events-data:/var/log/clickhouse-server
restart: unless-stopped
volumes:
pl-db-data:
pl-events-data:
Note the port mapping: 127.0.0.1:8000:8000, not - 8000:8000. That single 127.0.0.1: prefix is what keeps the app off your LAN entirely. This is the whole zero-exposure trick at the container layer.
plausible-conf.env:
BASE_URL=https://plausible.yourdomain.com
SECRET_KEY_BASE=<48-char-random-string>
DISABLE_REGISTRATION=true
Generate the secret on the host:
openssl rand -base64 48 | tr -d '\n'
Paste the output as SECRET_KEY_BASE. Never commit this file anywhere.
Now the startup-order gotcha that cost us a crash loop: depends_on only waits for the containers to start, not for ClickHouse to be ready. If the app runs migrations before ClickHouse accepts queries, everything dies. That’s why the command chain starts with sleep 10 && — crude, effective, and after months of reading other people’s compose files we can tell you everyone does some variant of it. The full sequence is: wait, create the databases, run migrations, then run the app. docker compose up -d from /opt/plausible/ and give it half a minute.
Part 3 — The Cloudflare Tunnel (zero open ports)
This is the section most tutorials skip or route through the Zero Trust dashboard, which wants team onboarding and eventually asks about billing. You don’t need any of it. The CLI path authenticates once in your browser and never touches the dashboard:
# Install cloudflared (Debian/Ubuntu)
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
sudo mv cloudflared-linux-amd64 /usr/local/bin/cloudflared
sudo chmod +x /usr/local/bin/cloudflared
# One-time browser auth (opens a URL; pick your domain)
cloudflared tunnel login
# Create the tunnel and wire up DNS automatically
cloudflared tunnel create plausible
cloudflared tunnel route dns -f plausible plausible.yourdomain.com
# Install as a systemd service (enabled + started)
cloudflared service install
tunnel route dns creates the CNAME record in your Cloudflare zone pointing at the tunnel — you don’t touch DNS manually. Note the tunnel ID printed by create; you’ll put it in the config file, but treat it as semi-sensitive and don’t paste it into public repos (ours appears nowhere in this post for the same reason).
Then write /etc/cloudflared/config.yml:
tunnel: <your-tunnel-id>
credentials-file: /etc/cloudflared/<your-tunnel-id>.json
ingress:
- hostname: plausible.yourdomain.com
service: http://localhost:8000
- service: http_status:404
That ingress block is the entire routing table: hostname → loopback-bound app, everything else → 404. Restart the service (sudo systemctl restart cloudflared), check it’s active, and https://plausible.yourdomain.com should return the Plausible login page — served from a server with zero inbound ports open. TLS terminates at Cloudflare’s edge; between the edge and your LXC the traffic rides the outbound tunnel connection. You never manage a certificate.
Part 4 — First account, first site, Astro integration
Because DISABLE_REGISTRATION=true, the first account you register becomes the owner and registration closes immediately afterward — nobody else can sign up on your instance, ever. Visit your new domain, register, add your production site’s domain, and copy the snippet.
We’re an Astro shop, so the script went in src/layouts/BaseLayout.astro, just before </head>:
<script defer data-domain="atiqlabs.com" src="https://plausible.yourdomain.com/js/script.js"></script>
One line, ~2KB, deferred, done. Worth noting: this runs fine alongside Cloudflare Web Analytics if you’re still collecting baseline numbers from it. We deliberately left both beacons live — more on why in the series hook at the end.
Verifying events actually flow
Don’t trust a green container status; prove the pipeline end to end:
- Open your site in a normal browser (not incognito-and-blocked-by-an-extension).
- Open devtools → Network tab, filter for
/api/event. - You should see the event POST return 202 Accepted.
- Switch to your Plausible dashboard — Current Visitors should tick up within seconds.
Ours returned 202 on the first real visit, which matched the dashboard immediately. That four-step check takes under a minute and beats staring at logs.
Honest caveats
- Home-internet dependency. If your residential connection drops, capture pauses. Events aren’t corrupted or lost-after-the-fact — browsers fire them once and move on — but those minutes simply have no data. For most sites that’s a rounding error; for a launch-day traffic spike during an outage, it isn’t.
- ClickHouse upgrades are the fiddly item. It holds all your event data and its version pinning matters more than anything else in the stack. Pin versions in compose (as above), read release notes before bumping, snapshot
pl-events-datafirst. The same discipline applies to any self-hosted observability stack — we cover the broader maintenance posture in our self-hosted LLM telemetry hardening guide. - No SRI hashes on the beacon. Subresource Integrity doesn’t apply to these scripts — they rotate content by design. Standard trade-off for any hosted-analytics beacon, self-hosted or not.
- The tunnel adds a hop through CF’s edge. Latency impact is milliseconds; nobody visiting your site will ever notice.
Alternative: the cloud-hosted variant (Digital Ocean, etc.)
Maybe home-internet downtime genuinely bothers you, or the analytics box would be your fifth always-on device and your household has opinions. The playbook maps cleanly onto a $6–12/month droplet: skip Part 1 entirely, spin up a 2GB Ubuntu droplet, install Docker + Compose identically, and every step from Part 2 onward is exactly the same — including the tunnel. Zero open ports applies there too, and the CLI path still skips Zero Trust onboarding.
What you gain: uptime independent of your home connection, datacenter-grade power and bandwidth. What you trade: roughly $72–144/year, and your analytics data lives on someone else’s disk again — which was rather the point of leaving managed hosting. To be explicit: we didn’t benchmark this exact path. We run ours locally; the above is guidance, not a tested deployment. But only step 1 differs, and steps 2–4 were executed verbatim on our hardware.
For the crawl-based verification side of site changes, we use the same pre/post-deploy crawl-compare discipline documented in our n8n deploy-tested write-up — the deploy gate caught nothing broken when the beacon landed, which is exactly what you want to hear about an analytics script.
What’s next
Both beacons — Cloudflare Web Analytics and self-hosted Plausible — are running side by side right now. After 30 days we’ll publish the comparison: real numbers, both tools, same traffic, including how much data each captures without a consent banner in the way and where their measurement philosophies diverge. Part 2 will also cover goals and funnels in Plausible — contact-form conversion tracking being the first thing most small-business sites actually want out of analytics.
Questions about running observability on infrastructure you own? That’s closer to managed LLM infrastructure than to a hosted analytics product. Get in touch.
Building your own AI infrastructure?
Talk to us