Self-Host Umami Analytics on Liquid Web
TL;DR
- Umami: MIT-licensed web analytics. ~38.7k GitHub stars, v3.3.1 (2026-08-20)
- Stack: Next.js app + PostgreSQL 16 + Caddy, measured 400 MB idle on 4 vCPU / 8 GB
- The lightest self-hosted analytics option in this guide series: comfortable headroom on Liquid Web's entry 4 GB VPS, no ClickHouse required
- Google Analytics: free but requires cookie consent; Umami on Liquid Web: from $36/mo (4 GB VPS, promo rate), no cookies
Updated 2026-09-09: corrected the Liquid Web VPS tier throughout this guide — Liquid Web's Managed VPS line starts at 4 GB, not 2 GB, so the entry price is $36/mo (promo) rather than $14/mo. Also refreshed Umami's GitHub stats and Umami Cloud's pricing tiers below.
Umami (github.com/umami-software/umami) is a privacy-focused web analytics tool built in Next.js with PostgreSQL as its only database. No ClickHouse, no Redis, no message queue, just a single app container and a database. That simplicity makes it the easiest self-hosted analytics option to operate: one image to update, one database to back up.
Umami vs Plausible: both are cookie-free, GDPR-friendly, and self-hostable. Umami uses only PostgreSQL (lighter, simpler), while Plausible uses PostgreSQL + ClickHouse (heavier, but better at large-scale aggregation queries and more detailed referrer/UTM reporting). For most sites under 1M pageviews/mo, Umami's performance is indistinguishable. Pick Umami if you want the simpler stack; pick Plausible if you need detailed funnel and UTM reporting. For a third option built for product analytics rather than pageview counting, see the self-hosted analytics hub.
Prerequisites
- A Liquid Web 4 GB Managed VPS (or larger) — Liquid Web's entry tier, from $36/mo promo / $72/mo regular (verify pricing)
- Docker Engine 25+ and Docker Compose V2
- A domain with its A record pointing to the VPS IP
- About 20 minutes
The complete docker-compose.yml
# umami/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 8 GB RAM)
# Idle: 400 MB RAM | Peak: 650 MB (200 pageviews/min, 5 sites)
# Source: https://github.com/umami-software/umami — adapted for production
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
timeout: 5s
retries: 5
networks:
- umami_net
umami:
image: ghcr.io/umami-software/umami:postgresql-${UMAMI_VERSION:-v2.14.0}
restart: unless-stopped
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: ${APP_SECRET}
# Optional: restrict tracking to specific hostnames
# ALLOWED_FRAME_URLS: https://yourdomain.com
# Rename the tracking script to reduce ad-blocker blocking
TRACKER_SCRIPT_NAME: ${TRACKER_SCRIPT_NAME:-script}
depends_on:
db:
condition: service_healthy
networks:
- umami_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat || exit 1"]
interval: 15s
timeout: 5s
retries: 5
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
umami:
condition: service_healthy
networks:
- umami_net
volumes:
db_data:
caddy_data:
caddy_config:
networks:
umami_net:
driver: bridge
Caddyfile
# Caddyfile — Umami reverse proxy
analytics.yourdomain.com {
reverse_proxy umami:3000 {
health_uri /api/heartbeat
health_interval 15s
}
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
}
}
.env file
# .env — keep out of version control
# Umami version — check github.com/umami-software/umami/releases
UMAMI_VERSION=v2.14.0
# PostgreSQL
POSTGRES_PASSWORD=change-me-strong-password
# App secret — generate with: openssl rand -hex 32
APP_SECRET=
# Optional: rename tracker script to bypass ad blockers (e.g. "tracker", "analytics", "a")
TRACKER_SCRIPT_NAME=script
First-run setup
# 1. Generate the app secret
openssl rand -hex 32 # paste into .env
# 2. Start the database
docker compose up -d db
docker compose ps db # wait until healthy
# 3. Start Umami (runs migrations automatically on first start)
docker compose up -d umami
# 4. Start Caddy
docker compose up -d caddy
# 5. Verify all services
docker compose ps
# 6. Visit https://analytics.yourdomain.com
# Default login: admin / umami
# IMPORTANT: Change the password immediately after login
# 7. Add your first website:
# Settings → Websites → Add website → enter your domain
# Copy the tracking snippet
Add the tracking snippet
After adding your website in Umami, copy the tracking script from Settings → Websites → Edit:
<script async src="https://analytics.yourdomain.com/script.js"
data-website-id="your-website-uuid"></script>
Change the script name to reduce ad-blocker blocking. Set TRACKER_SCRIPT_NAME=au in .env and restart Umami. Your snippet becomes:
<script async src="https://analytics.yourdomain.com/au"
data-website-id="your-website-uuid"></script>
Track custom events:
// Track a custom event
umami.track('button-click', { label: 'hero-cta' });
// Track with page URL override
umami.track({ url: '/checkout', title: 'Checkout page' });
Verify the stack
# All services should be Up and healthy
docker compose ps
# Heartbeat check
curl -s https://analytics.yourdomain.com/api/heartbeat
# {"ok":true}
# Resource usage
docker stats --no-stream
Runtime footprint
Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM).
| Service | Idle RAM | Peak RAM (200 events/min) |
|---|---|---|
| umami (Next.js) | 230 MB | 450 MB |
| db (postgres 16) | 155 MB | 195 MB |
| caddy | 15 MB | 20 MB |
| Total | 400 MB | 665 MB |
This is the lightest analytics stack in this guide series. Liquid Web's entry-level 4 GB VPS has comfortable headroom for it. Combined with another light app — Listmonk at 512 MB idle — the pair still fits inside a single 4 GB VPS.
Upgrading Umami
Umami's latest release is v3.3.1 (2026-08-20); this guide is tested and pinned against v2.14.0. The
v3.0.0 release notes list one breaking
change: "Umami v3 no longer supports MySQL as a database option, only PostgreSQL" — which is what this
guide already uses. DATABASE_URL, DATABASE_TYPE, and APP_SECRET are all still current in Umami's
environment variable reference (checked 2026-09-09,
a v3-era page that documents variables through v3.3.0), so the upgrade path below applies to v3
releases too. Before jumping straight to v3, read the release notes for your specific version — some users have reported migration issues on non-empty databases going from v2 to v3.
# Check github.com/umami-software/umami/releases
# Update version in .env
UMAMI_VERSION=v3.3.1
# Pull new image
docker compose pull umami
# Stop Umami
docker compose stop umami
# Start with new version (migrations run on startup)
docker compose up -d umami
# Verify
docker compose ps
docker compose logs umami --tail 20
Cost vs Google Analytics / Umami Cloud
| GA4 (free) | Umami Cloud | Umami on Liquid Web | |
|---|---|---|---|
| Monthly cost | $0 | Free (Hobby) or $20/mo (Pro) | from $36/mo VPS (4 GB, promo; $72/mo regular) |
| Event limit* | Unlimited | 100K/mo free, 1M/mo on Pro | Unlimited |
| Cookie consent needed? | Yes (EU) | No | No |
| Data ownership | Google's | Umami's | Yours |
| Session replay | ✗ | ✗ | ✗ |
| Custom events | ✓ | ✓ | ✓ |
| API access | ✓ | Pro plan only | ✓ |
| License | Proprietary | N/A | MIT |
*Umami Cloud meters events, not pageviews — each pageview counts as one event, and so does every custom event you track. A 100K-event free tier is a smaller allowance than a 100K-pageview cap would be.
Prices verified 2026-09-09 at umami.is/pricing and liquidweb.com/vps-hosting/managed-vps/ and are subject to change — re-check before budgeting.
When this isn't right for you
- You need detailed referrer and UTM campaign analytics. Plausible has more granular referrer data, top-of-funnel source breakdowns, and richer UTM parameter tracking. Umami's UTM support is basic by comparison.
- You need session replay or heatmaps. Umami is a pageview/event counter. For session-level detail, PostHog self-hosted (PostHog's own docs call for 16 GB RAM minimum) or Microsoft Clarity (free, cloud-only) are the options.
- You track millions of pageviews per day. PostgreSQL handles Umami's analytics queries well at moderate scale, but at very high event volumes (tens of millions/day), query performance degrades. Plausible's ClickHouse backend handles high volumes more efficiently.
- Your site needs embeddable public dashboards. Umami supports shared public dashboard links, but embedding in iframes is restricted by the
X-Frame-Options: SAMEORIGINheader (set in this guide's Caddyfile). ChangeSAMEORIGINtoALLOWALLin the Caddyfile if you need to embed the dashboard in an external page.
Exit strategy
# Export all stats as CSV from the dashboard:
# Website → Stats → Export
# Access raw event data via the Umami API
curl -H "x-umami-api-key: YOUR_API_KEY" \
"https://analytics.yourdomain.com/api/websites/WEBSITE_ID/events" \
> events.json
# Dump the PostgreSQL database
docker compose exec db pg_dump -U umami umami > umami_export.sql
Umami stores everything in PostgreSQL: all events, user accounts, website configurations, and session data. A standard pg_dump migrates the full dataset.
Both are cookie-free, GDPR-friendly analytics tools that don't require cookie consent banners. The technical privacy guarantees are similar: neither stores IP addresses or uses persistent identifiers. The main difference is company structure: Plausible is built by Plausible Analytics Ltd., Umami by Umami Software Inc. Both are self-hostable under open-source licenses (AGPL-3.0 for Plausible, MIT for Umami). On self-hosted, data stays entirely on your VPS for both.
Yes, unlimited websites on a self-hosted instance, each with its own tracking ID and separate dashboard. No per-site pricing, no per-site seat limits. Add sites in Settings → Websites → Add website.
Yes. Umami tracks route changes in SPAs automatically when the history API is used (pushState navigation). For hash-based routing (`#/page`), you need to call `umami.track()` manually on route change. The official Umami JavaScript library also works with the React, Next.js, and Vue community packages listed in the Umami integrations docs.
Umami supports shared public URLs for each website's dashboard. In the website settings, toggle 'Share URL' on, and this generates a public link anyone can view without logging in. The link shows the same stats dashboard but has no ability to edit settings or view other sites. Useful for sharing analytics with clients who don't need Umami accounts.
Common mistakes and fixes
Umami login page loads but credentials are rejected.
On first run, Umami creates an admin user with username `admin` and password `umami`. Log in with those defaults and change the password immediately in Settings → Profile. If you've already changed the password and forgotten it, reset it via: `docker compose exec db psql -U umami -d umami -c "UPDATE users SET password = crypt('newpassword', gen_salt('bf')) WHERE username = 'admin';"` (requires pgcrypto extension, included in the init SQL).
Tracking script loads but pageviews don't appear in the dashboard.
Two common causes: (1) The `data-website-id` in your tracking script must match the website ID shown in Umami Settings → Websites → Edit. (2) Umami's script is blocked by ad blockers. Test in an incognito window without extensions. To reduce ad-blocker blocking, proxy the Umami script through your main domain. Umami supports a proxy mode: rename `/script.js` to a custom path via the `TRACKER_SCRIPT_NAME` environment variable.
Umami container exits on startup with 'database migration failed'.
This usually means the postgres container wasn't fully healthy before Umami started. The `depends_on: db: condition: service_healthy` in this guide's compose file prevents this race condition. If you see it on a manual restart, run: `docker compose restart umami`. If it persists, check that DATABASE_URL uses `db` as the host (not `localhost`) and that the postgres password in DATABASE_URL matches POSTGRES_PASSWORD in .env.