Skip to main content
Self-hosting Privacy

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

A step-by-step beginner's guide to self-hosting Plausible Community Edition on a Hetzner VPS using rootless Podman, Quadlet, and Caddy. Privacy-first, secure, and production-ready.

24 min

This is the guide I wish I had found when I first decided to take control of my web analytics. If you care about visitor privacy, want full ownership of your data, and would rather not hand every pageview over to Google --- you are in the right place.

We are going to install Plausible Community Edition on a fresh Hetzner VPS, using Podman instead of Docker (rootless, daemonless, more secure), Caddy 2 as our reverse proxy (automatic HTTPS, zero config renewal), and Quadlet to manage everything as native systemd services.

Two words will guide every decision we make along the way: Privacy and Security.

Stack at a glance:

  • Ubuntu 26.04 LTS (aarch64 / Ampere)
  • Podman (rootless) with Quadlet (no Docker daemon)
  • Caddy 2 as reverse proxy with automatic TLS
  • Plausible Community Edition v3.2.1
  • PostgreSQL 16 + ClickHouse 24.12
  • Resend for transactional email
  • MaxMind GeoLite2 for city-level geolocation

Before you begin

You will need:

  • A Hetzner Cloud account
  • A domain name with access to its DNS configuration (we will use analytics.renard-digital.fr as the running example --- replace it with your own domain throughout)
  • An SSH key pair (generate one with ssh-keygen -t ed25519 if you do not have one yet)
  • A terminal and basic familiarity with running commands in it

That is it. No Docker, no Kubernetes, no previous DevOps experience required. We will walk through every single step.


What you will end up with

When you reach the end of this guide, you will have:

  • A fully functional Plausible analytics dashboard at https://analytics.renard-digital.fr
  • Automatic HTTPS via Caddy + Let’s Encrypt
  • All analytics data stored on your own server (PostgreSQL + ClickHouse)
  • Email delivery for admin login and reports (via Resend)
  • City-level geolocation for visitors (via MaxMind GeoLite2)
  • Registration locked down after your admin account is created
  • Automated daily database backups
  • All containers running rootless under a dedicated, non-login system user
  • A hardened VPS with firewall, fail2ban, and unattended security upgrades

Step 1 --- Provision your Hetzner VPS

  1. Log into the Hetzner Cloud Console.
  2. Click Create Project (or use an existing one), then Add Server.
  3. Choose the following:
SettingValue
LocationAny EU region (Falkenstein, Nuremberg, Helsinki) --- keeps data in the EU
ImageUbuntu 26.04
TypeCAX11 (Shared Ampere, 2 vCPU, 4 GB RAM)
NetworkingLeave defaults (IPv4 + IPv6)
SSH KeyAdd your public SSH key
Nameanalytics (or whatever you prefer)

Why CAX11? Plausible CE runs three containers (PostgreSQL, ClickHouse, Plausible). ClickHouse alone recommends 2 GB of RAM. The CAX11 gives you 4 GB with room to breathe, for roughly the price of a coffee per month.

  1. Click Create & Buy Now. Wait about 30 seconds.
  2. Note the Public IPv4 address displayed in the server list --- you will need it shortly.

Step 2 --- Configure DNS

Before we touch the server, let DNS propagate in the background:

  1. Go to your domain’s DNS management panel.
  2. Create an A record:
FieldValue
Name / Hostanalytics (or the subdomain you want)
TypeA
ValueYour Hetzner VPS IPv4 address
TTL300 (or the default)

If your DNS provider supports it, also add an AAAA record pointing to the IPv6 address of your VPS.

DNS propagation can take a few minutes to a few hours. We will verify it later when we set up Caddy.


Step 3 --- Connect and harden the server

Your VPS arrives as a blank Ubuntu installation accessible only as root over SSH. Let us fix that before anything else.

3.1 --- Initial SSH connection

Open a terminal on your local machine and connect:

ssh root@YOUR_SERVER_IP

Replace YOUR_SERVER_IP with the IPv4 address from Step 1.

If this is your first time connecting, you will see a host key fingerprint prompt. Type yes and press Enter.

3.2 --- System update

Always start with a fresh coat of updates:

apt update && apt upgrade -y

Install essential tools we will need throughout this guide:

apt install -y curl wget git unzip jq nftables fail2ban ufw unattended-upgrades apparmor

3.3 --- Create an admin user

Running everything as root is a security risk. Let us create a regular user with sudo privileges:

USERNAME="admin"
adduser --gecos "" $USERNAME
usermod -aG sudo $USERNAME

# Copy your SSH key so you can log in as the new user
mkdir -p /home/$USERNAME/.ssh
cp ~/.ssh/authorized_keys /home/$USERNAME/.ssh/authorized_keys
chown -R $USERNAME:$USERNAME /home/$USERNAME/.ssh
chmod 700 /home/$USERNAME/.ssh
chmod 600 /home/$USERNAME/.ssh/authorized_keys

