DEV Community

Cover image for Self-Host Plausible Analytics on Hetzner: A Complete Guide with Podman and Caddy
Nacio-Felix Laubressac - dev & security
Nacio-Felix Laubressac - dev & security

Posted on • Originally published at renard-digital.fr

Self-Host Plausible Analytics on Hetzner: A Complete Guide with Podman and Caddy

How to Self-Host Plausible Analytics: A Privacy-Focused Guide for VPS

Take back control of your analytics—without sacrificing privacy or security.

Google Analytics tracks your visitors. It also tracks you. Every click, every scroll, every session is logged, analyzed, and monetized. For small businesses, freelancers, and privacy-conscious teams, this is a non-starter.

Plausible Analytics offers a lightweight, open-source alternative. It's GDPR-compliant by default, doesn't use cookies, and collects zero personal data. But why stop there? Self-hosting Plausible gives you full control over your data, your security, and your privacy.

In this guide, we'll walk through how to self-host Plausible Analytics on a Hetzner VPS using Podman (not Docker), Caddy for automatic HTTPS, and advanced security hardening — including 2FA, dual-stack (IPv4 + IPv6), and VPN-only dashboard access.


Why Self-Host Plausible?

Privacy First

  • No cookies: Unlike Google Analytics, Plausible doesn't track users across sites.

  • No personal data: Only aggregates metrics (page views, referrers, devices).

  • GDPR-compliant: No need for cookie banners or consent pop-ups.

Security by Design

  • Full control: Your data stays on your server — no third-party access.

  • Rootless Podman: Run containers without sudo for better isolation.

  • Advanced hardening: Firewalls, fail2ban, SELinux, and more.

