Some links on this page are affiliate links: if you sign up through them, this site earns a commission at no extra cost to you. Prices and recommendations are not affected.

Eleven days ago we put a privacy-first analytics server next to a site that was already running. No new machine, no managed service, no cookie banner. It took about twenty minutes, it now uses 340 MB of RAM, and its database has grown to 9.5 MB.

It also recorded 96 pageviews in those eleven days, while the web server in front of it logged 2016 successful page responses.

Both numbers are real, both are from the same box, and the gap between them is the most useful thing in this article. Start with the install, because the install is the easy part.

Why self-host this at all

Three reasons, in the order they actually matter.

You already pay for the box. Hosted analytics is priced per month and scales with pageviews, so the bill grows exactly when the site starts working. A container next to an existing site costs whatever 340 MB of RAM costs you, which on a €5.49 server is a rounding error. We sized the rest of the fleet in our Hetzner review and in the VPS comparison.

No cookie banner. Umami does not set cookies and does not build cross-site profiles. That is the difference between a site that opens with content and a site that opens with a consent dialog, and it is worth more than any metric the dialog is protecting.

The data stays in your Postgres. You can query it with SQL, back it up with everything else, and delete it when you want to. No export quota, no retention tier.

The honest counter-argument: you now operate a database and an app that nobody else patches for you. If you will not run docker compose pull once a month, use the hosted version and stop reading.

The compose file, for a box that already runs something

This is the part most guides get wrong by assuming an empty machine. The realistic case is a server with a reverse proxy and a site on it already, and the goal is to add two containers without touching what works.

services:
  umami-db:
    image: postgres:17-alpine
    container_name: umami-db
    restart: unless-stopped
    env_file: [.env.umami]
    volumes:
      - umami_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 10s
      timeout: 5s
      retries: 10

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    restart: unless-stopped
    env_file: [.env.umami]
    depends_on:
      umami-db:
        condition: service_healthy

volumes:
  umami_db:

Two details are doing real work here.

Neither container publishes a port. They talk to the reverse proxy over the compose network and are unreachable from the internet except through it. If you add ports: ["3000:3000"] out of habit, you have just put an admin login on a public port with a default password on it, and the firewall is the only thing between you and a bad evening.

The healthcheck is not decoration. Umami runs its migrations on first boot. Start it against a Postgres that is still initialising and the first run fails on a connection error, leaving you to debug a problem that fixes itself on restart. condition: service_healthy removes the race.

The .env.umami file next to it:

POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=<long random string>
DATABASE_URL=postgresql://umami:<same string>@umami-db:5432/umami
APP_SECRET=<another long random string>

DATABASE_URL is the only variable the application truly requires. APP_SECRET signs the authentication tokens, and the documentation is explicit that every installation should have its own. Generate both the same way and never reuse them:

openssl rand -hex 32

Keep that file at chmod 600. It holds the database password in two places.

Wiring it to Caddy

If you already run Caddy the way we described in the Caddy and Docker setup, the whole integration is five lines:

stats.yourdomain.dev {
    encode zstd gzip

    header {
        X-Content-Type-Options nosniff
        Referrer-Policy no-referrer
        -Server
    }

    reverse_proxy umami:3000
}

Caddy resolves umami over the compose network and provisions the certificate on first request. There is no TLS configuration to write, which is the entire reason to use it on a small box.

Point the DNS record at the server before you reload, or the certificate request fails and Caddy backs off for a while.

The step you must not postpone

Umami creates a default account on first boot: username admin, password umami. This is documented, which means every scanner on the internet knows it too.

Log in, change the password, then prove the old one is gone:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST https://stats.yourdomain.dev/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"umami"}'

401 is the answer you want. Anything else means the change did not take, and you have a public dashboard with a published password on it.

Then add your site in the UI and drop the snippet it gives you into your HTML head:

<script defer src="https://stats.yourdomain.dev/script.js"
        data-website-id="your-website-id"></script>

The website id is not a secret. It identifies which site a pageview belongs to, nothing more.

What eleven days of real numbers look like

Here is the whole cost of running this, measured on a live box rather than estimated:

MetricValue
Umami container memory302 MB
Postgres container memory37 MB
Database size, 96 pageviews9.5 MB
CPU, both containers, steady state0.00%
Uptime since install, restarts11 days, zero

The 9.5 MB is almost entirely empty schema. Postgres does not grow linearly with a handful of events, so do not read a per-pageview cost into that number. The memory figure is the one that matters for capacity planning: budget roughly 350 MB and you will not be surprised.

Now the uncomfortable part

Same box, same eleven days, two sources of truth that disagree violently.

The access log recorded 2016 successful HTML responses. Umami recorded 96 pageviews across 26 sessions. If you take those at face value, analytics saw 4.8% of your traffic and you should panic.

Do not panic. Take the log apart instead.

Filtering user agents containing bot, spider, crawl, curl, python, wget or headless removes 1143 of those 2016 requests. More than half the traffic to a new site is openly declared automation, and that is before counting the ones that lie.

Of the 873 that survive, 196 are GoogleOther - a Google crawler whose user agent contains no occurrence of the string bot at all. If your log analysis greps for “bot”, you have been silently counting a crawler as a reader. That one is worth remembering the next time a dashboard built on access logs tells you something flattering.

The remainder still contains unlabelled scrapers. A heavy concentration of one exact ancient Safari build, for instance, is a fingerprint, not an audience.

So the real comparison is 96 recorded pageviews against a few hundred plausibly-human requests, and the gap has three sources, in descending order of size:

  1. Automation that does not run JavaScript. It appears in the log and never in analytics. This is most of the gap, and it is analytics being right.
  2. Blocked trackers. Some filter lists block by hostname pattern, and a host called stats. is a pattern. Self-hosting does not grant immunity, it only removes the well-known third-party domain. The project documents proxying the script under your own domain and moving the collection endpoint with COLLECT_API_ENDPOINT if you need to close that gap.
  3. Prefetch and no-JS readers. Small, real, unfixable.

The conclusion is not “logs are honest and analytics lies”. It is that they measure different populations: the log counts requests, analytics counts browsers that execute script. A new site’s log is mostly robots, which is why reading raw logs makes a young site feel popular and is the single easiest way to lie to yourself about traction.

If you want one number to steer by in the first months, use sessions from analytics and treat the log as a debugging tool.

Cookieless analytics avoids the consent requirement that attaches to storing or reading information on the visitor’s device, which is what makes the banner mandatory for conventional tracking. It does not make the processing invisible: an IP address still reaches your server, as it does for every request to every site.

Practical consequences, not legal advice: say in your privacy page what you collect, keep it aggregate, and set IGNORE_IP for your own addresses so your dashboard is not mostly you. If your situation is regulated, ask someone qualified rather than a blog.

Maintenance, which is the whole deal you signed

docker compose pull umami umami-db
docker compose up -d umami umami-db

That is the monthly job. Schema migrations run automatically on container start, which is convenient and also exactly why the volume matters: umami_db is the only thing you cannot recreate from this file. Back it up with the rest of your data and test the restore once, or you are keeping an untested copy of 9.5 MB and a false sense of safety.

After the first week, do one more thing: open the dashboard and compare it against what you know to be true. If you visited the site yourself twenty times and the dashboard shows two, something between the script tag and the collector is being blocked, and better to learn that now than in three months, when you are making decisions from the number.