3.4 --- SSH hardening

Edit the SSH daemon configuration:

sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^#*MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config

Restart SSH for the changes to take effect:

systemctl restart sshd

Important: Do NOT close your current SSH session yet. Open a new terminal and verify you can connect as your admin user: ssh admin@YOUR_SERVER_IP. Only after confirming access should you close the original root session.

3.5 --- Firewall with nftables

We will use nftables (the modern Linux firewall) to allow only SSH (port 22), HTTP (port 80), and HTTPS (port 443):

cat > /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Allow loopback traffic
        iif "lo" accept

        # Allow established and related connections
        ct state established,related accept

        # Allow ICMP (ping)
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept

        # Allow SSH (port 22)
        tcp dport 22 accept

        # Allow HTTP (port 80)
        tcp dport 80 accept

        # Allow HTTPS (port 443)
        tcp dport 443 accept

        # Log and drop everything else
        counter drop
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}
EOF

systemctl enable nftables
systemctl start nftables

Verify the rules are loaded:

nft list ruleset

You should see the three tcp dport rules for ports 22, 80, and 443.

Hetzner Cloud Firewall (optional but recommended): In addition to the host-level nftables firewall, Hetzner offers a cloud-level firewall that you can attach to your server in the console. Configure it with the same rules (allow TCP 22, 80, 443; deny everything else) for defense in depth.

3.6 --- Fail2ban

Fail2ban protects against brute-force SSH attacks by banning IPs that fail authentication too many times:

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h
EOF

systemctl enable fail2ban
systemctl start fail2ban

3.7 --- Automatic security updates

Keep your server patched without manual intervention:

cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF

cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
EOF

3.8 --- Raise file descriptor limits for rootless Podman

ClickHouse requires a high nofile ulimit (262,144 open file descriptors). Rootless Podman cannot raise this above the system limit, so we must increase it first:

cat > /etc/security/limits.d/plausible.conf << 'EOF'
plausible soft nofile 262144
plausible hard nofile 262144
EOF

# Also raise the limit for all systemd user instances
mkdir -p /etc/systemd/system/[email protected]
cat > /etc/systemd/system/[email protected]/limits.conf << 'EOF'
[Service]
LimitNOFILE=262144
EOF

These changes take effect at the next login or service start. No reboot needed yet.


Step 4 --- Install Podman

Now that the server is hardened, let us install Podman and its rootless dependencies:

apt install -y podman passt uidmap aardvark-dns fuse-overlayfs

What each package does:

  • podman --- the container engine (no daemon, rootless by design)
  • passt --- provides pasta, the user-mode networking tool for rootless containers
  • uidmap --- provides newuidmap/newgidmap for user namespace mapping
  • aardvark-dns --- DNS resolution by container name within Podman networks
  • fuse-overlayfs --- overlay filesystem for rootless container storage

Verify Podman works:

(EXECUTE THIS IN NON-ROOT USER)

podman --version
# Expected: podman version 5.x.x (or later)

podman info --format '{{.Host.Security.Rootless}}'
# Expected: true

Fix AppArmor if needed

Note: This section may or may not be needed on Ubuntu 26.04. If podman --version works cleanly, skip ahead. If you see Permission denied errors mentioning libsubid, the following fix applies.

Ubuntu sometimes ships AppArmor profiles that interfere with rootless Podman. If you encounter errors:

# Download and replace the problematic profiles
for profile in podman unprivileged_userns slirp4netns crun; do
  binary=""
  [[ $profile == "podman" ]]       && binary=" /usr/bin/podman"
  [[ $profile == "slirp4netns" ]]  && binary=" /usr/bin/slirp4netns"
  [[ $profile == "crun" ]]         && binary=" /usr/bin/crun"

  tee /etc/apparmor.d/${profile} > /dev/null <<PROFILE
abi <abi/4.0>,
include <tunables/global>
profile ${profile}${binary} flags=(unconfined) {
  userns,
}
PROFILE
done

apparmor_parser -r \
  /etc/apparmor.d/podman \
  /etc/apparmor.d/unprivileged_userns \
  /etc/apparmor.d/slirp4netns \
  /etc/apparmor.d/crun

# Verify
podman --version
podman run --rm docker.io/library/hello-world

Step 5 --- Create the service user

Following the principle of least privilege, we create a dedicated system user to run all Plausible containers. This isolates the workload from your admin account and from root.

useradd \
  --system \
  --create-home \
  --home-dir /opt/plausible \
  --shell /sbin/nologin \
  plausible

# Enable lingering so user containers survive logout and start at boot
loginctl enable-linger plausible

# Verify subuid/subgid mappings (required for rootless containers)\
cat /etc/subuid /etc/subgid

#You will probably have the range of the admin user, to assign a range to the plausible user, do as follows.

sudo usermod --add-subuids 200000-265535 --add-subgids 200000-265535 plausible

