All articles
TutorialsMar 05, 2026 · 18 min read

Self-Host Plausible Analytics on a VPS for Privacy-First Metrics

Self-Host Plausible Analytics on a VPS for Privacy-First Metrics

Most analytics tools squeeze every drop of behavioral data from your visitors and ship it to a third party. Plausible Analytics takes the opposite path: a small script, no cookies, no cross-site tracking, no consent banner required in most jurisdictions. The Community Edition is open source and runs perfectly well on a small VPS.

This tutorial walks through a production-ready install: Docker Compose, the official Plausible image, ClickHouse for events, Postgres for app data, Caddy for automatic HTTPS, and a simple backup script for both databases.

The `SECRET_KEY_BASE` you set on first boot signs every user session and recovery token. Generate it once with `openssl rand -base64 64` and back it up. If it changes after the first boot, every existing session and password-reset link breaks.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like stats.example.com at your server
  • Run Plausible CE, ClickHouse, and Postgres with one docker-compose.yml
  • Generate a SECRET_KEY_BASE with openssl rand -base64 64
  • Front the stack with Caddy for automatic HTTPS
  • Create the first admin user, add a site, drop in the script tag
  • Schedule daily Postgres and ClickHouse backups

Total time: about 20 minutes.

What You Need

  • A VPS with at least 2 GB RAM (4 GB is comfortable) running Ubuntu 22.04 or 24.04
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet
  • Root or sudo access

ClickHouse is the heaviest part of the stack. On a 1 GB box it works but feels tight. 2 GB is the realistic floor.

Step 1: Point a Subdomain at Your VPS

In your DNS provider, create an A record for the subdomain you want the dashboard to live on:

stats.example.com -> YOUR_VPS_IPV4

Add an AAAA record for IPv6 if you use it. Verify it resolves before continuing:

dig +short stats.example.com

DNS has to resolve before Caddy can fetch a Let's Encrypt certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu server:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm:

docker --version docker compose version

Step 3: Open the Firewall

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS.

Step 4: Create the Project Directory

sudo mkdir -p /opt/plausible cd /opt/plausible sudo mkdir -p clickhouse-data clickhouse-logs postgres-data caddy-data caddy-config

Everything persistent lives under /opt/plausible. If you back up this directory and the database dumps, you can rebuild the stack on any other VPS.

Step 5: Generate the Secret Key

Plausible needs a long random secret to sign sessions. Generate it once and never change it:

openssl rand -base64 64

Copy the output. You will paste it into the env file in the next step.

While you're at it, generate a strong Postgres password too:

openssl rand -base64 32

Step 6: Write the Environment File

Create /opt/plausible/plausible-conf.env:

# Public URL of your dashboard - must match exactly, including https:// BASE_URL=https://stats.example.com # Long random key from `openssl rand -base64 64` SECRET_KEY_BASE=REPLACE_WITH_OPENSSL_RAND_BASE64_64 # Disable open registration after you create your first user DISABLE_REGISTRATION=false # Database connections (referenced by the compose file below) DATABASE_URL=postgres://plausible:REPLACE_WITH_POSTGRES_PASSWORD@plausible-db:5432/plausible_db CLICKHOUSE_DATABASE_URL=http://plausible-events-db:8123/plausible_events_db # Email - set these once you wire up SMTP, leave blank for now [email protected] SMTP_HOST_ADDR= SMTP_HOST_PORT=587 SMTP_USER_NAME= SMTP_USER_PWD=

Lock the file down so only root can read it:

sudo chmod 600 /opt/plausible/plausible-conf.env

You also need a small ClickHouse config to keep the logs sane on a single-node install. Create /opt/plausible/clickhouse-config.xml:

<clickhouse> <logger> <level>warning</level> <console>true</console> </logger> <query_thread_log remove="remove"/> <query_log remove="remove"/> <text_log remove="remove"/> <trace_log remove="remove"/> <metric_log remove="remove"/> <asynchronous_metric_log remove="remove"/> <session_log remove="remove"/> <part_log remove="remove"/> </clickhouse>

And /opt/plausible/clickhouse-user-config.xml:

<clickhouse> <profiles> <default> <log_queries>0</log_queries> <log_query_threads>0</log_query_threads> </default> </profiles> </clickhouse>

These two files keep ClickHouse from filling the disk with verbose logs over time.

Step 7: Write the Compose File

Create /opt/plausible/docker-compose.yml:

services: plausible-db: image: postgres:16 container_name: plausible-db restart: unless-stopped environment: POSTGRES_USER: plausible POSTGRES_PASSWORD: REPLACE_WITH_POSTGRES_PASSWORD POSTGRES_DB: plausible_db volumes: - ./postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U plausible -d plausible_db"] interval: 10s timeout: 5s retries: 5 networks: - plausible-net plausible-events-db: image: clickhouse/clickhouse-server:24.12-alpine container_name: plausible-events-db restart: unless-stopped volumes: - ./clickhouse-data:/var/lib/clickhouse - ./clickhouse-logs:/var/log/clickhouse-server - ./clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro - ./clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro ulimits: nofile: soft: 262144 hard: 262144 networks: - plausible-net plausible: image: ghcr.io/plausible/community-edition:v2.1.4 container_name: plausible restart: unless-stopped depends_on: plausible-db: condition: service_healthy plausible-events-db: condition: service_started env_file: - plausible-conf.env command: > sh -c "/entrypoint.sh db migrate && /entrypoint.sh run" networks: - plausible-net caddy: image: caddy:2 container_name: plausible-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - plausible-net networks: plausible-net:

A few notes:

  • The Postgres password appears twice: once in the compose environment block and once inside DATABASE_URL in plausible-conf.env. They must match.
  • Plausible runs the database migrations on every boot via the command block. That's safe and keeps the image generic across releases.
  • Container ports stay internal. Caddy reaches Plausible over the Docker network on port 8000.

Step 8: Write the Caddyfile

Create /opt/plausible/Caddyfile:

stats.example.com { encode zstd gzip reverse_proxy plausible:8000 }

Caddy will issue and renew a Let's Encrypt certificate automatically.

Step 9: Start the Stack

cd /opt/plausible sudo docker compose up -d sudo docker compose logs -f

Wait for three things in the logs:

  1. ClickHouse reports Ready for connections
  2. Plausible runs its migrations and prints Running PlausibleWeb.Endpoint
  3. Caddy issues a certificate for stats.example.com

Then open https://stats.example.com in a browser. You should land on the Plausible signup page.

Step 10: Create the First Admin and Lock Down Signups

The first account you create through the signup form becomes the owner. Sign up, verify your email if you wired up SMTP, then close the door behind you:

  1. Edit /opt/plausible/plausible-conf.env
  2. Set DISABLE_REGISTRATION=true
  3. Restart Plausible:
sudo docker compose up -d plausible

To invite teammates later, use the in-app invitation flow from the team settings page.

Take a moment now to copy your `SECRET_KEY_BASE` and the Postgres password into your password manager. Losing the secret key means every signed token in your database becomes invalid.

Step 11: Add a Site and Embed the Script

Inside the Plausible dashboard:

  1. Click Add a site
  2. Enter the domain you want to track (for example mywebsite.com)
  3. Pick a timezone
  4. Plausible shows you a one-line snippet to paste into your site's <head>

The snippet looks like this:

<script defer data-domain="mywebsite.com" src="https://stats.example.com/js/script.js"></script>

That's the entire integration. No cookie banner, no consent flow, no client identifiers. Page views and unique visitors are derived from a salted, daily-rotated hash of IP plus user agent and never stored.

If you want custom events (signups, button clicks, downloads), use the extended script and call plausible('Signup') from your app:

<script defer data-domain="mywebsite.com" src="https://stats.example.com/js/script.tagged-events.js"></script> <script> window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }; </script>

Then trigger events from JavaScript:

plausible('Signup', { props: { plan: 'pro' } });

Step 12: Back Up Postgres and ClickHouse

Plausible splits its data across two databases. You need to back up both.

Create /usr/local/bin/plausible-backup.sh:

#!/usr/bin/env bash set -euo pipefail BACKUP_DIR="/var/backups/plausible" DATE="$(date +%F)" mkdir -p "$BACKUP_DIR" # Postgres - app data, users, sites, settings docker exec plausible-db \ pg_dump -U plausible -d plausible_db \ | gzip > "$BACKUP_DIR/plausible-db-$DATE.sql.gz" # ClickHouse - the events table is the only one that matters docker exec plausible-events-db \ clickhouse-client --query="BACKUP DATABASE plausible_events_db TO File('/var/lib/clickhouse/backup-$DATE.zip')" docker cp "plausible-events-db:/var/lib/clickhouse/backup-$DATE.zip" \ "$BACKUP_DIR/clickhouse-$DATE.zip" docker exec plausible-events-db \ rm -f "/var/lib/clickhouse/backup-$DATE.zip" # Keep the env file too - it has the SECRET_KEY_BASE cp /opt/plausible/plausible-conf.env "$BACKUP_DIR/plausible-conf-$DATE.env" # Prune anything older than 14 days find "$BACKUP_DIR" -mtime +14 -delete

Make it executable and schedule a daily run:

sudo chmod +x /usr/local/bin/plausible-backup.sh echo "15 3 * * * root /usr/local/bin/plausible-backup.sh" | \ sudo tee /etc/cron.d/plausible-backup

For off-site safety, sync /var/backups/plausible to S3, Backblaze B2, or another VPS using rclone on the same schedule.

Step 13: Upgrade Plausible Safely

The Plausible CE team tags releases on the ghcr.io/plausible/community-edition image. Pin to a specific tag in docker-compose.yml (the example above uses v2.1.4). To upgrade:

cd /opt/plausible # Take a fresh backup first sudo /usr/local/bin/plausible-backup.sh # Bump the image tag in docker-compose.yml, then: sudo docker compose pull sudo docker compose up -d

Plausible runs any pending migrations automatically on boot. If something looks off afterwards, restore from the dump and roll the tag back.

Troubleshooting

ClickHouse won't start. The container logs Cannot allocate memory or Too many open files. Either bump the VPS RAM to at least 2 GB or set the nofile ulimit shown in the compose file. On older kernels you may also need sysctl -w vm.overcommit_memory=1 on the host.

Plausible loops on BASE_URL mismatch. The dashboard shows a redirect loop or the cookie domain is wrong. BASE_URL must be the exact public URL with https:// and no trailing slash, and must match the hostname in Caddyfile.

Tracking script blocked by ad-blockers. uBlock and similar tools block well-known analytics paths. Either accept the small undercount, or proxy the script behind your own domain. See "Going Further" below.

Caddy fails to issue a certificate. DNS hasn't propagated, or ports 80/443 are blocked. Run dig +short stats.example.com and verify the firewall.

Sessions vanish after a restart. SECRET_KEY_BASE changed between boots. Pin it in plausible-conf.env once and never edit it again. Restore the value from your backup if it's been overwritten.

Going Further

Proxy the script through your own domain. Ad-blockers ignore first-party paths. Add a route in your existing app (or a Caddy site block) that proxies /p/script.js and /p/api/event to your Plausible instance, then change data-domain to point at that path. Plausible's docs cover the proxy headers required.

Add custom events for funnels. Plausible's tagged-events script lets you track signups, downloads, and button clicks without adding any personal data. Combine that with Plausible's Goals and Funnels view for a real conversion picture.

Try alternatives if Plausible doesn't fit. Goatcounter is a single-binary, single-database alternative with a smaller feature set. Umami is closer in spirit to Plausible but also free for self-hosters and runs on Postgres or MySQL.

Tighten access with a VPN. If your dashboard only needs to be reachable by you and your team, expose it on a Tailscale or WireGuard network rather than the public internet. Pair this with our Tailscale guide to lock the dashboard behind your tailnet while keeping the tracking endpoint public.

That's it. You now own every byte of your analytics data, your visitors stay anonymous by default, and there is no cookie banner to apologize for.


Self-hosting Plausible, ClickHouse, and Postgres on a single box is exactly the kind of workload our Linux VPS plans are tuned for. NVMe storage, IPv6, and snapshots come standard. See the options.