2026-09-02

Self-Hosting Umami Analytics on Coolify

A first-party analytics subdomain skips the cookie banner and the ad-blocker deny-list, if you can prove the pageviews actually arrive.

Every third-party analytics script sits on somebody else's domain, and every ad blocker worth using ships a list of those domains to kill on sight. Google Analytics, Plausible's hosted tier, PostHog cloud: block lists like EasyPrivacy have entries for all of them, so a real slice of your visitors never send a single event. Run the same open-source tool yourself on a subdomain of your own site and that specific problem goes away, because the script and the collection endpoint are both first-party. A blocklist keyed on someone else's hostname has nothing to match. I run Umami this way for a handful of my own sites, on Coolify, on the same VPS everything else already lives on. This is what actually stood it up, including the part where I assumed it worked and it didn't.

Why self-host analytics at all

Umami doesn't set a tracking cookie and doesn't build a cross-site profile of a visitor the way Google Analytics does, which is most of the reason people reach for it over GA4 in the first place, hosted or not. Self-hosting it doesn't change that behaviour. What it changes is where the event data lands: a Postgres database you control, on infrastructure you already pay for, instead of a vendor's warehouse under whatever retention and resale terms are current that quarter. If you already run Coolify for your own deploys, adding Umami costs one more service on a box you're already paying for, not a new subscription.

Standing it up on Coolify

Umami is one of Coolify's one-click resources, and that matters because it changes how you create it. Don't add it as an Application pointed at a Docker image. Add it as a Service. Coolify's services are pre-defined multi-container stacks: pick Umami from the service list under New Resource, give it a project and an environment, and Coolify creates the Umami container and a Postgres instance together, already wired to each other. You don't hand-roll a DATABASE_URL or stand up a separate database first. That's the actual advantage over doing it by hand: the app-to-Postgres wiring is Coolify's problem, not yours.

Pointing it at your own subdomain

Give it a subdomain, analytics.yoursite.com or whatever you'd rather use, with DNS already pointed at your Coolify server before you touch the domain field. Setting a service's domain is one of the few places I've seen Coolify's UI and its API disagree. Doing it purely through the API didn't reliably make it through to the router on my instance, and the service kept answering on Coolify's own generated sslip.io hostname until I set the domain directly in the Coolify UI, on the service's application component, and let it redeploy. If you're scripting the rest of the setup, budget one manual click here rather than fighting the API for it.

Rotate the default account before you tell anyone the URL is up

Umami ships a first-run admin account, username admin, password umami, and it stays live until you change it. The moment DNS resolves to that service, that login is reachable by anyone who finds or guesses the hostname, not from whenever you get around to setting a real password. I found this the hard way on my own instance: one session stood up Umami, confirmed it with a 200 and a valid certificate, and called it shipped. A later session tried the factory admin/umami login against the live panel and got back a valid admin bearer token, roughly an hour after the domain had gone live. A 200 on the homepage tells you the container is up. It tells you nothing about whether the front door is locked. Change the admin password in the same sitting you publish the hostname, not as a follow-up task on a list.

Wiring the tracking script into a site

Each site you track gets its own website ID from the Umami dashboard, and that ID goes straight into the page as a script attribute:

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

The website ID isn't a secret. It ships in plaintext in the HTML of every page that carries it, since anyone can view source and read it, so there's no harm hardcoding it directly in a layout template instead of routing it through an environment variable. If your build tooling only inlines env vars at build time, Astro's import.meta.env is the example I ran into, hardcoding it is actually the simpler path anyway.

Check your Content-Security-Policy before you assume the tag works. A CSP with script-src 'self' stops the browser from ever loading a script off your analytics subdomain, and even if it somehow loaded, a connect-src 'self' blocks the beacon that reports the pageview back. Both failures are silent: the page renders fine, nothing looks visibly wrong. You have to add your analytics host to both directives:

Content-Security-Policy: script-src 'self' https://analytics.example.com; connect-src 'self' https://analytics.example.com;

Confirm the change with curl -sI against the live page rather than trusting whatever's checked into the repo. The header a browser actually receives is what decides this, and on more than one site I've worked on, it wasn't the file I would have guessed from reading the source tree.

A script tag in the HTML is not proof anything arrived

This is the step that's easy to skip, and it's the one that actually matters. Seeing the script tag render in view-source, or watching the network tab show a 200 for script.js, only proves the script loaded. It doesn't prove a pageview was recorded on the other end. The only real proof is asking Umami's collection endpoint directly and reading what it says back. I ran exactly this check against this site's own Umami instance after wiring the tag in, not just eyeballed the tag sitting in the page source:

curl -s -X POST https://analytics.example.com/api/send \
  -H "Content-Type: application/json" \
  -H "User-Agent: Mozilla/5.0 (Verification Check)" \
  -d '{
    "type": "event",
    "payload": {
      "website": "00000000-0000-0000-0000-000000000000",
      "hostname": "example.com",
      "url": "/verification-check",
      "title": "Verification check",
      "language": "en-US",
      "screen": "1920x1080"
    }
  }'

That's the actual proof: a session and a visit got created server-side, not just that a request went out and something answered. Anything short of a clean 200 with both those fields present, a CORS rejection, a CSP violation sitting in the browser console, a 4xx from the API, means the chain is broken somewhere between the tag and the database, even though the site itself looks completely normal to a visitor. Run this once right after wiring in the tag, and again any time you touch the CSP or move the site to a new host.

The same principle applies to the admin login above. A returned 200 tells you what a request produced, not what should have been possible. Whether it's a tracking beacon or an admin panel, the only way to know a self-hosted service is doing what you think it's doing is to interrogate the actual endpoint, not read the config that's supposed to be controlling it.

Topics

Still stuck? Send it to Emsden Studio and get a straight answer on what it is and what fixing it costs.

More guides

All guides