#of course take free ranges :)

If the subuid/subgid entries are missing:

usermod --add-subuids 100000-165535 --add-subgids 100000-165535 plausible

Step 6 --- Directory structure and ClickHouse configuration

Directory structure

mkdir -p /opt/plausible/{quadlet,clickhouse,secrets,backups,scripts}
chown -R plausible:plausible /opt/plausible
chmod 700 /opt/plausible/secrets

ClickHouse configuration files

Plausible CE provides four XML configuration files that optimize ClickHouse for small-scale setups. We need to create them on the host so we can mount them read-only into the container.

Create all four files:

sudo -u plausible tee /opt/plausible/clickhouse/logs.xml > /dev/null <<'EOF'
<clickhouse>
    <logger>
        <level>warning</level>
        <console>true</console>
    </logger>

    <query_log replace="1">
        <database>system</database>
        <table>query_log</table>
        <flush_interval_milliseconds>7500</flush_interval_milliseconds>
        <engine>
            ENGINE = MergeTree
            PARTITION BY event_date
            ORDER BY (event_time)
            TTL event_date + interval 30 day
            SETTINGS ttl_only_drop_parts=1
        </engine>
    </query_log>

    <metric_log remove="remove" />
    <asynchronous_metric_log remove="remove" />
    <query_thread_log remove="remove" />
    <text_log remove="remove" />
    <trace_log remove="remove" />
    <session_log remove="remove" />
    <part_log remove="remove" />
</clickhouse>
EOF

sudo -u plausible tee /opt/plausible/clickhouse/ipv4-only.xml > /dev/null <<'EOF'
<clickhouse>
    <listen_host>0.0.0.0</listen_host>
</clickhouse>
EOF

sudo -u plausible tee /opt/plausible/clickhouse/low-resources.xml > /dev/null <<'EOF'
<clickhouse>
    <mark_cache_size>524288000</mark_cache_size>
</clickhouse>
EOF

sudo -u plausible tee /opt/plausible/clickhouse/default-profile-low-resources-overrides.xml > /dev/null <<'EOF'
<clickhouse>
    <profiles>
        <default>
            <max_threads>1</max_threads>
            <max_block_size>8192</max_block_size>
            <max_download_threads>1</max_download_threads>
            <input_format_parallel_parsing>0</input_format_parallel_parsing>
            <output_format_parallel_formatting>0</output_format_parallel_formatting>
        </default>
    </profiles>
</clickhouse>
EOF

What these files do:

  • logs.xml --- reduces log verbosity, auto-expires query logs after 30 days, disables unnecessary metric logging (saves disk and RAM)
  • ipv4-only.xml --- makes ClickHouse bind to IPv4 only, avoiding warnings on networks without IPv6
  • low-resources.xml --- reduces the mark cache to ~500 MB (appropriate for a 4 GB server)
  • default-profile-low-resources-overrides.xml --- limits threads and parallelism for low-RAM environments (from ClickHouse’s own recommendations)

Step 7 --- Secrets and environment file

Generate secret keys

Run these commands once and note the output:

echo "SECRET_KEY_BASE:  $(openssl rand -base64 48)"
echo "TOTP_VAULT_KEY:   $(openssl rand -base64 32)"
echo "POSTGRES_PASSWORD: $(openssl rand -base64 24)"

Keep these values safe. We will place them in the environment file below.

Write the environment file

Replace every REPLACE_ME placeholder with the values generated above, and adjust the domain and SMTP credentials for your setup:

sudo -u plausible tee /opt/plausible/secrets/plausible.env > /dev/null <<'EOF'
# ── Required ──────────────────────────────────────────────────────
BASE_URL=https://analytics.renard-digital.fr
SECRET_KEY_BASE=REPLACE_ME_WITH_SECRET_KEY_BASE
TOTP_VAULT_KEY=REPLACE_ME_WITH_TOTP_VAULT_KEY

# ── Web server ────────────────────────────────────────────────────
HTTP_PORT=8000

# ── PostgreSQL ────────────────────────────────────────────────────
POSTGRES_PASSWORD=REPLACE_ME_WITH_POSTGRES_PASSWORD
DATABASE_URL=postgres://postgres:REPLACE_ME_WITH_POSTGRES_PASSWORD@plausible-db:5432/plausible_db

# ── ClickHouse ────────────────────────────────────────────────────
CLICKHOUSE_DATABASE_URL=http://plausible-events-db:8123/plausible_events_db

# ── Email (Resend) ────────────────────────────────────────────────
# See Step 13 for setup instructions
[email protected]
SMTP_HOST_ADDR=smtp.resend.com
SMTP_HOST_PORT=465
SMTP_USER_NAME=resend
SMTP_USER_PWD=REPLACE_ME_WITH_RESEND_API_KEY
SMTP_HOST_SSL_ENABLED=true

