VPS Tutorials
Self-Hosted Umami Website Analytics on a VPS (2026): Docker Compose, PostgreSQL, Caddy HTTPS and Anti-Ad-Blocker Configuration
This tutorial explains how to self-host Umami on a VPS and build a lightweight, privacy-friendly website traffic analytics system. It covers choosing between Umami and Plausible, VPS configuration, Docker Compose, PostgreSQL, Caddy HTTPS, the tracking script, anti-ad-blocker measures, backups, upgrades, security hardening and common troubleshooting.

Self-Hosted Umami Website Analytics on a VPS (2026): Docker Compose, PostgreSQL, Caddy HTTPS and Anti-Ad-Blocker Configuration
Many site owners start out using Google Analytics directly. It is free, powerful, well documented, but it has a few unavoidable problems: it loads slowly, it gets blocked by ad blockers, it creates privacy compliance pressure, and the data always lives with someone else.
If all you want to know is how many visits your site gets each day, where visitors come from, and which pages are more popular, you don't really need a heavy analytics platform. Umami is more of a lightweight self-hosted website analytics tool: the interface is clean, it doesn't rely on cookies, it respects privacy by default, and it runs on a small VPS.
This article is about how to deploy Umami on a VPS. We'll use Docker Compose to run Umami and PostgreSQL, then use Caddy to obtain HTTPS automatically. Later we'll also cover the tracking script, anti-ad-blocker measures, backups, upgrades and common issues.
Common choices for self-hosted website analytics include Umami, Plausible, Matomo and others.
Matomo is very complete in features, but relatively heavier, more like a full analytics platform. Plausible's experience is very good, but the self-hosted Community Edition requires PostgreSQL plus ClickHouse, which means higher resource usage and more complex backups.
Umami's advantages are straightforward:
- Simple deployment; the application container plus PostgreSQL is enough.
- Friendly to small VPS instances; 1GB of memory can run a lightweight site.
- A clean backend; no need to learn complex reports.
- Supports multi-site analytics, suitable for individual site owners and small teams.
- You can customize the tracking script name to reduce the chance of being caught by ad blockers.
- Data lives in your own database, so backups and migrations are under your control.
If your site already has very high traffic, or you strongly depend on ClickHouse-level query performance, Plausible is also worth considering. But for most VPS users, Umami is better suited to lightweight self-hosting, especially on budget-limited small machines.
Umami isn't heavy; what actually grows is the visit data in PostgreSQL.
| Scenario | Recommended configuration |
|---|---|
| Personal blog, tool site, low-traffic official site | 1 vCPU / 1GB RAM / 20GB SSD |
| Multiple sites, a few thousand to tens of thousands of PV per day | 2 vCPU / 2GB RAM / 40GB SSD |
| Team use, long-term retention of large amounts of historical data | 2-4 vCPU / 4GB RAM / 80GB+ SSD |
If you already have a VPS running Caddy, Nginx, Uptime Kuma, Vaultwarden or NocoDB, Umami can run alongside them as an additional service. But if that machine only has 512MB of memory, be careful: PostgreSQL plus other services may attract the OOM killer.
This article assumes you already have:
- Ubuntu 22.04 / 24.04 or Debian 12.
- Docker and Docker Compose V2.
- A domain, for example
analytics.example.com. - The domain's A record already points to the VPS public IP.
- Ports 80 and 443 open for Caddy to obtain HTTPS.
Check Docker:
docker version
docker compose version
If Docker isn't installed yet, install it first before continuing. This article focuses on Umami itself and won't repeat Docker basics.
Create the directory:
sudo mkdir -p /opt/umami
sudo chown -R "$USER":"$USER" /opt/umami
cd /opt/umami
mkdir -p backups caddy-data caddy-config
Generate two random secrets:
openssl rand -hex 32
openssl rand -base64 32
The first is used as APP_SECRET, the second as the PostgreSQL password. APP_SECRET must stay unchanged long-term; subsequent restarts, upgrades and migrations must keep using the same value; don't regenerate it on every deployment.
Create .env:
nano .env
Write the following content, replacing the domain and passwords with your own:
UMAMI_DOMAIN=analytics.example.com
POSTGRES_PASSWORD=replace_with_a_long_database_password
APP_SECRET=replace_with_openssl_rand_hex_32
TRACKER_SCRIPT_NAME=metrics.js
COLLECT_API_ENDPOINT=/api/web-events
A few variables explained:
UMAMI_DOMAIN: the domain used by the Caddy reverse proxy.POSTGRES_PASSWORD: the PostgreSQL database password.APP_SECRET: a random string Umami uses to protect authentication tokens; each instance should be different, and don't change it casually after going live.TRACKER_SCRIPT_NAME: change the default tracking script to a custom name to reduce the chance of being caught by ad blockers.COLLECT_API_ENDPOINT: change the default collection endpoint to a custom path, which can also reduce rule matching.
The .env file is a production secret; don't commit it to Git, and don't put it in a web-accessible directory.
Create docker-compose.yml:
nano docker-compose.yml
Write:
services:
umami:
image: ghcr.io/umami-software/umami:latest
container_name: umami
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
APP_SECRET: ${APP_SECRET}
TRACKER_SCRIPT_NAME: ${TRACKER_SCRIPT_NAME}
COLLECT_API_ENDPOINT: ${COLLECT_API_ENDPOINT}
DISABLE_TELEMETRY: "1"
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
db:
image: postgres:15-alpine
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
timeout: 5s
retries: 5
caddy:
image: caddy:2-alpine
container_name: umami-caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
depends_on:
- umami
volumes:
umami-db-data:
A few key points here:
umamiis bound only to127.0.0.1:3000; the outside world cannot access 3000 directly.- PostgreSQL doesn't map 5432; the database is used only inside the Docker network.
- What's actually exposed externally is Caddy's 80 and 443.
DISABLE_TELEMETRY=1is optional; enable it if you don't want to participate in anonymous telemetry.
A more stable approach for production is to pin the Umami image version rather than using latest long-term. For the first deployment you can use latest to get it running; for formal long-term use, it's recommended to check the current stable version on the Umami GitHub Release or image registry, then manually pin the tag.
Create Caddyfile:
nano Caddyfile
Write:
{$UMAMI_DOMAIN} {
encode zstd gzip
reverse_proxy umami:3000
}
Open the firewall:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
If you're using a cloud provider VPS, you also need to allow 80 and 443 in the security group. Many Caddy certificate failures ultimately come down to the cloud security group not allowing the ports.
Run:
cd /opt/umami
docker compose up -d
Check the containers:
docker compose ps
Check the logs:
docker compose logs -f --tail=100 umami
The first startup initializes the database; waiting a minute or two is normal. Open in your browser:
https://analytics.example.com
The default account is:
用户名:admin
密码:umami
Change the password immediately after the first login. Don't delay this step; using the default password on a public service is one of the most common security incidents.
After logging into the backend, click to add a website and fill in:
- Website name: for example
My Blog - Domain: for example
example.com
After saving, Umami generates a tracking snippet. It looks roughly like this:
<script defer src="https://analytics.example.com/metrics.js" data-website-id="your-website-id"></script>
Put it in your website's <head>. For most static blogs, WordPress themes and Next.js projects, just place it where it loads on every page.
If you only want the script to take effect on a specified domain, you can add data-domains:
<script defer src="https://analytics.example.com/metrics.js" data-website-id="your-website-id" data-domains="example.com,www.example.com"></script>
If you want to respect the browser's Do Not Track setting, you can add:
<script defer src="https://analytics.example.com/metrics.js" data-website-id="your-website-id" data-do-not-track="true"></script>
After visiting your website, go back to the Umami backend; data usually appears within seconds to tens of seconds.
Ad blockers often block obvious analytics script paths, such as analytics, track, script.js, /api/send and the like.
Umami provides two very practical variables:
TRACKER_SCRIPT_NAME=metrics.js
COLLECT_API_ENDPOINT=/api/web-events
This way the tracking script is no longer the default script.js, and the collection endpoint is no longer the default path. Changing only the script name is incomplete; it's best to change the collection endpoint as well. It can't guarantee 100% bypassing of all rules, but it's more stable than using the default paths directly.
After modifying .env, restart:
docker compose up -d
Then re-copy the tracking code generated in the backend, or manually change the script address in your website to the new path.
Note: don't deceive users or bypass explicit privacy choices for the sake of analytics. If a user enables Do Not Track, it's recommended to respect it.
If your analytics domain goes through the Cloudflare proxy, Umami may see the Cloudflare node IP instead of the visitor's real IP.
You can specify the client IP header in the environment variables:
CLIENT_IP_HEADER=CF-Connecting-IP
Then add to docker-compose.yml in umami.environment:
CLIENT_IP_HEADER: CF-Connecting-IP
Restart Umami after the change.
This configuration is only suitable when traffic genuinely passes through Cloudflare the whole way. If the origin IP can be bypassed with direct connections, don't blindly trust externally supplied CF-Connecting-IP headers; it's best to use a firewall or security group to allow only Cloudflare origin IPs to access 80/443.
If you don't use Cloudflare, or only use DNS resolution, you don't need to configure this variable.
Umami's core data is all in PostgreSQL. The most important thing is to export the database regularly.
Create a backup script:
nano backup-umami.sh
Write:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/umami"
BACKUP_DIR="$APP_DIR/backups"
DATE="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR/$DATE"
cd "$APP_DIR"
docker exec umami-db pg_dump -U umami umami | gzip > "$BACKUP_DIR/$DATE/umami.sql.gz"
tar czf "$BACKUP_DIR/$DATE/umami-config.tar.gz" docker-compose.yml Caddyfile .env
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} \;
echo "Backup saved to $BACKUP_DIR/$DATE"
Add execute permission and test:
chmod +x backup-umami.sh
./backup-umami.sh
ls -lh backups
Add a scheduled task:
crontab -e
Back up every day at 3 AM:
0 3 * * * /opt/umami/backup-umami.sh >> /opt/umami/backups/backup.log 2>&1
Local backups only protect against mistakes, not disk failure. A more reliable approach is to also sync to object storage, a NAS or another VPS using rclone.
Before restoring, prepare the same docker-compose.yml, Caddyfile and .env. The restore commands below assume the target database is a brand-new empty database; if you want to overwrite an existing instance, stop the application first and confirm you want to delete the old data, then rebuild the database or volume, to avoid mixing old and new data.
Start the database:
cd /opt/umami
docker compose up -d db
sleep 20
Restore the SQL:
gunzip -c umami.sql.gz | docker exec -i umami-db psql -U umami umami
Then start all services:
docker compose up -d
After restoring, check:
- Whether you can log into the backend.
- Whether the website list exists.
- Whether historical visit data is still there.
- Whether the tracking script still uses the current domain.
After writing the backup script, it's best to use a test VPS to rehearse a restore once. A backup without a restore rehearsal is only psychological comfort.
Back up before upgrading:
cd /opt/umami
./backup-umami.sh
If using latest:
docker compose pull
docker compose up -d
Check the logs after upgrading:
docker compose logs --tail=100 umami
Umami's official documentation notes that after major version upgrades, PostgreSQL statistics may become stale, causing slow queries on large instances. After upgrading, you can enter the database and run:
docker exec -it umami-db psql -U umami -d umami
Then run:
ANALYZE;
This command refreshes the PostgreSQL query planner statistics and can usually be run safely online.
At minimum, do these:
- Change the default password immediately after logging in.
- Don't expose 3000 and 5432 to the public internet.
- Use random strong passwords for
APP_SECRETand the database password. - Don't put
.envand backup files in a web directory. - Only open 80, 443 and the necessary SSH ports.
- Regularly update the system and container images.
- If the backend is only for yourself, you can add another layer such as Cloudflare Access, Tailscale or WireGuard.
- Delete departed members, outsourced accounts and temporary accounts promptly.
If you share Umami with a team, also pay attention to permissions and site isolation. Don't have everyone use the same administrator account.
First check container status:
docker compose ps
Then check Caddy logs:
docker compose logs --tail=100 caddy
Key things to check:
- Whether the domain A record is correct.
- Whether the VPS firewall allows 80/443.
- Whether the cloud security group allows 80/443.
- Whether Cloudflare has the wrong proxy mode enabled.
When requesting a certificate for the first time, it's recommended to set Cloudflare to "DNS only" first, then enable the proxy as needed after it works.
Check the application logs:
docker compose logs --tail=100 umami
Common causes:
- The
DATABASE_URLpassword doesn't match the PostgreSQL initialization password. .envwas changed, but the old database volume still has the old password.- The
dbcontainer isn't healthy yet. - Indentation error in the Compose file.
If you changed the database password after the first startup, the PostgreSQL volume won't change automatically. In a test environment you can delete the volume and start over; in production you need to enter the database and change the password.
Open the browser developer tools and check Network:
- Whether
metrics.jsloads successfully. - Whether the collection endpoint returns 200 or 204.
- Whether it's blocked by an ad blocker.
- Whether the website's CSP forbids loading external scripts.
- Whether
data-website-idwas copied incorrectly.
If you used data-domains, confirm the domain is written correctly, including www and non-www.
Possible causes:
- Ad blockers blocked some requests.
- Users enabled Do Not Track.
- Your script is only on some pages.
- SPA routing or frontend framework integration is incorrect.
- The CDN cached the wrong script.
Website analytics can never be 100% accurate. It's better for looking at trends, not as financial-grade absolute numbers.
First check container resources:
docker stats
If PostgreSQL queries are slow, you can run the command mentioned earlier:
ANALYZE;
For instances with very high traffic, also consider adding VPS memory, shortening data retention, or migrating to a stronger database server.
Simple conclusion:
- Small sites, personal blogs, tool sites, small teams: prefer Umami.
- High traffic, very focused on beautiful reports and email reports: consider Plausible.
- Want something lighter, easier to back up, less maintenance: prefer Umami.
- Already have a VPS with 4GB or more and can accept ClickHouse: Plausible is also good.
Plausible's self-hosted version requires PostgreSQL and ClickHouse. ClickHouse has strong query performance, but for a small VPS it's an extra burden. Backups aren't just exporting one PostgreSQL either.
Umami's advantage is being light enough and simple enough. For most site owners, getting analytics running, backups done and HTTPS configured is more important than pursuing a more complex analytics system.
Check before formal use:
- The domain already resolves to the VPS.
- 80/443 are allowed in the firewall and security group.
- Umami is bound only to
127.0.0.1:3000. - PostgreSQL doesn't expose 5432.
- The passwords in
.envandAPP_SECREThave been replaced. -
APP_SECREThas been recorded in a password manager and won't be regenerated on future upgrades. - You have logged into the backend and changed the default password.
- You have added your first website.
- The tracking script has been placed in the website's
<head>. - The Network panel shows collection requests succeeding.
- The backup script has run successfully.
- You have done at least one restore rehearsal.
Umami is best at answering these questions: did visits go up today, where does search traffic come from, which article is most popular, which country or region visits the most, and did the recent promotion have any effect.
It isn't suited for very heavy user behavior analysis, and it shouldn't be treated as the only source of business data. Website analytics is essentially a trend tool, not absolute truth.
If you already have a VPS, want to get away from Google Analytics, and don't want to maintain a heavier analytics stack like ClickHouse, Umami is a very suitable choice. It's simple to deploy, has clear backups, and uses few resources; for traffic analytics on blogs, tool sites, official sites and small team projects, it's just right.