---
title: "Self-Hosting Umami Analytics Without a Public Login Page"
description: "Exposing a self-hosted Umami instance to the internet while keeping its login page off the public web."
published_at: "2026-09-11 00:00 +0000"
published: false
tags: ["homelab","umami","cloudflare","analytics"]
cover_image: https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/og.png
canonical_url: https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/
---

> **Notice:** In retrospect this really should've been a two-parter 😬.

I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up [Umami](https://umami.is) in the homelab. The install was the easy part; the instructions are dead simple.

Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn't good enough for _me_. So, I did what I normally do and stubbornly wandered off to my own path.

Quick bit of context for anyone who hasn't gone down this particular hole. Umami is a self-hosted analytics platform: a small Node app with a PostgreSQL database to maintain persistence sans the cookies and third parties slurping up all the data. It's as simple as dropping one script tag on your site. It posts a little blob of JSON on each page view and you get a dashboard. My install runs in an LXC on a member node of my homelab's Proxmox cluster.

I landed on it for the "normal" reasons. Cloudflare's Analytics are free but limited. They don't give me precisely what I'm looking for and they're a bit too slow for my liking. And, I'm not even going to give Google a single thought; they have enough of my data already.

Which leaves the question: how does a script tag on a public website safely and _securely_ reach a container in my house without punching a hole in my router?

## The answer everyone gives you.

The standard answer is a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). A small daemon, `cloudflared`, runs next to your service and dials *out* to Cloudflare. No dicking around with port forwarding, firewall rules or opening up your router to the world. Traffic arrives at Cloudflare, goes down the pipe your daemon already opened and lands on your service. It's genuinely lovely and it costs nothing. And since I already use Cloudflare to _currently_ host my static websites and domains it was just the pragmatic option. 

I think it's worth noting there is a real trade off here. A tunnel means Cloudflare terminates your TLS. Since they decrypt at the edge, they could inspect whatever they like and they re-encrypt on the way down the pipe. For page view pings carrying a URL and a screen size, I genuinely do not care. For anything with secrets in the payload I would care enormously and so should you.

Anyway, Umami has an admin interface. An admin interface typically has a login page. So, most guides you'll find after searching around will tell you to put something like [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) in front of it. Access is an authentication layer at the edge. Basically, someone hits your hostname, Cloudflare intercepts, they sign in against an identity provider and only then does the request continue to your box.

I was about to wire it to my IDP when I actually stopped to read my own ingress config a bit more closely.

## Tunnels match on path.

The important little detail I almost skimmed past was that a tunnel's ingress rules do not just match on hostname, but on _path_.

I had written this, which is what everyone starts with:

```hcl
ingress = [
  {
    hostname = "analytics.nightcity.network"
    service  = "http://umami:3000"
  },
  {
    service = "http_status:404"
  },
]
```

You write a single hostname and everything on it goes straight to the app while anything else gets a `404`. Ok.

But my analytics service only _needs_ to expose _two things_ to the public internet; the tracker script and the collect endpoint. Nobody on the internet needs to see `/login` or the dashboard, the settings, the user management or the API the dashboard talks to. So, why was I publishing everything and then buying into another service to place in front of it?

```hcl
ingress = [
  {
    hostname = "analytics.nightcity.network"
    path     = "^/bundle\\.js$"
    service  = "http://nginx:80"
  },
  {
    hostname = "analytics.nightcity.network"
    path     = "^/api/v1/data$"
    service  = "http://nginx:80"
  },
  {
    service = "http_status:404"
  },
]
```

I only need precisely _two_ paths out while everything else gets met with a lovely `404`.

My admin interface still exists, of course ( I am not typing SQL to read my own page views ). It sits on my home network behind my own reverse proxy, reachable from the couch or over my established VPN. 

Drawn out, the whole arrangement is two seperate paths that happen to share a name:

```mermaid
flowchart TD
    N["analytics.nightcity.network"]
    N -->|asked from the internet| CD[Cloudflare DNS]
    N -->|asked from my LAN| TD[Technitium]
    CD -->|104.21.x.x| E[Cloudflare edge]
    TD -->|10.0.0.200| P[My reverse proxy]
    E --> C[cloudflared]
    C -->|/bundle.js| X[nginx]
    C -->|/api/v1/data| X
    C -->|everything else| F[404]
    X --> U[Umami]
    P --> U
```

`cloudflared` decides what gets through and it's only aware of two paths. My side of the picture never touches Cloudflare, or the rest of the internet, at all. That shared fork at the top is possible with split horizon DNS and which route your request takes depends on how you get to that point. I'll explain a bit further down.

## Why I think this is the better trade.

I want to be fair to Access here, because it's a great product and there are plenty of setups where it's the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don't control, without a VPN, Access is exactly the kind of tool that'll get you there.

But if you don't need that, consider what you're actually buying in to.

With Access, `/login` is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the _attack surface_ is still there. There's an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn't consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.

With path scoping, `/login` returns a `404`. There is no flow to misconfigure because there is no flow. There is nothing to leave accidentally open when you change something else eighteen months from now.

> Elimination is the ultimate form of hardening.
>
> — Pretty much every cybersecurity professional.