# ── Geolocation (MaxMind) ─────────────────────────────────────────
# See Step 14 for setup instructions
MAXMIND_LICENSE_KEY=REPLACE_ME_WITH_MAXMIND_LICENSE_KEY
MAXMIND_EDITION=GeoLite2-City

# ── Registration ──────────────────────────────────────────────────
# Set to "true" AFTER creating your admin account (Step 12)
DISABLE_REGISTRATION=false
EOF

chmod 600 /opt/plausible/secrets/plausible.env

Security note: This file contains all your secrets. The chmod 600 ensures only the plausible user can read it. Never commit this file to version control.

Important: The POSTGRES_PASSWORD must match in both the POSTGRES_PASSWORD line and the DATABASE_URL line. Double-check before proceeding.


Step 8 --- Quadlet files

Quadlet is Podman’s native way to run containers as systemd services. Instead of managing containers manually with podman run, we define declarative unit files and let systemd handle the lifecycle.

Each .container, .network, and .volume file in ~/.config/containers/systemd/ generates a corresponding .service file at reload time.

8.1 --- Network: plausible.network

A private bridge network so the three containers can communicate by name:

sudo -u plausible tee /opt/plausible/quadlet/plausible.network > /dev/null <<'EOF'
[Unit]
Description=Plausible CE internal network

[Network]
NetworkName=plausible-net
Driver=bridge
EOF

8.2 --- PostgreSQL: plausible-db.container

sudo -u plausible tee /opt/plausible/quadlet/plausible-db.container > /dev/null <<'EOF'
[Unit]
Description=Plausible CE - PostgreSQL 16
After=network-online.target

[Container]
ContainerName=plausible-db
Image=docker.io/library/postgres:16-alpine
Network=plausible.network
Volume=plausible-db-data:/var/lib/postgresql/data
EnvironmentFile=/opt/plausible/secrets/plausible.env
HealthCmd=pg_isready -U postgres
HealthInterval=30s
HealthTimeout=20s
HealthRetries=3
HealthStartPeriod=60s
AutoUpdate=registry

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
EOF

Named volumes: We use Podman-managed named volumes (plausible-db-data, etc.) rather than bind mounts. Podman handles ownership and permissions automatically inside the user namespace --- this avoids a whole class of permission headaches with rootless containers.

8.3 --- ClickHouse: plausible-events-db.container

sudo -u plausible tee /opt/plausible/quadlet/plausible-events-db.container > /dev/null <<'EOF'
[Unit]
Description=Plausible CE - ClickHouse 24.12
After=network-online.target

[Container]
ContainerName=plausible-events-db
Image=docker.io/clickhouse/clickhouse-server:24.12-alpine
Network=plausible.network
Volume=plausible-event-data:/var/lib/clickhouse
Volume=plausible-event-logs:/var/log/clickhouse-server
Volume=/opt/plausible/clickhouse/logs.xml:/etc/clickhouse-server/config.d/logs.xml:ro
Volume=/opt/plausible/clickhouse/ipv4-only.xml:/etc/clickhouse-server/config.d/ipv4-only.xml:ro
Volume=/opt/plausible/clickhouse/low-resources.xml:/etc/clickhouse-server/config.d/low-resources.xml:ro
Volume=/opt/plausible/clickhouse/default-profile-low-resources-overrides.xml:/etc/clickhouse-server/users.d/default-profile-low-resources-overrides.xml:ro
Environment=CLICKHOUSE_SKIP_USER_SETUP=1
Ulimit=nofile=262144:262144
HealthCmd=wget --no-verbose --tries=1 -O- http://127.0.0.1:8123/ping || exit 1
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=60s
AutoUpdate=registry

[Service]
Restart=always
RestartSec=5
LimitNOFILE=262144

[Install]
WantedBy=default.target
EOF

Why LimitNOFILE in [Service] AND Ulimit in [Container]? The systemd LimitNOFILE raises the limit for the podman run process itself. The Quadlet Ulimit= then passes --ulimit to the container. Both are needed for ClickHouse to get its required 262,144 file descriptors. This works because we raised the system limits in Step 3.8.

8.4 --- Plausible application: plausible-app.container

sudo -u plausible tee /opt/plausible/quadlet/plausible-app.container > /dev/null <<'EOF'
[Unit]
Description=Plausible CE - Analytics Application
After=plausible-db.service plausible-events-db.service
Requires=plausible-db.service plausible-events-db.service

[Container]
ContainerName=plausible-app
Image=ghcr.io/plausible/community-edition:v3.2.1
Network=plausible.network
PublishPort=127.0.0.1:8000:8000
Volume=plausible-app-data:/var/lib/plausible
EnvironmentFile=/opt/plausible/secrets/plausible.env
Environment=TMPDIR=/var/lib/plausible/tmp
Exec=sh -c "/entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
Ulimit=nofile=65535:65535
AutoUpdate=registry

[Service]
Restart=always
RestartSec=10
TimeoutStartSec=180

[Install]
WantedBy=default.target
EOF

