← Back to Wiki
Monitoring / Self-Hosting

Self-Host Umami Analytics Behind Caddy: a Google Analytics Alternative

I asked a simple question about my own sites. What does traffic look like? The honest answer was nothing. My reverse proxy had run for months with zero access logging. Here is how I closed that gap with a self-hosted analytics stack, start to finish.

Share on X

Check what you actually have before assuming

It is easy to assume a reverse proxy logs requests just because it is healthy and serving traffic fine. Mine did not. A quick check of the container's own logs showed only internal housekeeping. TLS storage cleanup, certificate maintenance. Never a single HTTP request. No log directive had ever been added to any site block. Check this directly instead of assuming, especially on infrastructure stable long enough that you stopped thinking about it.

Picking a tool

Three real options, roughly in order of weight:

I went with Umami. A real dashboard without Plausible's operational overhead.

BE WARNED: 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 turned out to be the actual lightweight option, not the extra step it looked like on paper.

The stack

A single small container or 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
BE WARNED: rotate the default login before it is reachable from anywhere. Umami ships with a default admin and umami account. Log in once via the API. Set a new password immediately. Confirm the old credentials are rejected. Do all of that before you add any public DNS record or reverse proxy rule. A newly public admin panel on default credentials, even briefly, is a real exposure window.

Reusing an existing wildcard certificate

If you already run other *.yourdomain.com subdomains behind Cloudflare with an Origin CA certificate, a new subdomain needs zero new certificate work. Just a new site block reusing the existing wildcard cert. The reverse proxy migration guide covers how that is set up:

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

Reload the reverse proxy after adding it. Reload, not restart. A graceful reload validates the config and applies it without dropping any of your other live domains' connections. Confirm a handful of your existing domains still respond right 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. On a static multi-page site, run a one-line script across all your HTML files. Faster and less error-prone than editing each by hand, and it lets you confirm none were silently missed.

Verify with a real event, not a health check

A running container proves nothing about whether analytics work end to end. DNS, the reverse proxy route and the tracking script all have to line up first. Hit Umami's collect API 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. The pipeline is confirmed working when a real pageview shows up as a real number. Not before.

The DNS record is a separate step

None of the above makes the analytics domain publicly reachable. On Cloudflare, every subdomain needs its own explicit DNS record. There is 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. It is also easy to build and verify everything else and forget this last unglamorous step. Confirm public resolution and a real 200 from the script URL before you call it done.