The thing I keep coming back to: **an attack surface you removed cannot be misconfigured later.** A control you added can. This is all about shrinking the [attack surface](https://en.wikipedia.org/wiki/Attack_surface), which is an idea that's far older than me. We're not doing anything new here. But a standard installation path is built to get the typical user up and running quickly. However, quick doesn't necessarily mean appropriate for something like this.

Worth being precise about which of these is which, though. The `404` on `/login` is absence. There is nothing behind it to find no matter how hard you look. Umami does allow you to rename its default tracker paths as well, but that's plainly obscurity and it buys me nothing against anyone who actually takes the time to read my page source. Outside of that, renaming dodges a blocklist that matches on names and known patterns.

## Does it work though?

The nice part about this setup is that you can test the public path without leaving the house. Using `curl --resolve` lets you skip your own DNS and dial the Cloudflare edge directly, while still sending the right hostname, so the request takes the same road a real visitor does.

```bash
curl -sI \
  --resolve analytics.nightcity.network:443:104.21.x.x \
  https://analytics.nightcity.network/login | head -1
```

Run that against a handful of paths and you get:

```
/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404
```

That `405` is the collect endpoint telling me it only accepts `POST`, which is exactly what I want to see from a `GET`. It does admit the endpoint exists, where a `404` would not, but that one has to be findable anyway. Everything else gets a polite middle finger.

Meanwhile, from inside the house, the same hostname gives me the full dashboard. The same two path as before and the split falls out of the DNS I already run for network-wide adblock via a Technitium cluster.

### Something worth pointing out.

I updated the ingress config, re-ran my checks and `/script.js` came back with a `200`. Which was alarming, because I had just watched it `404`.

It was cached. Umami serves its tracker with a 24-hour cache header. My earlier testing had pulled it through the edge and Cloudflare was dutifully serving me the copy it already had. Which, to be fair, is exactly what it should normally be doing. A request with a junk query string came back `404` immediately confirming the origin was doing its job.

Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation strikes again!

## Hark! A plot twist!

Just after I shipped the script tag and confirmed real traffic was hitting Umami, Firefox showed me this:

![The Firefox popup for Local Network Access.](https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup.png)
*Go home Firefox, you are drunk.*

Sir, this is my own site from my machine on my home network. How _dare_ you?

As I mentioned earlier, I run split horizon DNS. Inside my network, `analytics.nightcity.network` resolves to a private address on my LAN so I get the dashboard directly. Outside, where _you better be_ reading from, the same name resolves to Cloudflare and goes down the tunnel. That's what makes the two-path thing work. I also mentioned this before in my previous article [My Blog Now Ships From My Homelab!](https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/) and it's one of my favourite aspects about my setup.

But when I load my _public_ blog from my _own house_, the browser sees a page served from a public origin trying to load a subresource from `10.x.x.x`. Browsers have started treating that as exactly the attack it usually is: a website on the internet quietly poking at your router, your printer or your NAS. Firefox calls it [Local Network Access](https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox) and blocks it by default. Chrome is heading the same way.

So the one place on earth where my analytics silently do not work is the chair I'm sitting in which is precisely how it should work. If it ever bothers me, the fix is to give the tracker its own hostname in a domain my internal resolver doesn't answer for, so it goes out to the edge from everywhere including my lounge room. I won't be doing that as I'm all too happy skipping my own page views.

## The one thing path scoping does not fix.

Moving the admin interface off the internet does nothing for the collect endpoint. That endpoint has to accept anonymous `POST`s from every visitor's browser, or it isn't an analytics endpoint. Umami rejects requests carrying a website ID it doesn't recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every page on this site and [Plant Smart](https://plantsm.art). Literally anyone can read it.

So the only real control is applying some kind of rate limit. Cloudflare's free tier, which I'm currently on because I'm cheap and actually trying to _reduce_ my subscription costs, permits one rule at a ten second window and it insists on counting per _data centre_ rather than _globally_. It'll stop something stupid. But, it will not stop something determined.

Basically, path scoping shrinks the surface area enormously and then stops. What's left is the stuff that's _supposed_ to be open. Though, the worst that can happen if the service gets hammered is fill my disk with garbage data points. Which would no-shit make me laugh.

## ... so, naturally I put Nginx in front of it.

I told myself this was about the rate limit which was a lie. It took a few hours for two better reasons to turn up.

### The origin is blind.

A tunnel terminates the connection at the edge and opens a fresh one to your box, so every request arrives from the connector sitting next to your service. Umami sees `172.16.x.x`, the Docker address of `cloudflared`, for the entire internet. That's also why the usual advice of "just put fail2ban on it" doesn't really apply. [Fail2ban](https://en.wikipedia.org/wiki/Fail2ban) blocks source addresses with a firewall rule and no packet ever arrives from the address you'd want to block.

Cloudflare does pass the real client along in a `CF-Connecting-IP` header. Nginx can promote that header back into being the actual client address, which makes both the logging and the limiting mean something:

```nginx
set_real_ip_from 172.16.0.0/12;
real_ip_header   CF-Connecting-IP;

limit_req_zone $binary_remote_addr zone=collect:10m rate=5r/s;
```

The `set_real_ip_from` range tells Nginx only the connector may assert that header. Nginx answers `cloudflared` and nothing else here and my internal route goes straight to Umami without passing through it. Meaning nothing on my LAN can forge a client address at me.

### Umami logs nothing at all. Zilch.

Its own logs are four lines of startup and then a blank stare into the void. Which means that until I put a proxy in front of it, the only record of what hit my analytics endpoint was... my analytics. 

Nginx gave me an access log within about ten seconds of starting and the very first thing it told me was that I had no idea what my traffic was.

## In which I am humbled by a robot.

I had been suprised with my initial visitor numbers. Then I looked properly and roughly ~90% of it was a single machine in Singapore hammering one [tiny post](https://wilhelm.codes/bits/update-putting-the-site-on-a-diet/):

```
browser  os      device  screen      country  hits  sessions
chrome   Mac OS  laptop  1366x1366   SG       53    51
```

There are a few tells here. Not only is the screen size a perfect square, `50`ish sessions with just as many hits from the same region with the same browser settings smells like a bot crawl. It was Kagi, near as I can tell, rendering the page to build a preview for [Kagi Small Web](https://kagi.com/smallweb). I have no real objection to that. It reaches my tracker at all because it runs the JavaScript, which an ordinary crawler never would. This is exactly the sort of thing that pollutes a dataset while you sit there feeling popular.

I don't care about the bots. The point is that I only found it because I'd added logging. I only added logging because I was chasing a rate limit I probably didn't need.

## The small detail I liked most.

Nginx has a default location block and mine looks like this:

```nginx
location / {
    return 444;
}
```

`444` is an [Nginx special](https://http.dev/444#configuring-444). It doesn't return a status, but closes the connection quietly.

That rule is doing the same job as the catch-all `404` in my tunnel ingress. It holds exactly the same line only hop further in. Two independent things now have to be wrong at once before anything reaches my admin interface.

So, the tunnel is Cloudflare's. The edge rate limit is Cloudflare's. The Nginx config is a text file that's _mine_ and it doesn't care in the slightest what is in front of it. Which matters, because I'm not 100% sold on keeping Cloudflare around longer-term.

None of the idea is Cloudflare-specific either. Any reverse proxy has a path-scoped location block and any tunnel worth the name matches on more than a hostname. [Tailscale](https://tailscale.com/), [Pangolin](https://pangolin.net/), [NetBird](https://netbird.io/) or even vanilla [WireGuard](https://www.wireguard.com/) will get you there. I picked Tunnels as a convenient stop-gap until I move on to something a bit more "self-hosty". The vendor is incidental. Publish only what _has_ to be public.

## In closing ...

None of this is novel. Tunnels have matched on path since forever, it's in the [docs](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched) and I'm certain plenty of people are already doing exactly this. I just hadn't thought about it properly, because the well-trodden advice is "put an auth layer in front of it" and this kind of consensus is usually good enough that you stop thinking.

But "protect the admin panel" and "publish the admin panel and _then_ protect it" are not the same sentence and you should be aware of the difference.

If you're running anything through a tunnel right now, it's worth five minutes with your ingress config. Ask yourself which paths actually _need_ to leave your network. For a lot of self-hosted things the honest answer is "fewer than all of them". The best answer is "none". And if you'd rather find out than wonder, that `curl --resolve` trick works against any hostname, including yours. Start with `/login` and see what comes back.

And if you're about to tell me the browser permission dialog is a bug, it isn't. It's three correct systems arguing and I've decided that's a feature.

## Would you like to know more?

I kept the theory light in the post, because it kind of goes against the vibe of the site and my own casual style of writing. But the ideas underneath this are old and much better argued elsewhere, so here's where I'd start.

- [The Protection of Information in Computer Systems](https://web.mit.edu/Saltzer/www/publications/protection/), Saltzer and Schroeder, 1975. The source of *fail-safe defaults* ( deny by default, allow by exception, which is exactly what that catch-all `404` rule is ) and *economy of mechanism* ( fewer moving parts means fewer parts to get wrong ). It's a fifty year old paper and it reads like one, but section I is short and it has aged as disgracefully as I have.
- [Attack Surface Analysis](https://cheatsheetseries.owasp.org/cheatsheets/Attack_Surface_Analysis_Cheat_Sheet.html), OWASP. The plain-English version of the whole post, minus my nonsense. Useful if you want a structured way to ask "what am I actually exposing" about something larger than a script tag.
- [Tunnel configuration file](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched), Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that `path` was sitting right there next to `hostname`.
- [Local Network Access](https://wicg.github.io/local-network-access/), WICG. The spec behind the permission dialog that made me raise an eyebrow. If you'd rather have prose than a spec, Chrome's [New permission prompt for Local Network Access](https://developer.chrome.com/blog/local-network-access) covers the same ground and explains why browsers decided this needed fixing.

If you only read one, make it Saltzer and Schroeder. Almost everything I thought I worked out on that Saturday turns out to be in there, but described better by people who got to it first and who are _far_ smarter than me.
