Umami GitHub Explained: Cookie-Free Tracking, Session Hashing, and PostgreSQL Trade-offs
umami by umami-software: umami-software/umami
Umami is a self-hosted, cookie-free web and product analytics platform. This overview explains how server-side session hashing combines a website ID, visitor IP, browser user agent, and rotating cryptographic salt, with the source describing raw IP processing as temporary rather than persisted to the analytics database. It then follows declarative HTML event attributes, JavaScript tracking functions, Node.js ingestion, the unified Next.js and Node.js application, and PostgreSQL persistence through Prisma. Proxy headers affect client-IP attribution, while funnel reporting turns collected events into sequential journey analysis. The closing section describes self-hosting trade-offs around storage growth and query work as event volume increases.
TimeStamps:
0:00 Cookie-Free Tracking and the Stateless Model
1:44 Session Hashing with a Rotating Salt
3:25 HTML Attributes and JavaScript Events
4:42 Next.js, Node.js, and PostgreSQL Ingestion
5:54 Proxy Headers and Funnel Reporting
6:36 Self-Hosted Storage Trade-offs
7:05 PostgreSQL Query Performance at Scale
8:26 Privacy and Operational Trade-offs
🕵️ Cookie-free sessions and server-side identifiers
🧩 HTML, JavaScript, and Node.js event ingestion
🗄️ PostgreSQL, Prisma, proxy headers, and funnels
📈 Self-hosting trade-offs for storage and query growth
Umami exchanges client-side state for server-side session logic and a self-managed PostgreSQL analytics pipeline. The key concepts are how anonymous sessions are formed, how proxy headers preserve attribution, and how storage and query behavior change as event volume grows. The repository and its documentation provide the technical context for evaluating those architecture choices.
#Umami #WebAnalytics #PostgreSQL
[de-DE] YNAS ynp_36ea3890562c1359 Cinematic
Privater automatisierter Staging-Upload. Paketidentität: ynp_36ea3890562c1359; Ausgabe: cinematic.
[es-US] Cinemática YNAS ynp_36ea3890562c1359
Carga de entorno de prueba de automatización privada. Identidad del paquete: ynp_36ea3890562c1359; salida: cinemática.
[fr-FR] YNAS ynp_36ea3890562c1359 cinématique
Téléchargement de l'environnement de test d'automatisation privé. Identifiant du package : ynp_36ea3890562c1359 ; sortie : cinématique.
[hi] YNAS ynp_36ea3890562c1359 सिनेमैटिक
निजी स्वचालन स्टेजिंग अपलोड। पैकेज पहचान: ynp_36ea3890562c1359; आउटपुट: सिनेमाई।
[id] Sinematik YNAS ynp_36ea3890562c1359
Unggahan staging otomatisasi pribadi. Identitas paket: ynp_36ea3890562c1359; output: sinematik.
[it] YNAS ynp_36ea3890562c1359 cinematic
Caricamento di automazione privata. Identità del pacchetto: ynp_36ea3890562c1359; output: cinematografico.
[pt-BR] YNAS ynp_36ea3890562c1359 cinematográfico
Upload de teste de automação privada. Identificação do pacote: ynp_36ea3890562c1359; saída: cinemática.
54 views
How to Self-Host Umami Analytics with Docker and PostgreSQL
---
title: "How to Self-Host Umami Analytics with Docker and PostgreSQL"
published: true
description: "Self-host Umami v3 with Docker Compose and PostgreSQL: secrets, HTTPS with Caddy, the tracking script, and fixes for 28P01 and check-db errors."
tags: selfhosted, docker, analytics, postgres
canonical_url: deploylark.com
---
Umami is a small, privacy-friendly alternative to Google Analytics. You can run it yourself with two containers: the app and a PostgreSQL database. It has a big following among self-hosters. On October 14, 2026, the Umami repository had about 39,400 GitHub stars.
PostgreSQL is the database to use. When a user tried Umami 3 on MySQL in #3859, maintainer Mike Cao answered: "v3 does not run on MySQL".
TL;DR:
- Run ghcr.io/umami-software/umami:3.4.0 with postgres:15.19-alpine. Umami v3 doesn't run on MySQL.
- Put POSTGRES_PASSWORD, APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY in a .env file. Choose the database password once, because changing it later breaks the app.
- Bind the port to 127.0.0.1:3000 and put Caddy in front for HTTPS.
- The default login is admin / umami. Change the password before you do anything else, then add your website and paste the tracking script into your site's <head>.
Prepare the secrets and the compose file
Create the secrets
Don't use the placeholder passwords from the example file. Put real ones in a .env file next to the compose file:
mkdir umami && cd umami
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 16)
APP_SECRET=$(openssl rand -hex 32)
TWO_FACTOR_ENCRYPTION_KEY=$(openssl rand -hex 32)
EOF
chmod 600 .env
chmod 600 keeps the secrets readable only by your user. The values are hex on purpose. Hex has no quotes, $ or @ characters that could break the DATABASE_URL or a shell command later.
Choose POSTGRES_PASSWORD once and keep it. Changing it later breaks the app (see [the 28P01 error](#password-authentication-failed-for-user-umami-28p01-after-changing-the-password)).
TWO_FACTOR_ENCRYPTION_KEY is newer. Older guides leave it out, and people who then turned on 2FA found their instance blocked by "TWO_FACTOR_ENCRYPTION_KEY is missing or invalid" (#4443). Set it from the start.
Write the compose file
This is the official docker-compose.yml with the hard-coded secrets replaced by values from .env, and one more change: the port is bound to 127.0.0.1.
services:
umami:
image: ghcr.io/umami-software/umami:3.4.0
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
APP_SECRET: ${APP_SECRET}
TWO_FACTOR_ENCRYPTION_KEY: ${TWO_FACTOR_ENCRYPTION_KEY}
depends_on:
db:
condition: service_healthy
init: true
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
interval: 5s
timeout: 5s
retries: 5
db:
image: postgres:15.19-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:
Why 127.0.0.1:3000:3000? Upstream publishes "3000:3000", which listens on all interfaces, and Docker-published ports bypass ufw. Umami starts with a default admin / umami login, so until you change it, anyone who can reach port 3000 could log in as admin. Binding to localhost means only the reverse proxy on the same host can reach it.
Here's what I tested with that binding. I did a first start on a fresh volume with the port bound to 127.0.0.1:3900. The migrations ran, the heartbeat answered on 127.0.0.1, and the port didn't answer on the host's network address. The logs matched the excerpt below. The later steps (logging in, adding a website and tracking a view) ran on upstream's "3000:3000" binding, not the localhost one. If you keep upstream's binding, change the password before the port is reachable from outside.
This is the capture from that run, trimmed. The … lines mark removed output, and the last two lines are my notes on the two curl results:
!Terminal capture from the Umami lab run: docker compose ps shows the db and umami containers healthy with the port published as 127.0.0.1:3900 to 3000, the log shows the database checks passing, 26 migrations found, all migrations applied and the database up to date, and the notes record HTTP 200 from the heartbeat on 127.0.0.1 and no answer (000) on the host network address
The healthcheck is upstream's, so I kept it. Note that curl without -f exits 0 even on an HTTP 500, so it only catches a dead process. With curl -f http://localhost:3000/api/heartbeat, errors would count too. If you have a large existing database, also give migrations more time before the healthcheck gives up, for example with start_period: 300s or more retries (see [the check-db error below](#error-check-db-exited-with-1-after-an-upgrade)). I didn't test either change.
If you plan to upgrade on purpose rather than whenever latest moves, pin a version tag instead of latest.
Start Umami and log in
Run the first start
docker compose up -d
docker compose logs -f umami
On the first start, Umami creates the database schema itself. Here's an excerpt from my log on a fresh, empty volume (the umami-1 | prefixes and the long list of migration folders removed):
✓ DATABASE_URL is defined.
✓ Database connection successful.
✓ Database version check successful.
26 migrations found in prisma/migrations
Applying migration `01_init`
...
Applying migration `26_add_api_key`
All migrations have been successfully applied.
✓ Database is up to date.
▲ Next.js 16.3.4
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
✓ Ready in 0ms
On later restarts you'll see No pending migrations to apply. instead of the Applying migration lines.
The heartbeat endpoint is a quick way to check it's up. It also makes a good target for an Uptime Kuma monitor:
$ curl localhost:3000/api/heartbeat
{"ok":true}
Change the default password
With the localhost binding, open http://localhost:3000 on the server itself. You can also tunnel it from your laptop with ssh -L 3000:127.0.0.1:3000 you@server and open http://localhost:3000 there, or set up the HTTPS proxy below first and log in through it. The default login is admin / umami.
!Umami 3 login page with username and password fields
Change the password before you do anything else. The easy way is the dashboard, under Settings, then Profile. I did it through the API, which works the same way. It needs jq (sudo apt install jq):
TOKEN=$(curl -s localhost:3000/api/auth/login -H 'content-type: application/json' \
-d '{"username":"admin","password":"umami"}' | jq -r .token)
curl -s localhost:3000/api/me/password -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"currentPassword":"umami","newPassword":"<new password>"}'
That returned 200, and after that admin / umami got 401 incorrect-username-password.
Add your website and HTTPS
Add the tracking script
In the dashboard, go to Settings, then Websites, then Add website. I used the API instead (POST /api/websites with {"name":"Test site","domain":"localhost"}). Umami gives you a website ID.
!Umami Websites settings page with the Add website button
Put the script in your site's <head>:
<script defer src="https://analytics.example.com/script.js"
data-website-id="a91472cb-2673-40ad-8940-7a865af4701d"></script>
I served a test page with that tag from a second container and visited it. The beacon request went to /api/send and the view showed up on the dashboard:
!Umami dashboard for the test site showing its first recorded page view
Put Caddy in front for HTTPS
The script tag above uses https://analytics.example.com, and on a real site it has to be HTTPS, because an HTTPS page won't load a script from http://. The simplest way is Caddy on the same host. Per Caddy's docs, it gets a Let's Encrypt certificate automatically. Point a DNS A or AAAA record for analytics.example.com at your server, make sure ports 80 and 443 are open, and use this Caddyfile (/etc/caddy/Caddyfile for the Debian package):
analytics.example.com {
reverse_proxy 127.0.0.1:3000
}
Then run sudo systemctl reload caddy. This is why the compose file binds Umami to 127.0.0.1. Caddy connects locally, and the outside world only sees HTTPS. I ran my tests over plain HTTP on localhost with no public domain, so this step follows Caddy's docs rather than my lab. The Vaultwarden HTTPS guide shows a tested Caddy setup with a local certificate, and the Nginx Proxy Manager guide covers a web-UI alternative.
!Diagram of the finished setup. A visitor's browser loads script.js and sends POST /api/send to Caddy at analytics.example.com on port 443. Caddy forwards to Umami on 127.0.0.1:3000, and Umami stores data in PostgreSQL (db:5432, on a volume). Because port 3000 is bound to 127.0.0.1, only Caddy can reach Umami.
Fix common Umami errors
Tracking errors
Page views don't appear, but /api/send returns 200
This one cost me some time. My first test visit came from a headless browser. /api/send answered 200 OK with this body:
{"beep":"boop"}
Nothing was saved. Umami runs every request through the isbot library and silently drops anything that looks like a bot. A headless Chrome user agent counts as one. Here's the check in src/app/api/send/route.ts:
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
return json({ beep: 'boop' });
}
The same page loaded with a normal Chrome user agent was recorded straight away. If you test with curl, Playwright or an uptime checker, you'll see nothing. That's working as designed. DISABLE_BOT_CHECK exists if you really need it.
Database errors
password authentication failed for user "umami" (28P01) after changing the password
I reproduced this on purpose. I changed POSTGRES_PASSWORD in .env and ran docker compose up -d again. Umami went into a restart loop:
✓ DATABASE_URL is defined.
✓ Database connection successful.
✗
Invalid `prisma.$queryRaw()` invocation:
Raw query failed. Code: `28P01`. Message: `password authentication failed for user "umami"`
Yes, it prints "Database connection successful" right before failing. Don't let that fool you.
The reason is that the Postgres image only reads POSTGRES_PASSWORD when it creates an empty data volume. After that, the password is stored in the database, and changing the env var does nothing. To fix it, set the database password to match the new value:
docker compose exec db psql -U umami -d umami \
-c "ALTER USER umami WITH PASSWORD '<new password from .env>';"
docker compose restart umami
Keep the password in single quotes inside the SQL. That's how PostgreSQL marks a string literal. If you generated it with openssl rand -hex as shown earlier, it's only 0-9a-f, so there's nothing to escape.
After that, the app container was healthy again and /api/heartbeat returned {"ok":true}. If you get Prisma errors like P1000 even though the credentials look right, also read #4459. There, a collaborator traced one case back to 2FA being enabled without TWO_FACTOR_ENCRYPTION_KEY.
ERROR: "check-db" exited with 1 after an upgrade
On upgrades, Umami runs database migrations at startup. In #3408 (2.17 to 2.18), a migration on a large table took more than 15 minutes. Meanwhile the compose healthcheck (5 s × 5 retries) marked the container unhealthy and restarted it partway through. If you have a lot of data, back up first, watch the logs, and don't restart while migrations are running. My test database was empty, so I didn't see this myself.
Prisma P3009 (failed migration)
If a migration was interrupted, Umami refuses to start with P3009 (#2645, 58 comments). The collaborator's advice is to look in the _prisma_migrations table to see which migration failed. If its changes are actually in place, mark it as applied, for example with npx prisma migrate resolve --applied "<migration name>". Use the name of your own failed migration from _prisma_migrations. 05_add_visit_id was the one in that v2-era thread and is only an example. I haven't checked whether npx prisma is available in the v3 runtime image, so you may need to run it from a checkout of the Umami source instead.
relation "account" does not exist in the Postgres log
On a fresh database you may see this in the Postgres log. A collaborator explained in #2145 that it comes from the startup check probing for an old table, and it's harmless if Umami starts normally.
Managed Postgres that requires SSL
Hosted databases often reject connections without encryption ("no pg_hba.conf entry ... no encryption", #3687). The usual fix is to add ?sslmode=require to DATABASE_URL. I used a local container, so I didn't run this case.
FAQ
Setup
What is the default Umami username and password?
It's admin / umami, per the install docs, and that's what my fresh install accepted. Change it right after the first login. Users have asked for a way to set it with environment variables instead (#4083).
Can I use MySQL with Umami v3?
No. The maintainer confirmed in #3859 that v3 doesn't run on MySQL. Use PostgreSQL, as in this guide.
Tracking
Why isn't Umami tracking page views after I added the script?
Questions like this discussion come up often. First check the bot filter described [above](#page-views-dont-appear-but-apisend-returns-200). Visits from headless browsers and test tools are dropped while /api/send still returns 200. Then check that the data-website-id matches the website in your dashboard, and that the script loads over HTTPS on an HTTPS site.
How do I stop ad blockers from blocking Umami?
The Umami docs give two environment variables for self-hosted instances. TRACKER_SCRIPT_NAME renames script.js (for example to x.js), and COLLECT_API_ENDPOINT renames /api/send (for example to /api/x). This is a common complaint, as in #726 about Brave. I didn't test ad blockers.
How do I exclude my own visits?
Per the docs, open your site, open the browser console and run localStorage.setItem('umami.disabled', 1);. The setting is per website, so repeat it on each site you track. localStorage.removeItem('umami.disabled'); turns tracking back on. People ask about this in discussion #2305, and I didn't test it.
---
Originally published on DeployLark: How to Self-Host Umami Analytics with Docker and PostgreSQL
1 reaction · 1 comment
Mentions
I added a free tool to my site today. Now I see everything: how many people come, from which country, which page they open, what phone or laptop. Every week a report of the numbers lands in my messages. Free and open source. umami-software/umami
Build a RAG SaaS with pipeshub-ai, gridex & MagicUI | Open-Source Stack In this episode we build a private company knowledge assistant by integrating pipeshub-ai + gridex + magicui + google-api-python-client + umami. documents become a permission-aware search and answer workspace Repositories used in this architecture: - pipeshub-ai: pipeshub-ai/pipeshub-ai - gridex: gridex/gridex - magicui: magicuidesign/magicui - google-api-python-client: googleapis/google-api-python-client - umami: umami-software/umami This episode explains one integrated product architecture, including interfaces, data flow, deployment, security, and failure handling. Subscribe for daily open-source product builds: youtube.com #GitHub #OpenSource #SoftwareArchitecture #Programming
I’ve replaced DataFast with self-hosted Uminami analytics easy to setup and works well! no need for paid saas when open source alternative exists just point your clanker to the repo and ask it to implement for you ❤️🤖 umami-software/umami
I don't know, but I think Umami does the same; it is open source and supports self-hosting. umami-software/umami
Umami GitHub Explained: Cookie-Free Tracking, Session Hashing, and PostgreSQL Trade-offs umami by umami-software: umami-software/umami Umami is a self-hosted, cookie-free web and product analytics platform. This overview explains how server-side session hashing combines a website ID, visitor IP, browser user agent, and rotating cryptographic salt, with the source describing raw IP processing as temporary rather than persisted to the analytics database. It then follows declarative HTML event attributes, JavaScript tracking functions, Node.js ingestion, the unified Next.js and Node.js application, and PostgreSQL persistence through Prisma. Proxy headers affect client-IP attribution, while funnel reporting turns collected events into sequential journey analysis. The closing section describes self-hosting trade-offs around storage growth and query work as event volume increases. TimeStamps: 0:00 Cookie-Free Tracking and the Stateless Model 1:44 Session Hashing with a Rotating Salt 3:25 HTML Attributes and JavaScript Events 4:42 Next.js, Node.js, and PostgreSQL Ingestion 5:54 Proxy Headers and Funnel Reporting 6:36 Self-Hosted Storage Trade-offs 7:05 PostgreSQL Query Performance at Scale 8:26 Privacy and Operational Trade-offs 🕵️ Cookie-free sessions and server-side identifiers 🧩 HTML, JavaScript, and Node.js event ingestion 🗄️ PostgreSQL, Prisma, proxy headers, and funnels 📈 Self-hosting trade-offs for storage and query growth Umami exchanges client-side state for server-side session logic and a self-managed PostgreSQL analytics pipeline. The key concepts are how anonymous sessions are formed, how proxy headers preserve attribution, and how storage and query behavior change as event volume grows. The repository and its documentation provide the technical context for evaluating those architecture choices. #Umami #WebAnalytics #PostgreSQL [de-DE] YNAS ynp_36ea3890562c1359 Cinematic Privater automatisierter Staging-Upload. Paketidentität: ynp_36ea3890562c1359; Ausgabe: cinematic. [es-US] Cinemática YNAS ynp_36ea3890562c1359 Carga de entorno de prueba de automatización privada. Identidad del paquete: ynp_36ea3890562c1359; salida: cinemática. [fr-FR] YNAS ynp_36ea3890562c1359 cinématique Téléchargement de l'environnement de test d'automatisation privé. Identifiant du package : ynp_36ea3890562c1359 ; sortie : cinématique. [hi] YNAS ynp_36ea3890562c1359 सिनेमैटिक निजी स्वचालन स्टेजिंग अपलोड। पैकेज पहचान: ynp_36ea3890562c1359; आउटपुट: सिनेमाई। [id] Sinematik YNAS ynp_36ea3890562c1359 Unggahan staging otomatisasi pribadi. Identitas paket: ynp_36ea3890562c1359; output: sinematik. [it] YNAS ynp_36ea3890562c1359 cinematic Caricamento di automazione privata. Identità del pacchetto: ynp_36ea3890562c1359; output: cinematografico. [pt-BR] YNAS ynp_36ea3890562c1359 cinematográfico Upload de teste de automação privada. Identificação do pacote: ynp_36ea3890562c1359; saída: cinemática.
Self Hosted Analytics? You can go with: Umami : umami-software/umami Traffic-source : mddanishyusuf/traffic-source Thanks me later. t.co
@umami_software Hello, please bump your version. High CVE umami#4656
How to Self-Host Umami Analytics with Docker and PostgreSQL --- title: "How to Self-Host Umami Analytics with Docker and PostgreSQL" published: true description: "Self-host Umami v3 with Docker Compose and PostgreSQL: secrets, HTTPS with Caddy, the tracking script, and fixes for 28P01 and check-db errors." tags: selfhosted, docker, analytics, postgres canonical_url: deploylark.com --- Umami is a small, privacy-friendly alternative to Google Analytics. You can run it yourself with two containers: the app and a PostgreSQL database. It has a big following among self-hosters. On October 14, 2026, the Umami repository had about 39,400 GitHub stars. PostgreSQL is the database to use. When a user tried Umami 3 on MySQL in #3859, maintainer Mike Cao answered: "v3 does not run on MySQL". TL;DR: - Run ghcr.io/umami-software/umami:3.4.0 with postgres:15.19-alpine. Umami v3 doesn't run on MySQL. - Put POSTGRES_PASSWORD, APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY in a .env file. Choose the database password once, because changing it later breaks the app. - Bind the port to 127.0.0.1:3000 and put Caddy in front for HTTPS. - The default login is admin / umami. Change the password before you do anything else, then add your website and paste the tracking script into your site's <head>. Prepare the secrets and the compose file Create the secrets Don't use the placeholder passwords from the example file. Put real ones in a .env file next to the compose file: mkdir umami && cd umami cat > .env <<EOF POSTGRES_PASSWORD=$(openssl rand -hex 16) APP_SECRET=$(openssl rand -hex 32) TWO_FACTOR_ENCRYPTION_KEY=$(openssl rand -hex 32) EOF chmod 600 .env chmod 600 keeps the secrets readable only by your user. The values are hex on purpose. Hex has no quotes, $ or @ characters that could break the DATABASE_URL or a shell command later. Choose POSTGRES_PASSWORD once and keep it. Changing it later breaks the app (see [the 28P01 error](#password-authentication-failed-for-user-umami-28p01-after-changing-the-password)). TWO_FACTOR_ENCRYPTION_KEY is newer. Older guides leave it out, and people who then turned on 2FA found their instance blocked by "TWO_FACTOR_ENCRYPTION_KEY is missing or invalid" (#4443). Set it from the start. Write the compose file This is the official docker-compose.yml with the hard-coded secrets replaced by values from .env, and one more change: the port is bound to 127.0.0.1. services: umami: image: ghcr.io/umami-software/umami:3.4.0 ports: - "127.0.0.1:3000:3000" environment: DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami APP_SECRET: ${APP_SECRET} TWO_FACTOR_ENCRYPTION_KEY: ${TWO_FACTOR_ENCRYPTION_KEY} depends_on: db: condition: service_healthy init: true restart: always healthcheck: test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"] interval: 5s timeout: 5s retries: 5 db: image: postgres:15.19-alpine environment: POSTGRES_DB: umami POSTGRES_USER: umami POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - umami-db-data:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 volumes: umami-db-data: Why 127.0.0.1:3000:3000? Upstream publishes "3000:3000", which listens on all interfaces, and Docker-published ports bypass ufw. Umami starts with a default admin / umami login, so until you change it, anyone who can reach port 3000 could log in as admin. Binding to localhost means only the reverse proxy on the same host can reach it. Here's what I tested with that binding. I did a first start on a fresh volume with the port bound to 127.0.0.1:3900. The migrations ran, the heartbeat answered on 127.0.0.1, and the port didn't answer on the host's network address. The logs matched the excerpt below. The later steps (logging in, adding a website and tracking a view) ran on upstream's "3000:3000" binding, not the localhost one. If you keep upstream's binding, change the password before the port is reachable from outside. This is the capture from that run, trimmed. The … lines mark removed output, and the last two lines are my notes on the two curl results: !Terminal capture from the Umami lab run: docker compose ps shows the db and umami containers healthy with the port published as 127.0.0.1:3900 to 3000, the log shows the database checks passing, 26 migrations found, all migrations applied and the database up to date, and the notes record HTTP 200 from the heartbeat on 127.0.0.1 and no answer (000) on the host network address The healthcheck is upstream's, so I kept it. Note that curl without -f exits 0 even on an HTTP 500, so it only catches a dead process. With curl -f http://localhost:3000/api/heartbeat, errors would count too. If you have a large existing database, also give migrations more time before the healthcheck gives up, for example with start_period: 300s or more retries (see [the check-db error below](#error-check-db-exited-with-1-after-an-upgrade)). I didn't test either change. If you plan to upgrade on purpose rather than whenever latest moves, pin a version tag instead of latest. Start Umami and log in Run the first start docker compose up -d docker compose logs -f umami On the first start, Umami creates the database schema itself. Here's an excerpt from my log on a fresh, empty volume (the umami-1 | prefixes and the long list of migration folders removed): ✓ DATABASE_URL is defined. ✓ Database connection successful. ✓ Database version check successful. 26 migrations found in prisma/migrations Applying migration `01_init` ... Applying migration `26_add_api_key` All migrations have been successfully applied. ✓ Database is up to date. ▲ Next.js 16.3.4 - Local: http://localhost:3000 - Network: http://0.0.0.0:3000 ✓ Ready in 0ms On later restarts you'll see No pending migrations to apply. instead of the Applying migration lines. The heartbeat endpoint is a quick way to check it's up. It also makes a good target for an Uptime Kuma monitor: $ curl localhost:3000/api/heartbeat {"ok":true} Change the default password With the localhost binding, open http://localhost:3000 on the server itself. You can also tunnel it from your laptop with ssh -L 3000:127.0.0.1:3000 you@server and open http://localhost:3000 there, or set up the HTTPS proxy below first and log in through it. The default login is admin / umami. !Umami 3 login page with username and password fields Change the password before you do anything else. The easy way is the dashboard, under Settings, then Profile. I did it through the API, which works the same way. It needs jq (sudo apt install jq): TOKEN=$(curl -s localhost:3000/api/auth/login -H 'content-type: application/json' \ -d '{"username":"admin","password":"umami"}' | jq -r .token) curl -s localhost:3000/api/me/password -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"currentPassword":"umami","newPassword":"<new password>"}' That returned 200, and after that admin / umami got 401 incorrect-username-password. Add your website and HTTPS Add the tracking script In the dashboard, go to Settings, then Websites, then Add website. I used the API instead (POST /api/websites with {"name":"Test site","domain":"localhost"}). Umami gives you a website ID. !Umami Websites settings page with the Add website button Put the script in your site's <head>: <script defer src="https://analytics.example.com/script.js" data-website-id="a91472cb-2673-40ad-8940-7a865af4701d"></script> I served a test page with that tag from a second container and visited it. The beacon request went to /api/send and the view showed up on the dashboard: !Umami dashboard for the test site showing its first recorded page view Put Caddy in front for HTTPS The script tag above uses https://analytics.example.com, and on a real site it has to be HTTPS, because an HTTPS page won't load a script from http://. The simplest way is Caddy on the same host. Per Caddy's docs, it gets a Let's Encrypt certificate automatically. Point a DNS A or AAAA record for analytics.example.com at your server, make sure ports 80 and 443 are open, and use this Caddyfile (/etc/caddy/Caddyfile for the Debian package): analytics.example.com { reverse_proxy 127.0.0.1:3000 } Then run sudo systemctl reload caddy. This is why the compose file binds Umami to 127.0.0.1. Caddy connects locally, and the outside world only sees HTTPS. I ran my tests over plain HTTP on localhost with no public domain, so this step follows Caddy's docs rather than my lab. The Vaultwarden HTTPS guide shows a tested Caddy setup with a local certificate, and the Nginx Proxy Manager guide covers a web-UI alternative. !Diagram of the finished setup. A visitor's browser loads script.js and sends POST /api/send to Caddy at analytics.example.com on port 443. Caddy forwards to Umami on 127.0.0.1:3000, and Umami stores data in PostgreSQL (db:5432, on a volume). Because port 3000 is bound to 127.0.0.1, only Caddy can reach Umami. Fix common Umami errors Tracking errors Page views don't appear, but /api/send returns 200 This one cost me some time. My first test visit came from a headless browser. /api/send answered 200 OK with this body: {"beep":"boop"} Nothing was saved. Umami runs every request through the isbot library and silently drops anything that looks like a bot. A headless Chrome user agent counts as one. Here's the check in src/app/api/send/route.ts: if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { return json({ beep: 'boop' }); } The same page loaded with a normal Chrome user agent was recorded straight away. If you test with curl, Playwright or an uptime checker, you'll see nothing. That's working as designed. DISABLE_BOT_CHECK exists if you really need it. Database errors password authentication failed for user "umami" (28P01) after changing the password I reproduced this on purpose. I changed POSTGRES_PASSWORD in .env and ran docker compose up -d again. Umami went into a restart loop: ✓ DATABASE_URL is defined. ✓ Database connection successful. ✗ Invalid `prisma.$queryRaw()` invocation: Raw query failed. Code: `28P01`. Message: `password authentication failed for user "umami"` Yes, it prints "Database connection successful" right before failing. Don't let that fool you. The reason is that the Postgres image only reads POSTGRES_PASSWORD when it creates an empty data volume. After that, the password is stored in the database, and changing the env var does nothing. To fix it, set the database password to match the new value: docker compose exec db psql -U umami -d umami \ -c "ALTER USER umami WITH PASSWORD '<new password from .env>';" docker compose restart umami Keep the password in single quotes inside the SQL. That's how PostgreSQL marks a string literal. If you generated it with openssl rand -hex as shown earlier, it's only 0-9a-f, so there's nothing to escape. After that, the app container was healthy again and /api/heartbeat returned {"ok":true}. If you get Prisma errors like P1000 even though the credentials look right, also read #4459. There, a collaborator traced one case back to 2FA being enabled without TWO_FACTOR_ENCRYPTION_KEY. ERROR: "check-db" exited with 1 after an upgrade On upgrades, Umami runs database migrations at startup. In #3408 (2.17 to 2.18), a migration on a large table took more than 15 minutes. Meanwhile the compose healthcheck (5 s × 5 retries) marked the container unhealthy and restarted it partway through. If you have a lot of data, back up first, watch the logs, and don't restart while migrations are running. My test database was empty, so I didn't see this myself. Prisma P3009 (failed migration) If a migration was interrupted, Umami refuses to start with P3009 (#2645, 58 comments). The collaborator's advice is to look in the _prisma_migrations table to see which migration failed. If its changes are actually in place, mark it as applied, for example with npx prisma migrate resolve --applied "<migration name>". Use the name of your own failed migration from _prisma_migrations. 05_add_visit_id was the one in that v2-era thread and is only an example. I haven't checked whether npx prisma is available in the v3 runtime image, so you may need to run it from a checkout of the Umami source instead. relation "account" does not exist in the Postgres log On a fresh database you may see this in the Postgres log. A collaborator explained in #2145 that it comes from the startup check probing for an old table, and it's harmless if Umami starts normally. Managed Postgres that requires SSL Hosted databases often reject connections without encryption ("no pg_hba.conf entry ... no encryption", #3687). The usual fix is to add ?sslmode=require to DATABASE_URL. I used a local container, so I didn't run this case. FAQ Setup What is the default Umami username and password? It's admin / umami, per the install docs, and that's what my fresh install accepted. Change it right after the first login. Users have asked for a way to set it with environment variables instead (#4083). Can I use MySQL with Umami v3? No. The maintainer confirmed in #3859 that v3 doesn't run on MySQL. Use PostgreSQL, as in this guide. Tracking Why isn't Umami tracking page views after I added the script? Questions like this discussion come up often. First check the bot filter described [above](#page-views-dont-appear-but-apisend-returns-200). Visits from headless browsers and test tools are dropped while /api/send still returns 200. Then check that the data-website-id matches the website in your dashboard, and that the script loads over HTTPS on an HTTPS site. How do I stop ad blockers from blocking Umami? The Umami docs give two environment variables for self-hosted instances. TRACKER_SCRIPT_NAME renames script.js (for example to x.js), and COLLECT_API_ENDPOINT renames /api/send (for example to /api/x). This is a common complaint, as in #726 about Brave. I didn't test ad blockers. How do I exclude my own visits? Per the docs, open your site, open the browser console and run localStorage.setItem('umami.disabled', 1);. The setting is per website, so repeat it on each site you track. localStorage.removeItem('umami.disabled'); turns tracking back on. People ask about this in discussion #2305, and I didn't test it. --- Originally published on DeployLark: How to Self-Host Umami Analytics with Docker and PostgreSQL