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.
stats.example.com at your serverdocker-compose.ymlSECRET_KEY_BASE with openssl rand -base64 64Total time: about 20 minutes.
80 and 443 open to the internetClickHouse is the heaviest part of the stack. On a 1 GB box it works but feels tight. 2 GB is the realistic floor.
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.
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
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.
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.
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
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.
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:
environment block and once inside DATABASE_URL in plausible-conf.env. They must match.command block. That's safe and keeps the image generic across releases.8000.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.
cd /opt/plausible
sudo docker compose up -d
sudo docker compose logs -f
Wait for three things in the logs:
Ready for connectionsRunning PlausibleWeb.Endpointstats.example.comThen open https://stats.example.com in a browser. You should land on the Plausible signup page.
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:
/opt/plausible/plausible-conf.envDISABLE_REGISTRATION=truesudo docker compose up -d plausible
To invite teammates later, use the in-app invitation flow from the team settings page.
Inside the Plausible dashboard:
mywebsite.com)<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' } });
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.
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.
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.
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.