Cost-Effective

  • Free: No monthly fees (vs. Plausible's $9+/month hosted plans).

  • Scalable: Start with a €4.50/month Hetzner VPS and upgrade as needed.


Prerequisites

Before we begin, ensure you have:

A Hetzner VPS (CX21: 2 vCPU, 4GB RAM, 40GB SSD recommended).

A domain name (e.g., analytics.yourdomain.com).

Basic Linux knowledge (SSH, terminal commands).

Podman 5.0+ (rootless, daemonless).


Step 1: Server Hardening (Advanced Security)

Firewall Rules

Restrict access to only essential ports (SSH, HTTP, HTTPS):

sudo apt update && sudo apt install -y ufw
sudo ufw allow 22/tcp      # SSH
sudo ufw allow 80/tcp      # HTTP (for Let's Encrypt)
sudo ufw allow 443/tcp     # HTTPS
sudo ufw deny all          # Block everything else
sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

Create a Dedicated User

Never run services as root. Create a dedicated user for Plausible:

sudo useradd -r -s /bin/false plausible
Enter fullscreen mode Exit fullscreen mode

Secure SSH

Disable root login and enforce SSH keys:

sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
Enter fullscreen mode Exit fullscreen mode

Install Fail2ban

Block brute-force attacks:

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Podman

Podman is a daemonless, rootless alternative to Docker. It's more secure and integrates seamlessly with systemd.

sudo apt update && sudo apt install -y podman podman-docker
Enter fullscreen mode Exit fullscreen mode

Configure Rootless Podman

Allow your user to run containers without sudo:

sudo usermod --add-subuids 100000-165535 $USER
sudo usermod --add-subgids 100000-165535 $USER
Enter fullscreen mode Exit fullscreen mode

Enable Lingering

Ensure containers persist after logout:

sudo loginctl enable-linger $USER
Enter fullscreen mode Exit fullscreen mode

Step 3: Deploy Plausible with Quadlet

Quadlet is Podman's declarative way to manage containers with systemd. It's cleaner and more maintainable than docker-compose.yml.

Create Quadlet Directory

mkdir -p ~/.config/containers/systemd/plausible
Enter fullscreen mode Exit fullscreen mode

PostgreSQL Container

Create ~/.config/containers/systemd/plausible/plausible-postgres.container:

[Unit]
Description=Plausible PostgreSQL Database
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/postgres:16-alpine
ContainerName=plausible-postgres
Environment=POSTGRES_DB=plausible
Environment=POSTGRES_USER=plausible
EnvironmentFile=%h/volumes/plausible/.env
Volume=%h/volumes/plausible/postgres-data:/var/lib/postgresql/data:Z
HealthCmd=pg_isready -U plausible
HealthInterval=30s
AutoUpdate=registry
Network=plausible.network
NoNewPrivileges=true
DropCapability=ALL
AddCapability=CAP_CHOWN,CAP_DAC_OVERRIDE,CAP_SETUID,CAP_SETGID
Memory=512m
PidsLimit=200

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
Enter fullscreen mode Exit fullscreen mode

ClickHouse Container

Create ~/.config/containers/systemd/plausible/plausible-clickhouse.container:

[Unit]
Description=Plausible ClickHouse Analytics Database
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/clickhouse/clickhouse-server:24.3-alpine
ContainerName=plausible-clickhouse
Volume=%h/volumes/plausible/clickhouse-data:/var/lib/clickhouse:Z
HealthCmd=wget --spider -q http://localhost:8123/ping
HealthInterval=30s
AutoUpdate=registry
Network=plausible.network
NoNewPrivileges=true
DropCapability=ALL
AddCapability=CAP_CHOWN,CAP_DAC_OVERRIDE,CAP_SETUID,CAP_SETGID
Ulimit=nofile=262144:262144
Memory=1g
PidsLimit=500

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
Enter fullscreen mode Exit fullscreen mode

Plausible App Container

Create ~/.config/containers/systemd/plausible/plausible.container:

[Unit]
Description=Plausible Analytics Web Application
After=network-online.target
After=plausible-postgres.service
After=plausible-clickhouse.service
Wants=network-online.target

[Container]
Image=ghcr.io/plausible/community-edition:v3.2.1
ContainerName=plausible
PublishPort=127.0.0.1:8000:8000
EnvironmentFile=%h/volumes/plausible/.env
HealthCmd=curl -fsS http://localhost:8000/api/health || exit 1
HealthInterval=30s
AutoUpdate=registry
Network=plausible.network
NoNewPrivileges=true
DropCapability=ALL
AddCapability=CAP_CHOWN,CAP_DAC_OVERRIDE,CAP_SETUID,CAP_SETGID
Memory=256m
PidsLimit=100

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
Enter fullscreen mode Exit fullscreen mode

Network Configuration

Create ~/.config/containers/systemd/plausible/plausible.network:

[Network]
Description=Plausible internal network
Internal=false
DNSEnabled=true
Enter fullscreen mode Exit fullscreen mode

Environment File

Create ~/volumes/plausible/.env:

# Required
BASE_URL=https://analytics.yourdomain.com
SECRET_KEY_BASE=$(openssl rand -base64 48)    # Generate once, back up securely
TOTP_VAULT_KEY=$(openssl rand -base64 32)     # For 2FA

# Database URLs
DATABASE_URL=postgres://plausible:${POSTGRES_PASSWORD}@plausible-postgres:5432/plausible
CLICKHOUSE_DATABASE_URL=http://plausible:secret@plausible-clickhouse:8123/plausible

# Postgres
POSTGRES_PASSWORD=your-strong-password-here

# SMTP (for password resets & email reports)
SMTP_HOST_ADDR=smtp.resend.com
SMTP_HOST_PORT=587
SMTP_USER_NAME=resend_api_key
SMTP_USER_PWD=re_xxxxxxxxxxxx
MAILER_EMAIL=plausible@yourdomain.com

# Security
DISABLE_REGISTRATION=invite_only

# Privacy
CLICKHOUSE_MAX_DATA_RETENTION_DAYS=30  # 30-day retention
IP_ANONYMIZATION=true                  # Disable IP tracking
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: SECRET_KEY_BASE signs all user sessions. If you change it after first boot, every existing session and password-reset link breaks. Back it up somewhere safe.

Start the Services

systemctl --user daemon-reload
systemctl --user start plausible-postgres plausible-clickhouse
systemctl --user start plausible
systemctl --user enable plausible-postgres plausible-clickhouse plausible
Enter fullscreen mode Exit fullscreen mode

Verify everything is running:

systemctl --user status plausible
curl -I http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

Step 4: Reverse Proxy with Caddy (Automatic HTTPS)

Caddy automatically provisions Let's Encrypt certificates and enforces HTTPS. No certbot, no cron jobs, no manual renewals.

Install Caddy

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
Enter fullscreen mode Exit fullscreen mode

Configure Caddy

Create /etc/caddy/Caddyfile:

analytics.yourdomain.com {
    encode gzip zstd

    reverse_proxy 127.0.0.1:8000 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }

    # Block bots
    @bots header_regexp User-Agent "(bot|crawl|spider|slurp|semrush|ahrefs|dotbot)"
    respond @bots 403
}
Enter fullscreen mode Exit fullscreen mode

