I wanted a quick way to glance at one of my servers from my phone — CPU, memory, disk — and to fire off the occasional admin action like "apply updates" or "reboot," without SSHing in. The obvious move is a little web dashboard. The obvious problem is that a control panel you can reach from your phone is also a control panel an attacker can reach from theirs.
So here's the approach I settled on, and I'll show you how to build it: a small static status page plus a tiny "actions" API, published only inside my Tailscale network. Nothing is exposed to the public internet. Tailnet membership is the authentication — if a device isn't signed into my tailnet, the page simply doesn't exist for it.
The security model first
This is the part that matters, so let's be clear about it before any code:
- Tailscale Serve publishes to your tailnet only — not the internet. (The internet-facing feature is called Funnel; we are deliberately not using it.) Only your own logged-in devices can load the page.
- Automatic HTTPS. Serve gives you a real Let's Encrypt certificate for your machine's
*.ts.netname, so it'shttps://with no cert warnings and no manual setup. - The control API binds to
127.0.0.1only. It never listens on a public interface. The only path to it is through Tailscale. - Destructive actions are confirmed on the server, not just in the browser — so a stray request can't reboot the box.
Put together: no public surface, identity-based access, least privilege. Let's build it.
Prerequisites
- A Linux server with Tailscale installed and connected (
tailscale up). - MagicDNS + HTTPS certificates enabled for your tailnet (one toggle each in the Tailscale admin console). Serve needs these to issue the
*.ts.netcert.
Find your machine's name with:
tailscale status
It'll look like your-server.tailXXXX.ts.net — that's the URL your dashboard will live at.
Part 1 — collect the stats (no root needed)
The status page is just a static file that reads a small JSON snapshot. A shell script generates that snapshot on a timer. It only reads system info, so it runs as a normal user — no privileges required.
#!/usr/bin/env bash
# /usr/local/bin/server-stats.sh — snapshot server stats to JSON. No root needed.
OUT=/var/www/status/stats.json
read -r l1 l5 l15 _ < /proc/loadavg
read -r memtotal memused < <(free -m | awk '/^Mem:/{print $2" "$3}')
read -r dsize dused dpct < <(df -BG --output=size,used,pcent / | awk 'NR==2{gsub(/[G%]/,"");print $1" "$2" "$3}')
up=$(cut -d. -f1 /proc/uptime)
mkdir -p "$(dirname "$OUT")"
cat > "$OUT" <<JSON
{"load":[$l1,$l5,$l15],"mem_used":$memused,"mem_total":$memtotal,
"disk_used":$dused,"disk_total":$dsize,"disk_pct":$dpct,"uptime":$up,
"updated":"$(date -u +%FT%TZ)"}
JSON
Make it executable, then run it on a schedule with a systemd timer. Two small unit files do this. First the service:
# /etc/systemd/system/server-stats.service
[Unit]
Description=Collect server stats snapshot
[Service]
Type=oneshot
User=youruser
ExecStart=/usr/local/bin/server-stats.sh
Then the timer that fires it every 10 seconds:
# /etc/systemd/system/server-stats.timer
[Unit]
Description=Run server-stats every 10s
[Timer]
OnBootSec=10
OnUnitActiveSec=10
[Install]
WantedBy=timers.target
Enable it:
sudo systemctl enable --now server-stats.timer
Part 2 — the status page
The page is plain HTML that fetches stats.json and re-renders every few seconds. Here's the core of it — wire the rest of your fields the same way:
<div id="load">–</div>
<script>
async function refresh() {
const d = await (await fetch("/stats.json?_=" + Date.now())).json();
document.getElementById("load").textContent = d.load.join(" ");
// …render mem_used / disk_pct / uptime the same way
}
refresh();
setInterval(refresh, 10000);
</script>
Save your page as /var/www/status/index.html (same folder the collector writes into). That's the whole front end — no framework, no build step.
Part 3 — publish it privately with Tailscale Serve
This is the magic. One command serves that directory to your tailnet over HTTPS:
tailscale serve --bg /var/www/status
--bg keeps it running in the background (and it persists across reboots). Your page is now at https://your-server.tailXXXX.ts.net/ — reachable from any device on your tailnet, and invisible to everything else. Check what's published anytime:
tailscale serve status
https://your-server.tailXXXX.ts.net (tailnet only)
|-- / path /var/www/status
That "tailnet only" line is the whole point. To undo everything later, tailscale serve reset.
Note: On a phone, open that
https://…ts.netURL in your browser and use "Add to Home Screen" — with a web app manifest it behaves like a native app.
Part 4 — add control actions, safely
Reading stats is harmless. Running apt upgrade or rebooting is not — so this part needs care. The pattern: a tiny API that listens only on 127.0.0.1, run as root by systemd, and reached from the page through Tailscale via a /api path. It never touches a public interface.
#!/usr/bin/env python3
# /usr/local/bin/server-actions.py — privileged actions, bound to localhost only.
import json, subprocess, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
path = self.path.rstrip("/")
if path.startswith("/api"): # strip the /api mount prefix if present
path = path[4:] or "/"
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
if path == "/update":
subprocess.Popen(["bash", "-c", "apt-get update && apt-get upgrade -y"])
return self._send(200, {"started": True})
if path == "/reboot":
if body.get("confirm") != "reboot": # server-side guard
return self._send(400, {"error": "type 'reboot' to confirm"})
threading.Thread(
target=lambda: (time.sleep(1), subprocess.run(["/sbin/reboot"])),
daemon=True).start()
return self._send(200, {"ok": True})
self._send(404, {"error": "not found"})
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 9000), Handler).serve_forever() # localhost ONLY
Two things in there are doing the security heavy-lifting. The bind address ("127.0.0.1", 9000) means nothing off the box can reach it directly. And the /reboot handler refuses unless the request body literally contains {"confirm": "reboot"} — so the confirmation is enforced on the server, independent of whatever the browser does.
Run it as a service:
# /etc/systemd/system/server-actions.service
[Unit]
Description=Server action endpoint (localhost only)
After=network.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/server-actions.py
User=root
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now server-actions.service
Now mount that localhost service into your tailnet under /api, alongside the status page:
tailscale serve --bg --set-path=/api http://127.0.0.1:9000
The page can now call the actions with ordinary fetch() — same origin, so no CORS, no extra auth to wire up:
// apply updates
fetch("/api/update", { method: "POST" });
// reboot — must send the confirmation the server demands
fetch("/api/reboot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "reboot" }),
});
In the UI, gate that reboot button behind a little modal that makes you type the word reboot before it sends. The browser check is for you; the server check is what actually keeps you safe.
Part 5 — a friendly public URL (optional)
A *.ts.net hostname isn't memorable. If you want status.example.com to take you there, point a normal web server at a redirect — not the content. Here's a Caddy block:
status.example.com {
redir https://your-server.tailXXXX.ts.net{uri}
}
The public hostname only ever emits a redirect; the actual page still loads solely on tailnet devices, because the target is a *.ts.net address. Anyone not on your tailnet just gets bounced to a URL their machine can't resolve. No content is ever exposed.
Want to lock it down further?
Tailscale ACLs let you restrict which of your own devices can reach the server at all — so even within your tailnet, only your phone and laptop can load it, for example. And because the actions service runs as root, keep its surface tiny: a fixed set of named actions, no shell passthrough, no user-supplied commands. Every action should be something you'd be comfortable hard-coding.
Wrapping up
That's the whole technique: a static page reading a JSON snapshot, a minimal localhost-only API for the dangerous bits, and Tailscale Serve stitching both onto a private, HTTPS, tailnet-only URL. You get a phone-friendly status-and-control panel with effectively zero public attack surface — the dashboard literally doesn't exist for anyone outside your network.
If you've already set up passwordless SSH, this pairs nicely with it: SSH for deep work, the dashboard for a quick glance and the occasional one-tap action.
Hope this helps!