DEV Community

DeployLark
DeployLark

Posted on Originally published at deploylark.com

How to Self-Host Umami Analytics with Docker and PostgreSQL

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
Enter fullscreen mode Exit fullscreen mode

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).

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:
Enter fullscreen mode Exit fullscreen mode

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). 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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>"}'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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' });
}
Enter fullscreen mode Exit fullscreen mode

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"`
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. 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

Top comments (1)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

the beep boop body is the detail worth remembering. a 200 that saves nothing is the worst failure mode to debug, it looks like success everywhere you check.