Until this week, every one of my 19 websites ran Google Analytics. It worked, in the sense that numbers showed up in a dashboard I rarely opened. It also loaded a large script from Google on every page, set cookies in every visitor's browser, and, embarrassingly, contradicted my own privacy page, which claimed this site had no trackers. (It has since been corrected.)

So I installed Umami on the same server that hosts this site. This post covers why, how it works, exactly how I set it up, and the four things that bit me along the way.

What Umami is

Umami is open-source web analytics. You run it on your own server, or use their paid cloud. You add one small script tag to your pages, and it counts page views, visitors, referrers, countries, devices and browsers. Newer versions add custom events, funnels, user journeys, retention and UTM campaign tracking. The project is mature: around 39,000 stars on GitHub and hundreds of contributors.

What it doesn't do is just as important. It sets no cookies and stores no personal data. It works out unique visitors with an anonymous hash that only means something on your own site and changes every month, so it can't follow anyone around the web. There's nothing to sell to advertisers, because there is no advertiser.

Why I use it over GA4

  • It's honest by default. No cookies means no cookie banner for most sites, and a privacy page I can write in two plain sentences.
  • It's tiny. I measured both on this site. Umami's script is 2.3 KB compressed. Google's tag is 179 KB compressed (535 KB uncompressed). That's about 75 times more JavaScript to report the same page view.
  • I own the data. It lives in a Postgres database on my server, covered by my nightly backups, and nobody else trains on it or decides to change the reports.
  • The dashboard is readable. One page answers 90% of my questions: how many people came, from where, to which pages. GA4 can answer more, but I could never find anything in it.
  • It has an API, and an MCP server. I can pull numbers into my weekly email report, or ask an AI assistant "which posts got the most visits this week?"

When GA4 is still the better choice

Let's be fair to Google. Keep GA4 if you run Google Ads and need conversion data flowing back into them, if you want the built-in Search Console link, or if you need demographic reports. I'm also running both side by side for a few weeks before I turn anything off, and I'd suggest you do the same.

How it works, in one paragraph

A visitor loads your page. The Umami script (served from your server) sends a small request saying "someone viewed /blog/this-post, from this referrer, on this screen size." Umami's server works out a rough location from the IP address, combines a few details into an anonymous visitor hash, stores the event in the database, and throws the IP address away. The dashboard is just queries over those events. That's it.

What you need

  • A Linux server or VPS with Docker. Umami used about 200 MB of RAM in my case, so a small VPS is fine. See setting up a VPS without getting lost if you don't have one yet.
  • A domain or subdomain for the dashboard. I use stats.williamlodge.com, one subdomain for every site.
  • A reverse proxy for HTTPS, such as Nginx Proxy Manager, Caddy, or your control panel's proxy.

Step 1: the Docker Compose file

Make a folder, say /opt/umami, and generate two random secrets:

mkdir -p /opt/umami && cd /opt/umami
openssl rand -hex 24   # database password
openssl rand -hex 32   # APP_SECRET

Then create docker-compose.yml, pasting your two values in place of the placeholders:

services:
  umami:
    image: ghcr.io/umami-software/umami:3.4.0   # pin a version
    ports:
      - "127.0.0.1:3000:3000"                  # only reachable via your proxy
    environment:
      DATABASE_URL: postgresql://umami:DB_PASSWORD@db:5432/umami
      APP_SECRET: YOUR_APP_SECRET
      DISABLE_TELEMETRY: 1
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: DB_PASSWORD
      TZ: UTC
      PGTZ: UTC
    volumes:
      - umami-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
      interval: 5s
      retries: 10
    restart: unless-stopped

volumes:
  umami-db:

Start it and check that it answers:

docker compose up -d
curl http://127.0.0.1:3000/api/heartbeat   # → {"ok":true}

On first start, Umami creates its tables automatically. You'll see "All migrations have been successfully applied" in docker compose logs umami.

Already run Postgres on the host? That's my setup. I skipped the db service, created an umami database and user in my existing Postgres, and ran the container with --network host so it can reach 127.0.0.1:5432. The bonus is that my existing nightly pg_dumpall backs it up with no extra work.

Step 2: give it a domain with HTTPS

Point a subdomain at your server (an A record for stats), then add a proxy host in your reverse proxy that forwards stats.yourdomain.com to 127.0.0.1:3000, with a Let's Encrypt certificate. In Nginx Proxy Manager that's one form. Then check it from outside:

curl https://stats.yourdomain.com/api/heartbeat

Step 3: change the default password, right now

Umami ships with the login admin / umami, and bots know it. Before you do anything else, log in and change it under Settings → Profile. Save the new one in your password manager.

Step 4: add a website and paste the tag

In Settings → Websites → Add website, enter a name and domain. Umami gives you a tracking code like this:

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

Put it in the <head> of every page. On WordPress, use a header plugin or your theme's header file. On a static site, add it to your template. If your site is built by a script, put it in the script, not the output, or your next build will remove it.

Open your site in another browser, and you should appear in Umami's Realtime view within seconds.

Step 5 (optional): track the clicks that matter

Page views are automatic. For buttons, add one attribute and Umami counts it as an event, with no extra JavaScript:

<button data-umami-event="newsletter-signup">Subscribe</button>

Two more attributes worth knowing: data-domains="yourdomain.com" on the script tag stops it counting visits on staging or localhost, and running localStorage.setItem('umami.disabled', 1) in your browser's console stops Umami counting your own visits.

The four gotchas I hit

1. My timestamps were six hours in the future

My first test page view saved fine but showed up in no reports. The cause: my host Postgres runs on Denver time, and Umami assumes the database speaks UTC. Every event was stored six hours ahead, so "the last hour" was always empty. The fix was one line, applied only to Umami's database:

ALTER DATABASE umami SET timezone TO 'UTC';

If you use the compose file above, the TZ and PGTZ lines already avoid this. If you reuse an existing database server, check your timestamps after the first visit.

2. A strict Content-Security-Policy blocks it silently

One of my apps sends a security header that only allows scripts from its own domain. The Umami tag was on the page, and the browser quietly refused to run it. If your site sends a Content-Security-Policy, add your stats domain to both script-src (to load the script) and connect-src (to send the page views).

3. Auto-updaters and analytics don't mix

Umami changes its own database tables when a new version starts. I pin an exact version in the image tag and update by hand after reading the release notes, rather than letting a tool like Watchtower pull latest at 3 a.m.

4. Ad blockers still block some of it

Self-hosting on your own subdomain gets past many blockers, but not all, because some block lists include the Umami script by name. Treat every analytics tool's numbers as a lower bound, Google's included. They're good for trends, not exact headcounts.

Keeping it healthy

  • Back up the database. Your stats are just rows in Postgres. A nightly pg_dump is enough. See backups you can restore.
  • Monitor it. Point Uptime Kuma, or a simple cron check, at /api/heartbeat.
  • Cap its memory. A mem_limit: 512m in compose keeps a misbehaving container from starving your other sites.

Update your privacy page

Cookie-free doesn't mean you should say nothing. Tell visitors you use Umami, that it's self-hosted, sets no cookies and stores no personal data, and, if you keep GA4 for now, say that too. This site's privacy page does exactly that.

Was it worth it?

For me, yes. It took about twenty minutes to get the first site reporting, and an evening to roll it out across 19 sites, mostly because of the gotchas above. My pages got lighter, my privacy page got honest, and my analytics now live next to the rest of my data, on a server I control. I'll report back once I've run both tools side by side long enough to decide whether GA4 stays.