How I Accidentally Exposed My Umami Dashboard (and What I Learned)
Recently, a few hours after setting up Umami with Docker and Nginx on my VPS, I stumbled into a misconfiguration that left the admin dashboard exposed to the public web. Thankfully, there was no immediate danger. Since right after creating Umami's docker instance, I have updated the admin username and password immediately, and locked it down before anything bad could happen. Still, it was a stressful reminder that small mistakes in deployment can have big consequences.
Here’s the story of what happened and what I learned along the way.
The Misconfiguration
My initial Docker Compose setup for Umami included:
ports:
- "0.0.0.0:3000"
This published the container on 0.0.0.0:3000, making it accessible to the entire internet. I had assumed that my firewall (UFW) would block access, but I later learned that Docker bypasses UFW with its own iptables rules. So, despite UFW saying port 3000 was closed, it was wide open.
When I tested from outside, http://myserverip:3000/dashboard
was publicly reachable.
Oops.
The Fix
To shut it down, I adjusted Docker Compose to bind only to localhost:
ports:
- "127.0.0.1:3000:3000"
This meant Umami was no longer accessible from the internet — only the host itself could reach it.
But Then: Tailnet Access Broke
Since I often access my dashboard over Tailscale (http://debian.tailXXXX.ts.net:3000/dashboard
), suddenly that stopped working. Why?
- Nginx could still access
127.0.0.1:3000
, because it’s local. - But my tailnet peers were trying to hit the server’s Tailscale IP (
100.x.x.x:3000
), where nothing was listening.
The fix: Tailscale Serve.
sudo tailscale serve --https=443 http://127.0.0.1:3000
This made Umami available at https://debian.tailXXXX.ts.net/dashboard
, privately over my tailnet, without exposing it to the public internet.
Key Lessons Learned
1. Docker ports and exposure
-
"0.0.0.0:3000"
exposes to the world, regardless of UFW. -
Safer options:
- Bind only to
127.0.0.1:3000
. - Or skip
ports:
entirely and connect via Docker network.
- Bind only to
2. Why Nginx worked but Tailnet didn’t
- Services on
127.0.0.1
are local-only. - Nginx runs on the host, so it could still reach Umami.
- Tailscale peers connect via
tailscale0
(100.x.x.x), so they couldn’t.
3. Tailscale Serve vs Funnel
tailscale serve
shares locally running services over your tailnet only.- It does not expose to the public internet.
- Public exposure only happens with Tailscale Funnel, which is opt-in.
4. Final secure setup
- Umami bound to
127.0.0.1:3000
. - Nginx proxies only
/script.js
and/api/{send,collect}
to the world. - Dashboard reachable only through Tailscale Serve.
Conclusion
What started as a small misconfiguration turned into a valuable security lesson. Always verify what’s actually exposed with ss
, docker ps
, or nmap
— don’t just assume firewalls have your back. Today, my Umami setup is both safer and cleaner: public endpoints for analytics only, private dashboard access via Tailscale.
If you’re self-hosting, learn from my mistake: don’t trust assumptions — trust verification.