entry-15
A public status page with a private admin
Deploying Uptime Kuma behind Caddy on the public VPS: HTTPS and headers validated from outside, backend ports left closed, and the admin account claimed over a private SSH tunnel before the certificate made the hostname discoverable.
revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 5 min read
series / VPS part 3 of 4 all parts → ← prev next →
Phase 3 of the VPS lab put a service on the public internet for the first
time. status.stayz3ro.dev is Uptime Kuma behind Caddy, and the goal was a
dashboard anyone can load over HTTPS while the admin side of it stays off
the public surface.
Historical note, 2026-10-04. This describes the August 2026 deployment at
status.stayz3ro.dev, and that hostname now redirects. The public status page is atstatus.chrisalorenzo.com, and the admin UI is reached only through a tailnet-only Tailscale Serve path with a loopback backend. The steps below are kept as they were run in August.
I already wrote about the DNS problem that turned the deploy into a night. This is the deployment side: what the stack looks like, what validating it from outside actually proved, and the order I brought it up in.
The stack
Two containers on one Docker network called web:
caddypublishes ports 80, 443, and 443/udp for HTTP/3. It terminates TLS, and both its config and its certificates live in named volumes so they survive a restart or a rebuild.uptime-kumapublishes nothing at all. It is reached asuptime-kuma:3001across the internalwebnetwork, and that name appears once in the Caddyfile:
status.{$SITE_DOMAIN} {
import security_headers
reverse_proxy uptime-kuma:3001
}
Not publishing a port is the part that matters. Caddy is the only process on the host listening on a public port, and the only route to Kuma is through it. The same file disables Caddy’s own local admin API and writes structured JSON access logs to a file on a mounted volume.
The first-run window
Uptime Kuma’s first-run setup is unauthenticated by design. The first browser to load that screen creates the admin account, which is fine locally and a race on a public hostname. The moment Let’s Encrypt issues a certificate for a hostname, that hostname is published to Certificate Transparency logs, and those logs are scanned continuously by bots looking for exactly this kind of half-configured service.
So there are two windows that must not overlap: the one where the setup screen is still claimable, and the one after the certificate makes the hostname findable. The safe order is to close the first window before opening the second, which means the admin account exists before the site has a public URL at all.
Claiming the account over a tunnel
Docker’s bridge network means the host can reach a container directly even when that container publishes no ports. A local SSH forward does the rest:
ssh -L 3001:<container-ip>:3001 <vps-host>
With that forward open, http://localhost:3001 travels through the SSH
connection to Kuma’s port inside its container, bypassing the public
hostname, Caddy, and the certificate entirely. The admin account and
password were created there, over Tailscale and SSH, before the hostname
resolved to anything publicly useful.
There is no screenshot of this step by design. The point of doing it over a tunnel is that it never touches the public path, so there is nothing on the public side to capture. The longer version of how that night went, including the DNS panel that was not authoritative and the tunnel command in its original context, is its own post.
Validating it from outside
Once the status record existed in the live DNS zone, Caddy requested the
certificate with the tls-alpn-01 challenge and started serving. All the
validation ran from the workstation, not from the VPS, because a request
from the box to itself says nothing about what the internet sees.
curl -I http://status.stayz3ro.dev
curl -I https://status.stayz3ro.dev
curl -Iv https://status.stayz3ro.dev 2>&1 | grep -E 'subject:|issuer:|SSL certificate verify'
Plain HTTP returned 308 Permanent Redirect to the HTTPS URL. HTTPS
returned 200. The certificate showed CN=status.stayz3ro.dev with a
Let’s Encrypt issuer and verified cleanly.
The HTTPS response headers came back as configured:
| Header | Value |
|---|---|
strict-transport-security | max-age=31536000; includeSubDomains |
x-content-type-options | nosniff |
x-frame-options | DENY |
referrer-policy | strict-origin-when-cross-origin |
Server was absent, so the response does not advertise that it is Caddy.
A browser load confirmed the padlock and the Kuma dashboard behind it.
Confirming nothing else answers
The last check is what else on the box responds from outside. Port 3001 is Kuma’s own port and 3000 was an application port from an earlier iteration of the plan. Both were probed directly by IP:
curl -sS -m 5 http://<public-ip>:3000 ; echo "exit=$?"
curl -sS -m 5 http://<public-ip>:3001 ; echo "exit=$?"
Both non-zero, timing out rather than answering. Only 80 and 443 respond on the public IP, and SSH is not in the picture at all because Phase 2 moved it to Tailscale-only. An uptime dashboard that quietly exposes its own backend port is not much of a win, so this was worth proving rather than assuming from the compose file.
Closing note
The status page and its admin are the same application, but they do not have to travel the same path. Publishing the read side through a reverse proxy while claiming the admin side over a private tunnel kept the unauthenticated setup window from ever existing on the public internet.
That was the August setup. The tunnel was an SSH forward. Today the public
page is status.chrisalorenzo.com, and the admin UI sits on a tailnet-only
Tailscale Serve path with a loopback backend. The principle has not changed:
the read side is public, and the admin side is private.
The ordering is the transferable part. For any self-hosted app with an unauthenticated first run, secure it over a private path before the certificate makes the hostname discoverable, because the bots that watch Certificate Transparency logs are faster than you are. The Phase 4 runbook reuses the same sequence for the next service, which is the sign this one held up as a pattern rather than a one-off fix.
Related posts: Moving the blog and status page to a new domain.