The 35-Watt Roommate (Part 8): Umami Analytics, or How a Password with Special Characters Broke Everything
Google Analytics annoys me. Not because it’s bad, but because for my small projects I don’t need a cookie banner, don’t want GDPR headaches, and really just want to know: does anyone actually visit my site? And if so, where do they come from?
Until now, I solved this through Grafana. In the monitoring post, I had an Nginx dashboard showing me hits, status codes, and origin countries. It works, but it feels like analytics through a keyhole. I can see that someone requested /lotto6aus49, but not whether they looked at other pages afterwards. No sessions, no time on page, no referrers in context. Raw access logs made pretty, nothing more.
So: Umami. Open source, privacy-first, no cookies, GDPR-compliant out of the box. Sounds like a ten-minute job. It wasn’t.
The Plan
Umami needs two things: a PostgreSQL database and the app itself. Both as Docker containers, behind Traefik, secrets from files. Just like every other service in my setup. Routine, supposedly.
The target architecture:
Problem 1: The Entrypoint Dance
Umami’s Docker image is based on Next.js in standalone mode. The official CMD is npm run start-docker, which runs Prisma migrations and then starts the server. Simple enough.
But I didn’t want to write database credentials as environment variables in the compose file (they’d show up in cleartext in Portainer). Instead, I wanted to inject them via Docker Secrets. That means I need a custom entrypoint that reads the secrets and exports them as environment variables.
First attempt with a shell wrapper:
entrypoint:
- /bin/sh
- -ec
- |
set -eu
export DATABASE_URL="postgresql://umami:$(cat /run/secrets/db_password)@umami-db:5432/umami"
export APP_SECRET="$(cat /run/secrets/app_secret)"
exec npm run start-docker
Result: node_modules/.bin/next: not found. The Umami image is a Next.js standalone build. There’s no node_modules/.bin/next. The standalone output only has server.js at the root.
Second attempt: exec node server.js instead of npm run start-docker. Better, but then Prisma migrations don’t run on first start.
Problem 2: The Shell That Couldn’t Read
Third attempt: shell wrapper with npx prisma migrate deploy before the server start. Sounds reasonable. Looks reasonable. Only: nothing happened. DATABASE_URL was empty, APP_SECRET was empty.
The problem: the Umami image runs as user nextjs (UID 1001). That user apparently can’t use /bin/sh in the expected way, or command substitution with $(cat ...) doesn’t work as expected in the context of the Alpine-based Node image.
The solution: write the entire entrypoint in Node. No shell wrapper, straight JavaScript:
entrypoint:
- node
- -e
- |
const { readFileSync } = require('fs');
const { execSync } = require('child_process');
const dbPass = readFileSync('/run/secrets/umami_db_password', 'utf8').trim();
const appSecret = readFileSync('/run/secrets/umami_app_secret', 'utf8').trim();
process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';
process.env.APP_SECRET = appSecret;
process.env.DISABLE_TELEMETRY = '1';
execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
require('./server.js');
Node can read the secrets. Node can set the environment variables. Node can start the server. No shell in between swallowing anything.
Problem 3: The Password with Special Characters
My generated database password: aB3x+Kp/7mZ9Qw+nR4s/tU2v=
Looks secure. It is. But it contains +, / and =. And those end up in a PostgreSQL connection URL. Unencoded. PostgreSQL on the server side stores the password correctly (via POSTGRES_PASSWORD_FILE). But Prisma on the client side parses the URL and interprets + as a space, / as a path separator.
The fix: encodeURIComponent() in the Node entrypoint. That’s why the code above doesn’t use simple string interpolation, but:
process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';
This turns + into %2B, / into %2F, = into %3D. Prisma decodes it correctly, PostgreSQL receives the original password. Both sides are happy.
Problem 4: The Volume That Won’t Listen
Even with the correct password and proper encoding, I kept getting: FATAL: password authentication failed for user "umami".
The reason: PostgreSQL only sets POSTGRES_PASSWORD during the first initialization of the data volume. If the volume already exists (from an earlier, failed attempt), PostgreSQL ignores any password change completely. Doesn’t matter whether it’s a file, environment variable, or prayer.
The only fix: delete the volume, start completely fresh.
docker stop umami-db umami
docker rm umami-db umami
docker volume rm umami_umami-db-data
I had to do this four times total until all the other problems were also resolved. Lesson learned: for PostgreSQL password issues, always check the volume first.
Problem 5: PostgreSQL Trims, Prisma Doesn’t (or Vice Versa)
One detail that cost me another round: POSTGRES_PASSWORD_FILE in the official PostgreSQL image strips trailing newlines. If the secret file contains password\n, PostgreSQL stores password.
On the Umami side, readFileSync reads the file including the newline. Without .trim(), encodeURIComponent then also encodes the \n, producing a completely different password. That’s why the .trim() is there on both reads. Small, but critical.
The Final Compose File
After all those iterations, the result looks like this:
services:
umami-db:
image: postgres:16-alpine
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD_FILE: /run/secrets/umami_db_password
volumes:
- umami-db-data:/var/lib/postgresql/data
networks:
- umami
secrets:
- umami_db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami"]
interval: 10s
timeout: 5s
retries: 5
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: umami
restart: unless-stopped
depends_on:
umami-db:
condition: service_healthy
entrypoint:
- node
- -e
- |
const { readFileSync } = require('fs');
const { execSync } = require('child_process');
const dbPass = readFileSync('/run/secrets/umami_db_password', 'utf8').trim();
const appSecret = readFileSync('/run/secrets/umami_app_secret', 'utf8').trim();
process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';
process.env.APP_SECRET = appSecret;
process.env.DISABLE_TELEMETRY = '1';
execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
require('./server.js');
networks:
- umami
- apps
secrets:
- umami_db_password
- umami_app_secret
labels:
- "traefik.enable=true"
- "traefik.docker.network=apps"
- "traefik.http.routers.umami.rule=Host(`umami.dieck-labs.de`)"
- "traefik.http.routers.umami.entrypoints=web"
- "traefik.http.routers.umami.middlewares=crowdsec@docker"
- "traefik.http.services.umami.loadbalancer.server.port=3000"
Cloudflare Access: Protect the Dashboard, Keep Tracking Open
My entire *.dieck-labs.de sits behind Cloudflare Access. That means nobody gets to umami.dieck-labs.de without logging in. Great for the dashboard, bad for tracking. Because visitors to my websites need to load script.js and send events to /api/send without authenticating with Cloudflare.
The trick: create a separate Application in Cloudflare Access that’s more specific than the wildcard. Cloudflare matches more specific applications first.
- New self-hosted application: “Umami Tracking”
- Two application domains:
umami.dieck-labs.dewith path/script.jsumami.dieck-labs.dewith path/api/send
- Policy: Bypass with selector Everyone
The dashboard stays protected by the wildcard application. Only the tracking endpoints are public. Elegant solution, no security compromise.
Adding It to Your Sites
Once Umami is running (default login: admin / umami, change it immediately!), you create a website and get an ID. Then a script tag in the head:
<script defer src="https://umami.dieck-labs.de/getinfo.js" data-website-id="YOUR-WEBSITE-ID"></script>
In Astro, this goes into the layout component. Done. No cookies, no banner, no GDPR worries.
Bonus: Bypassing Ad Blockers
After deploying, I noticed: uBlock Origin blocks the tracking script. The default path /script.js is on every major filter list (EasyPrivacy and similar). Makes sense, it’s a generic analytics script name.
Umami has a fix for this: the environment variable TRACKER_SCRIPT_NAME. Set it to something inconspicuous and Umami serves the tracker under that name instead:
process.env.TRACKER_SCRIPT_NAME = 'getinfo';
Now the script lives at /getinfo.js instead of /script.js. Ad blockers don’t flag it because it’s not on any filter list. The script itself is identical, just the URL changes.
Don’t forget to update the Cloudflare Access bypass rule from /script.js to /getinfo.js as well.
Lessons Learned
What I estimated as “ten minutes” turned into an afternoon of debugging. Each individual problem was small: a wrong entrypoint here, a special character there, a persistent volume that refuses to update. But combined, it felt like an escape room.
The key takeaways:
- PostgreSQL volumes remember the initial password. Always delete when you have password problems.
- Special characters in passwords must be URL-encoded when they end up in connection strings.
- Shell entrypoints in Node images are treacherous. When in doubt: write the entrypoint directly in Node.
- Cloudflare Access and public APIs can coexist if you control priority through separate applications.
Umami is now running stable, barely uses any resources, and shows me exactly what I want to know: who comes from where, which pages get read, how long people stay. Without Google, without cookies, without guilt.