the day after migrating 11 production apps to Dokploy, i finally fixed the thing i had been putting off for years: i had no idea who was visiting any of my sites. a portfolio, a technical blog, marketplaces, a Formula 1 telemetry app, all serving traffic, none of them measured. the reflex answer is "add Google Analytics", and i think that reflex is wrong. here's the path i took instead, and the exact setup.
why not Google Analytics
i dropped GA4 for four reasons. it tracks users with cookies, which drags in GDPR and ePrivacy consent requirements, so a personal portfolio ends up wearing a banner it never needed, and post-Schrems II several EU regulators ruled its US data transfers unlawful outright. the data lives on Google's servers under Google's retention policies, a strange trade for measuring my own sites. the tag weighs tens of kilobytes and sits on every ad-blocker list, so you pay page weight for partial data. and GA4 is an enterprise tool in a free-tier costume: i want pageviews, referrers, and countries, not a BigQuery export pipeline.
why Umami, and why self-hosted
Umami is an MIT-licensed Next.js + Postgres app that does exactly the boring, useful part of analytics: pageviews, visitors, referrers, countries, devices, browsers, and custom events. what sold it:
- no cookies and no personal data stored, so no consent banner needed. visitors are counted with a rotating-salt hash, so no persistent identifier ever reaches the browser and nobody gets tracked across sites or sessions.
- the tracker is tiny. mine serves at 4,655 bytes, while GA4's tag is an order of magnitude heavier.
- one instance, unlimited sites. everything reports into a single dashboard with per-site tokens.
- it's a stack i already run. Next.js and Postgres 16 have the same shape as half the apps on this VPS (virtual private server). nothing exotic to learn or babysit.
the self-host versus cloud question answers itself when you already operate a server. Umami Cloud's free tier caps events and retention, and the Pro tier is $20/month. self-hosted costs $0 extra on an existing VPS, and mine measured about 260 MiB of RAM total: 221 MiB for the app and 40 MiB for its dedicated Postgres. the data never leaves the box, and "one more compose stack" is precisely what a PaaS-managed (platform as a service) server is for.
honorable mentions: Plausible is excellent but AGPL-licensed (GNU Affero General Public License) and built on Elixir and ClickHouse, which makes it heavier to run and outside my stack. Matomo is the full-featured veteran but a PHP monolith with a cookies-by-default history. GoatCounter is delightfully tiny but more minimal than i wanted. Umami hit the middle.
the setup
Umami's official image plus an embedded Postgres, as one compose stack. this is the entire deployment:
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@umami-db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: ${APP_SECRET}
HOSTNAME: 0.0.0.0
depends_on:
umami-db:
condition: service_healthy
restart: unless-stopped
umami-db:
image: postgres:16-alpine
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"]
interval: 5s
retries: 10
restart: unless-stopped
volumes:
umami-db-data:two details earn their place:
HOSTNAME: 0.0.0.0: Umami is a Next.js standalone app, and standalone Next binds to the container hostname's interface by default. the app logs "Ready", your reverse proxy gets connection-refused, and users get a 502. this exact bug cost me debugging time during the Dokploy migration; now it's muscle memory.- the
${POSTGRES_PASSWORD}and${APP_SECRET}interpolation keeps secrets out of the compose file. generate both withopenssl rand -hex 32and put them in the env store of whatever runs your compose (Dokploy's Environment tab, or a.envnext to the file).
on Dokploy the whole thing is: new project, add a Compose service, set the source to "Raw", paste the YAML, set the two env vars, and deploy. attach the domain (analytics.chrisdevcode.com, container port 3000, Let's Encrypt), then redeploy. Dokploy injects Traefik routing labels at deploy time, and a plain "deploy" does nothing if the config hash hasn't changed. from project.create to a live Let's Encrypt certificate took seven minutes, all through API calls. if you run plain docker-compose, it's the same file; just put your favorite reverse proxy in front of port 3000.
one Cloudflare note: keep the analytics hostname at the third level (analytics.example.com). Universal SSL only covers one subdomain level, a lesson the migration taught me via a fourth-level domain that fails TLS (Transport Layer Security) at the Cloudflare edge to this day.
day-zero hardening: the default login is a public door
Umami ships with admin / umami. the moment your domain resolves, that's a public admin panel with known credentials, and "i'll change it after i look around" is how these things stay unchanged for months. i rotated it via Umami's own API before ever opening the login page in a browser:
# inside the container, or against the domain:
TOKEN=$(curl -s -X POST https://analytics.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"umami"}' | jq -r .token)
curl -s -X POST https://analytics.example.com/api/me/password \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"currentPassword\":\"umami\",\"newPassword\":\"$(openssl rand -base64 18)\"}"store the result in a root-only file (chmod 600) the way you'd store any server credential. worth knowing: self-hosted Umami has no email support at all. there are no SMTP (Simple Mail Transfer Protocol) settings and no password-reset flow, so if you lose the admin password you're resetting it through the database. the credentials file is the recovery mechanism; treat it like one.
wiring up the sites
each site gets one line in its <head>:
<script defer src="https://analytics.example.com/script.js"
data-website-id="1e574457-…"></script>add the website in Umami's settings, copy its ID, ship the line, and pageviews appear in the dashboard within seconds. no cookie banner, no consent modal, nothing else to do. ad-blockers will still eat some fraction of script.js requests; Umami's TRACKER_SCRIPT_NAME env var lets you serve the script under an innocuous name if that fraction matters to you. for a portfolio and a blog, i accept the undercount.
daily reports in Telegram, because self-hosted Umami can't email(change my mind ... please)
Umami Cloud sells emailed reports; self-hosted has nothing. it does have a clean REST API though, and my server already had a Telegram bot wired for deploy and backup notifications. a roughly 100-line Python script (standard library only) logs into /api/auth/login, iterates /api/websites, and pulls /api/websites/{id}/stats for the last day. the API hands back the previous period for free, so day-over-day deltas are one subtraction. the script posts the summary through the bot, and a cron entry runs it every morning:
0 4 * * * root /usr/bin/python3 /root/umami-telegram-report.py dailythe one genuine gotcha: the script's API calls came back 403. Cloudflare was bot-blocking POST requests to my own domain, originating from my own server. the fix was to stop leaving the building entirely: connect to 127.0.0.1:443 and set the TLS SNI (Server Name Indication) to the real hostname, so local Traefik routes the request and the Let's Encrypt certificate still verifies:
sock = socket.create_connection(("127.0.0.1", 443), timeout)
ctx = ssl.create_default_context()
tls = ctx.wrap_socket(sock, server_hostname="analytics.example.com")full TLS verification, zero Cloudflare, and the script keeps working even if i tighten the WAF (web application firewall) later. new sites need no report changes; the script iterates whatever exists.
the scoreboard
| what | number |
|---|---|
| time to live (project create to certificate) | ~7 minutes |
| marginal cost | $0/month |
| memory footprint | 221 MiB app + 40 MiB Postgres |
| tracker weight | 4,655 bytes |
| cookie banners required | 0 |
| sites on one instance | all of them |lessons
- "why not Google Analytics" has a real answer (consent obligations, data ownership, page weight), and it's worth writing it down before muscle memory pastes the
gtagsnippet. - treat default credentials as an outage. rotate before or at the moment of exposure, via API if the UI is slower.
- self-hosting's missing features are API opportunities. no email support became a Telegram report i like better than email.
- your own WAF doesn't know it's you. server-to-self API calls through a CDN (content delivery network) proxy will eventually hit bot rules; loop back through the local reverse proxy with proper SNI instead.
- match new services to stacks you already operate. Umami won on merits, but it also won because Next.js + Postgres on this server is a solved problem with existing backup and deploy patterns.
if you run even one site on a VPS, give yourself an afternoon and stand up your own analytics. watching pageviews land on a dashboard that answers to nobody but you is a good feeling. go count some visitors!
Comments