← Back to Wiki
Monitoring / Self-Hosting

Add Real Traffic Analytics to a Self-Hosted Site

Asked a simple question about my own sites — "what does traffic look like?" — and found the honest answer was nothing. My reverse proxy had been running for months with zero access logging. Here's how I closed that gap with a self-hosted analytics stack, start to finish.

Check what you actually have before assuming

It's easy to assume a reverse proxy is logging requests just because it's healthy and serving traffic fine. Mine wasn't. A quick check of the container's own logs showed only internal housekeeping messages — TLS storage cleanup, certificate maintenance — never a single HTTP request. No log directive had ever been added to any site block. Worth checking directly rather than assuming, especially on infrastructure that's been stable long enough to stop thinking about.

Picking a tool

Three real options, roughly in order of weight:

Went with Umami: a real dashboard without Plausible's operational overhead.

Gotcha: Umami dropped its SQLite image. The plan was a single lightweight container using Umami's SQLite build for the smallest possible footprint. That image tag no longer exists on GHCR — only postgresql-latest and mysql-latest are published now. A small Postgres sidecar container ended up being the actual lightweight option, not the extra step it looked like on paper.

The stack

A single small container/VM running both services via Docker Compose:

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: "<generated>"
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    ports:
      - "3050:3000"
    environment:
      APP_SECRET: "<generated>"
      DATABASE_URL: "postgresql://umami:<password>@db:5432/umami"
    depends_on:
      db:
        condition: service_healthy
Rotate the default login before it's reachable from anywhere. Umami ships with a default admin/umami account. Log in once via the API, immediately set a new password for that account, and confirm the old credentials are rejected — before adding any public DNS record or reverse proxy rule for the service. A newly public admin panel sitting on default credentials, even briefly, is a real exposure window.

Reusing an existing wildcard certificate

If you're already running other *.yourdomain.com subdomains behind Cloudflare with an Origin CA certificate (see the reverse proxy migration guide for how that's set up), a new subdomain needs zero new certificate work — just a new site block reusing the existing wildcard cert:

analytics.example.com {
	tls /config/origin-cert.pem /config/origin-key.pem
	reverse_proxy 192.168.1.X:3050
}

Reload (not restart) the reverse proxy after adding it — a graceful reload validates the config and applies it without dropping any of your other live domains' connections. Worth confirming a handful of your existing domains still respond immediately after, not just the new one.

Wiring in the tracking script

Umami gives each site you register a small script tag with a unique website ID:

<script defer src="https://analytics.example.com/script.js" data-website-id="..."></script>

Drop it before </head> on every page you want tracked. For a static multi-page site, a one-line script across all your HTML files is faster and less error-prone than editing each by hand — and lets you confirm none were silently missed.

Verify with a real event, not a health check

A running container proves nothing about whether analytics actually work end-to-end — DNS, the reverse proxy route, and the tracking script all have to line up correctly first. Umami's collect API can be hit directly to simulate a real pageview:

curl -X POST http://localhost:3050/api/send \
  -H "Content-Type: application/json" \
  -d '{"type":"event","payload":{"website":"<website-id>","url":"/","hostname":"example.com"}}'

Then check the stats endpoint (or the dashboard) actually shows it. Only once a real pageview shows up as a real number is the pipeline confirmed working — not before.

The DNS record is a separate step

None of the above makes the analytics domain publicly reachable by itself. If you're on Cloudflare, every subdomain typically needs its own explicit DNS record — there's usually no wildcard record silently covering new ones. An A record pointing at the same origin your other subdomains use, proxied the same way, is enough — but it's easy to build and verify everything else first and forget this last, unglamorous step. Confirm public resolution and a real 200 from the script URL before calling it done.