Key details:

  • PublishPort=127.0.0.1:8000:8000 --- binds to localhost only. The Plausible dashboard is NOT exposed directly to the internet. Only Caddy (running on the same machine) can reach it. This is a deliberate security choice.
  • Exec=... --- runs database creation, migrations, and then starts the web server. This is equivalent to the command in the official Docker Compose file.
  • Requires= --- ensures PostgreSQL and ClickHouse are started before the app.
  • TimeoutStartSec=180 --- first-run database migrations can take a while; give it 3 minutes.

Step 9 --- Activate Quadlet

Quadlet reads .container, .network, and .volume files from a specific directory. We need to symlink our files into place and reload systemd.

# Create the Quadlet target directory for the plausible user
sudo -u plausible mkdir -p /opt/plausible/.config/containers/systemd

# Symlink all Quadlet files
sudo -u plausible bash -c '
  for f in /opt/plausible/quadlet/*.container \
            /opt/plausible/quadlet/*.network; do
    ln -sfv "$f" ~/.config/containers/systemd/
  done
'

# Reload systemd for the plausible user
id -u plausible
#note the result, for example 999 and inject it in the next command line
sudo -u plausible env XDG_RUNTIME_DIR=/run/user/999 systemctl --user daemon-reload

Verify that Quadlet generated all the expected services:

sudo -u plausible bash -c "ls /run/user/$(id -u plausible)/systemd/generator/"

Expected output:

plausible-app.service
plausible-db.service
plausible-events-db.service
plausible-network.service

If you see all four, Quadlet has correctly parsed your files. If any are missing, check the Quadlet files for syntax errors and re-run daemon-reload.


Step 10 --- Pull images and start services

Pulling images before starting prevents systemd timeouts on first boot (images are several hundred megabytes):

cd /tmp
sudo -u plausible podman pull docker.io/library/postgres:16-alpine
sudo -u plausible podman pull docker.io/clickhouse/clickhouse-server:24.12-alpine
sudo -u plausible podman pull ghcr.io/plausible/community-edition:v3.2.1

Note: Pulling from ghcr.io (GitHub Container Registry) does not require authentication for public images. If you encounter rate limits, wait a moment and retry.

Start in dependency order

PLAUSIBLE_UID=$(id -u plausible)
D="XDG_RUNTIME_DIR=/run/user/${PLAUSIBLE_UID}"

# 1. Create the network
sudo -u plausible env $D systemctl --user start plausible-network.service

# 2. Start databases
sudo -u plausible env $D systemctl --user start plausible-db.service
sudo -u plausible env $D systemctl --user start plausible-events-db.service

# 3. Wait for databases to report healthy
echo "Waiting for databases to become healthy..."
sleep 30

# 4. Verify databases are ready
sudo -u plausible podman exec plausible-db pg_isready -U postgres
# Expected: accepting connections

sudo -u plausible podman exec plausible-events-db wget -qO- http://127.0.0.1:8123/ping
# Expected: Ok.

# 5. Start the Plausible application
sudo -u plausible env $D systemctl --user start plausible-app.service

Monitor the startup

Database migrations run on first boot and take about 30-60 seconds:

sudo journalctl _UID=$(id -u plausible) -f --no-pager \
  | grep -v "health_status\|PODMAN_SYSTEMD"

Look for log lines indicating the server is ready. Then test locally:

curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/
# Expected: 200

If you see 200, Plausible is running and responding. Time to expose it to the world --- safely.


Step 11 --- Install Caddy

Caddy is our reverse proxy. It handles TLS certificate provisioning and renewal automatically via Let’s Encrypt, so we never have to think about HTTPS certificates.

Since the CAX11 is an ARM (aarch64) server, the official Cloudsmith APT repository can have GPG issues. We install directly from the GitHub release:

CADDY_VER=$(curl -s https://api.github.com/repos/caddyserver/caddy/releases/latest \
  | grep tag_name | cut -d'"' -f4 | tr -d v)

ARCH=$(dpkg --print-architecture)
curl -sLo /tmp/caddy.tar.gz \
  "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VER}/caddy_${CADDY_VER}_linux_${ARCH}.tar.gz"

tar -xzf /tmp/caddy.tar.gz -C /usr/local/bin caddy
chmod +x /usr/local/bin/caddy
caddy version

Create the Caddy user and directories

Caddy will run as its own unprivileged system user (not root), with the CAP_NET_BIND_SERVICE capability to bind to ports 80 and 443:

groupadd --system caddy 2>/dev/null || true
useradd --system --gid caddy \
  --home /var/lib/caddy --shell /sbin/nologin caddy 2>/dev/null || true

mkdir -p /etc/caddy /var/log/caddy /var/lib/caddy
sudo chown caddy:caddy /etc/caddy /var/log/caddy /var/lib/caddy

Write the Caddyfile

cat > /etc/caddy/Caddyfile << 'EOF'
analytics.renard-digital.fr {
    reverse_proxy localhost:8000

    encode gzip zstd

    header {
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    log {
        output file /var/log/caddy/analytics.renard-digital.fr.log {
            roll_size 10mb
            roll_keep 5
        }
    }
}
EOF

chown caddy:caddy /etc/caddy/Caddyfile

Replace analytics.renard-digital.fr with your actual domain from Step 2. The domain in the Caddyfile MUST match the BASE_URL in your environment file and your DNS record.

What the Caddyfile does:

  • Proxies all requests to Plausible on localhost:8000
  • Compresses responses with gzip and zstd
  • Sets security headers (prevents clickjacking, MIME sniffing, etc.)
  • Hides the Caddy server version (-Server)
  • Logs access to a rotating log file

Create the systemd service

cat > /etc/systemd/system/caddy.service << 'EOF'
[Unit]
Description=Caddy web server
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload

Validate and start Caddy

caddy validate --config /etc/caddy/Caddyfile
# Expected: Valid configuration

systemctl enable --now caddy

Check Caddy’s status and logs:

systemctl status caddy
journalctl -u caddy --no-pager -n 20

Caddy will now:

  1. Contact the Let’s Encrypt ACME server
  2. Verify that your domain resolves to this server’s IP (this is why DNS from Step 2 matters)
  3. Obtain a TLS certificate
  4. Start serving HTTPS on port 443

Verify everything works

Open a browser and visit:

https://analytics.your-domain.com

You should see the Plausible registration/login page, served over HTTPS with a valid certificate. If you see a certificate error, double-check that your DNS A record points to the correct IP address.

You can also verify from the command line:

curl -sI https://analytics.your-domain.com | head -5
# Expected: HTTP/2 200, with proper headers

Step 12 --- Create your admin account and lock registration

Create the admin account

  1. Visit https://analytics.renard-digital.fr in your browser.
  2. Fill in your name, email, and password to create the first (admin) account.
  3. Log in and verify the dashboard loads.

Lock registration

Now that your admin account exists, prevent anyone else from creating accounts:

sudo -u plausible sed -i 's/DISABLE_REGISTRATION=false/DISABLE_REGISTRATION=true/' \
  /opt/plausible/secrets/plausible.env

# Restart the Plausible app to pick up the change
PLAUSIBLE_UID=$(id -u plausible)
sudo -u plausible env XDG_RUNTIME_DIR=/run/user/${PLAUSIBLE_UID} \
  systemctl --user restart plausible-app.service

Verify: open an incognito/private browser window and visit your Plausible URL. You should see a login page with no registration link.

Why this matters: Leaving registration open means anyone who discovers your analytics URL can create an account and access your data. Locking it down is a fundamental security measure.


Step 13 --- Configure email with Resend

Plausible uses email for admin login links, password resets, and weekly report delivery. We use Resend for this --- their free tier covers 3,000 emails per month, which is more than enough.

Set up Resend

  1. Sign up at resend.com.
  2. Go to Domains and add your domain (e.g., renard-digital.fr).
  3. Resend will show you DNS records to add (SPF, DKIM, DMARC). Add them to your DNS configuration and wait for verification.
  4. Go to API Keys and create a new API key. Copy it.

Update the environment file

Edit /opt/plausible/secrets/plausible.env and fill in the email section:

sudo -u plausible nano /opt/plausible/secrets/plausible.env

Set these values:

[email protected]
SMTP_HOST_ADDR=smtp.resend.com
SMTP_HOST_PORT=465
SMTP_USER_NAME=resend
SMTP_USER_PWD=re_YOUR_RESEND_API_KEY
SMTP_HOST_SSL_ENABLED=true

Then restart:

PLAUSIBLE_UID=$(id -u plausible)
sudo -u plausible env XDG_RUNTIME_DIR=/run/user/${PLAUSIBLE_UID} \
  systemctl --user restart plausible-app.service

Test email delivery

Log out of your Plausible dashboard and click the “Forgot password” link. Enter your admin email address. If the configuration is correct, you will receive an email within seconds.


Step 14 --- Enable city-level geolocation with MaxMind

By default, Plausible uses a built-in country-level geolocation database. For city-level detail, we integrate MaxMind GeoLite2, which is free for use.

Heads up: The city-level database requires approximately 1 GB of additional RAM. On the CAX11 (4 GB total), this is fine.

Set up MaxMind

WORK IN PROGRESS


Step 15 --- Database backups

Losing your analytics data is not an option. We will set up automated daily backups for both PostgreSQL and ClickHouse.

PostgreSQL backup script

sudo -u plausible tee /opt/plausible/scripts/backup-postgres.sh > /dev/null <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR=/opt/plausible/backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"

podman exec plausible-db \
  pg_dump -U postgres plausible_db \
  | gzip > "${BACKUP_DIR}/postgres_${TIMESTAMP}.sql.gz"

find "$BACKUP_DIR" -name "postgres_*.sql.gz" -mtime +30 -delete
echo "$(date): PostgreSQL backup completed - postgres_${TIMESTAMP}.sql.gz"
SCRIPT

sudo -u plausible chmod 750 /opt/plausible/scripts/backup-postgres.sh

ClickHouse backup script

sudo -u plausible tee /opt/plausible/scripts/backup-clickhouse.sh > /dev/null <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR=/opt/plausible/backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"

CLICKHOUSE_DB="plausible_events_db"
BACKUP_FILE="${CLICKHOUSE_DB}_${TIMESTAMP}.zip"

# Run the backup and capture the output to verify success
# (clickhouse-client returns exit 0 even if the backup itself fails)
BACKUP_OUTPUT=$(podman exec plausible-events-db clickhouse-client \
  --query "BACKUP DATABASE ${CLICKHOUSE_DB} TO File('${BACKUP_FILE}')" 2>&1)

if ! echo "$BACKUP_OUTPUT" | grep -q "BACKUPED\|100%"; then
  echo "ERROR: ClickHouse backup failed"
  echo "$BACKUP_OUTPUT"
  exit 1
fi

# Copy the backup out of the container using cat + redirection
# (podman cp can fail with rootless containers due to user namespace mapping)
podman exec plausible-events-db \
  cat "/var/lib/clickhouse/user_files/${BACKUP_FILE}" \
  > "${BACKUP_DIR}/clickhouse_${TIMESTAMP}.zip"

# Verify the file was copied successfully
if [ ! -s "${BACKUP_DIR}/clickhouse_${TIMESTAMP}.zip" ]; then
  echo "ERROR: Backup file is empty or missing after copy"
  exit 1
fi

# Clean up the backup file from the container
podman exec plausible-events-db rm -f \
  "/var/lib/clickhouse/user_files/${BACKUP_FILE}"

find "$BACKUP_DIR" -name "clickhouse_*.zip" -mtime +30 -delete
echo "$(date): ClickHouse backup completed - clickhouse_${TIMESTAMP}.zip"
SCRIPT

sudo -u plausible chmod 750 /opt/plausible/scripts/backup-clickhouse.sh

Schedule daily backups via cron

sudo -u plausible bash -c '
  (crontab -l 2>/dev/null; echo "0 2 * * * /opt/plausible/scripts/backup-postgres.sh >> /opt/plausible/backups/backup.log 2>&1"; echo "30 2 * * * /opt/plausible/scripts/backup-clickhouse.sh >> /opt/plausible/backups/backup.log 2>&1") | crontab -
'

# Verify
sudo -u plausible crontab -l

This runs PostgreSQL backups at 02:00 and ClickHouse backups at 02:30 every night. Backups older than 30 days are automatically pruned.

Test the backup manually

sudo -u plausible /opt/plausible/scripts/backup-postgres.sh
sudo -u plausible /opt/plausible/scripts/backup-clickhouse.sh
ls -lh /opt/plausible/backups/

Off-site backups: The backups above are stored on the same server. For true data safety, consider syncing them to a separate location (e.g., Hetzner Storage Box, S3-compatible storage, or another VPS). A simple rsync or rclone cron job does the job.


Step 16 --- Survive reboots

We already enabled lingering in Step 5 with loginctl enable-linger plausible. This ensures the plausible user’s systemd services start at boot without requiring an interactive login session.

Verify lingering

loginctl show-user plausible | grep Linger
# Expected: Linger=yes

Test a full reboot

reboot

Wait about 60 seconds for the server to come back up, then SSH back in and check:

sudo -u plausible podman ps

Expected output (three containers running):

CONTAINER ID  IMAGE                                              STATUS            NAMES
xxxxxxxxxxxx  localhost/postgres:16-alpine                        Up X seconds      plausible-db
xxxxxxxxxxxx  localhost/clickhouse/clickhouse-server:24.12-alpine Up X seconds      plausible-events-db
xxxxxxxxxxxx  localhost/ghcr.io/plausible/community-edition:v3.2.1 Up X seconds    plausible-app

Also verify Caddy:

systemctl status caddy
# Expected: active (running)

If everything is running, your Plausible instance will survive reboots.


Useful commands for daily management

Add this alias to your own admin user’s ~/.bashrc for convenience:

echo 'export PLAUSIBLE_UID=$(id -u plausible)' >> ~/.bashrc
echo 'alias plausible-ctl="sudo -u plausible env XDG_RUNTIME_DIR=/run/user/\${PLAUSIBLE_UID} systemctl --user"' >> ~/.bashrc
source ~/.bashrc

Now you can use these shortcuts:

# ── Status ──────────────────────────────────────────────────
plausible-ctl status plausible-app.service
sudo -u plausible podman ps

# ── Logs ────────────────────────────────────────────────────
# Follow all Plausible logs
sudo journalctl _UID=$(id -u plausible) -f \
  | grep -v "health_status\|PODMAN_SYSTEMD"

# Follow only the app container
sudo -u plausible podman logs -f plausible-app

# ── Restart ─────────────────────────────────────────────────
plausible-ctl restart plausible-app.service
plausible-ctl restart plausible-db.service
plausible-ctl restart plausible-events-db.service

# ── Shell into a container ──────────────────────────────────
sudo -u plausible podman exec -it plausible-app sh
sudo -u plausible podman exec -it plausible-db psql -U postgres plausible_db
sudo -u plausible podman exec -it plausible-events-db clickhouse-client

# ── Update a container image ────────────────────────────────
sudo -u plausible podman pull ghcr.io/plausible/community-edition:v3.2.1
plausible-ctl restart plausible-app.service

# ── Check disk usage ────────────────────────────────────────
sudo -u plausible podman system df

Troubleshooting

SymptomLikely causeFix
podman: libsubid.so: Permission deniedAppArmor blocking PodmanSee the AppArmor fix in Step 4
podman run fails with ulimit errorSystem nofile too lowVerify Step 3.8 and reboot
Plausible app keeps restartingDatabase not ready yetWait for health checks to pass, then restart: plausible-ctl restart plausible-app.service
curl localhost:8000 returns connection refusedApp container not runningCheck logs: journalctl _UID=$(id -u plausible) -f
Caddy fails to obtain certificateDNS not pointing to serverVerify your A record with dig analytics.renard-digital.fr
Caddy permission denied on CaddyfileWrong file ownershipsudo chown caddy:caddy /etc/caddy/Caddyfile
ClickHouse Address family not supportedIPv6 not available in networkEnsure ipv4-only.xml is mounted (Step 6)
ClickHouse OOM (out of memory)Insufficient RAMEnsure you have at least 4 GB. Check low-resources.xml is mounted
Email not sendingSMTP credentials or DNSVerify SPF/DKIM DNS records for your domain in Resend
Registration page visible after lockConfig not reloadedVerify DISABLE_REGISTRATION=true in env file, then restart the app
Container cannot resolve peer by nameMissing aardvark-dnsapt install aardvark-dns, then restart containers

Adding the tracking script to your website

Your Plausible instance is ready. To start collecting analytics, add this snippet to every page of the website(s) you want to track, just before the closing </head> tag:

<script defer data-domain="your-website.com" src="https://analytics.renard-digital.fr/js/script.js"></script>

Replace your-website.com with the domain you want to track, and analytics.renard-digital.fr with your actual Plausible domain.

Because the script is served from your own domain (not a third-party analytics service), it is more resistant to being blocked by ad blockers and browser privacy features. This also means no third party ever sees your visitors’ data.

In your Plausible dashboard, click Add a website and enter the domain. Stats will start appearing within minutes.


What is not covered

  • Upgrading Plausible CE --- To upgrade, pull the new image, update the version tag in the Quadlet file, and restart. Always read the release notes before upgrading.
  • Monitoring and alerting --- For production monitoring, consider forwarding container logs to an aggregation service or setting up health-check alerts.
  • Horizontal scaling --- This guide targets a single-server setup. For high-traffic sites, you would need external PostgreSQL, ClickHouse clustering, and a load balancer.
  • Off-site backup sync --- Backups are stored locally. For true resilience, sync them to remote storage (S3, Hetzner Storage Box, etc.).
  • Google integrations --- Search Console and Google Ads import require additional setup.

Why this stack?

A few words on the design choices in this guide, in case you are wondering:

Why Podman instead of Docker? Podman is rootless by design. There is no daemon running as root, no socket that can be exploited for privilege escalation. Containers run under a regular user account with the least privileges necessary. This is a meaningful security improvement over Docker for a single-server setup.

Why Caddy instead of Nginx? Caddy handles TLS certificates automatically --- no Certbot, no cron jobs, no expired certificate surprises. Its configuration is minimal and human-readable. For a single-site reverse proxy, it is hard to beat.

Why Quadlet instead of podman-compose? Quadlet integrates directly with systemd, giving you proper service management (dependencies, restart policies, health checks, journal logging). There is no extra daemon or layer to maintain.

Why a dedicated system user? The plausible user isolates the entire analytics stack from the rest of the system. If any container is ever compromised, the attacker is confined to this unprivileged user’s namespace --- they cannot reach your admin account, your SSH keys, or any other service on the machine.


If you made it this far, you now have a fully self-hosted, privacy-respecting web analytics platform under your complete control. No cookies. No personal data collection. No third-party tracking. Just clean, actionable insights about your website traffic --- served from your own infrastructure.

Welcome to the independent web.

Tags

#plausible analytics #self-hosting #podman #caddy #ubuntu #hetzner #quadlet #systemd #rootless containers #privacy #web analytics #clickhouse #postgresql

Got a web project?

Renard Digital supports you from A to Z: site, domain, email.

Get in touch