Start Caddy

sudo systemctl enable --now caddy
Enter fullscreen mode Exit fullscreen mode

Step 5: Dual-Stack (IPv4 + IPv6)

Enable IPv6 in Hetzner Cloud Console

  1. Go to Hetzner Cloud ConsoleNetworkingEnable IPv6.

  2. Assign an IPv6 address to your server.

Configure Caddy for IPv6

Update your /etc/caddy/Caddyfile to listen on both IPv4 and IPv6:

analytics.yourdomain.com {
    bind ::
    ...
}
Enter fullscreen mode Exit fullscreen mode

Verify IPv6 Connectivity

curl -6 https://analytics.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Step 6: Enable 2FA for Plausible Dashboard

Plausible supports TOTP-based 2FA for the admin dashboard.

  1. Set TOTP_VAULT_KEY in .env (already done in the environment file above).

  2. Log in to your Plausible dashboard at https://analytics.yourdomain.com.

  3. Go to SettingsSecurityEnable 2FA.

  4. Scan the QR code with Google Authenticator, Authy, or any TOTP app.

Every login now requires a time-based code from your phone. Even if your password is compromised, your dashboard stays protected.


Step 7: Privacy Optimizations

IP Anonymization

Already enabled via IP_ANONYMIZATION=true in .env. Plausible truncates visitor IPs before storing them — making it impossible to identify individual users.

Data Retention

Already set to 30 days via CLICKHOUSE_MAX_DATA_RETENTION_DAYS=30. Older analytics data is automatically purged. No hoarding, no surprises.

Restrict Dashboard Access to VPN

For maximum security, restrict the Plausible dashboard to a VPN so only you (and your team) can access it.

Option A: Tailscale (Recommended)

  1. Install Tailscale:
   curl -fsSL https://tailscale.com/install.sh | sh
   sudo tailscale up
Enter fullscreen mode Exit fullscreen mode
  1. Update Caddy to bind to your Tailscale IP only:
   analytics.yourdomain.com {
       bind 100.x.y.z  # Your Tailscale IP
       ...
   }
Enter fullscreen mode Exit fullscreen mode

Option B: WireGuard

  1. Set up WireGuard on your server.

  2. Update Caddy to bind to the WireGuard interface IP.

The tracking script (/js/script.js) still works for all visitors — only the admin dashboard is locked behind the VPN.


Step 8: Common Pitfalls & Fixes

Issue Cause Fix
ClickHouse OOM Insufficient RAM Set Memory=1g in Quadlet file
Geolocation fails Missing X-Forwarded-For Ensure Caddy sets headers correctly
Caddy cert fails DNS not propagated Run dig +short analytics.yourdomain.com
Plausible won't start SECRET_KEY_BASE changed Back up and restore the original key
IPv6 not working Hetzner IPv6 not enabled Enable in Cloud Console
Container dies after logout Lingering not enabled Run sudo loginctl enable-linger $USER
Permission denied on volumes SELinux context mismatch Add :Z suffix to volume mounts

Final Notes

🔒 Security Checklist Recap

  • [x] Firewall: only ports 22, 80, 443 open

  • [x] SSH: root login disabled, keys only

  • [x] Fail2ban: blocking brute-force attacks

  • [x] Rootless Podman: no sudo for containers

  • [x] NoNewPrivileges=true on all containers

  • [x] DropCapability=ALL + minimal AddCapability

  • [x] Memory and PID limits on all containers

  • [x] Plausible binds to 127.0.0.1 only (not public)

  • [x] HTTPS enforced via Caddy + security headers

  • [x] 2FA enabled on the dashboard

  • [x] DISABLE_REGISTRATION=invite_only

  • [x] 30-day data retention with IP anonymization

  • [x] Dashboard locked behind VPN (optional but recommended)


🚀 Try Parlant.dev Beta

Want a simpler way to self-host privacy tools? Join the Parlant.dev beta — a managed, open-source platform that takes the hassle out of running your own infrastructure.


📣 Syndication

This article is ready for Dev.to, Medium, and LinkedIn. When syndicating, add this footer:

Originally published on Renard Digital.


Next Steps

  1. Test your setup: Visit https://analytics.yourdomain.com and verify the dashboard loads.

  2. Monitor logs: Use journalctl --user -u plausible -f to debug issues.

  3. Backup regularly: Use pg_dump for PostgreSQL and clickhouse-client for ClickHouse.

Need help? Contact Renard Digital — we help businesses self-host with confidence.

Top comments (0)