name: tailscale-serve description: Safely expose local web services (like Hermes Dashboard, Jupyter) on a headless VPS to a user's private Tailscale network without opening public firewall ports.
When a user needs to access a web interface (like Hermes Dashboard, Jupyter Notebook, or a local dev server) running on a headless VPS from their personal devices (Mac, iPhone, etc.), use tailscale serve to expose it securely to their private Tailnet instead of binding to 0.0.0.0 or opening public firewall ports.
127.0.0.1). hermes dashboard --port 9119curl -fsSL https://tailscale.com/install.sh | sh.Run tailscale up. If it hangs waiting for authentication, run it in the background to capture the auth link:
tailscale up 2>&1 &
Provide the https://login.tailscale.com/a/... link to the user so they can authenticate the VPS into their Tailnet.
Use tailscale serve to map the local port to the Tailnet (typically port 80 for HTTP or 443 for HTTPS).
tailscale serve --bg --http 80 <local_port>
# Example: tailscale serve --bg --http 80 9119
When running tailscale serve or tailscale serve status, you may see:
Serve is not enabled on your tailnet. To enable, visit: https://login.tailscale.com/f/serve?node=...
Resolution: The Tailnet admin (usually the user) must enable the "Serve" feature. Provide the EXACT link from the error output to the user. Once they click and enable, the mapping works immediately — no need to re-run the command.
Pitfall: Nginx/HAProxy port conflicts
When attempting Nginx workarounds for Host header validation, ensure Nginx and the Dashboard do NOT try to bind to the same port (e.g. 9119). If they do, Nginx or Hermes will exit with [Errno 98] Address already in use. The Nginx listen port and proxy_pass destination port must be different.
Pitfall: dashboard.basic_auth UI missing
Even if you manage to configure Basic Auth (dashboard.basic_auth.username and password_hash in config.yaml) to satisfy the public-bind (--host 0.0.0.0) requirements, the dashboard might still crash with a 500 Internal Server Error on /auth/login?provider=basic due to missing UI templates for basic auth. Do not try to hack a public bind with basic_auth; use 127.0.0.1 and SSH tunneling.
Once tailscale serve is active, it outputs the Tailnet DNS name (e.g., http://machine-name.tailxxxx.ts.net/). You can also find the Tailscale IP via tailscale status. Provide BOTH to the user.
Problem: Some apps (Hermes Dashboard, some dev servers) validate the Host header. If you hit them via tailscale serve (e.g., http://vmi3427693.tail8c1aaf.ts.net:10000/), the app rejects it:
{"detail":"Invalid Host header. Dashboard requests must use the hostname the server was bound to."}
Root cause: The app was bound to 127.0.0.1 and only accepts Host: 127.0.0.1. Tailscale Serve preserves the original Host header — it doesn't rewrite it.
Symptom detection: curl -v http://<tailscale-ip>:<port> shows the app returns 400 with "Invalid Host header" while curl http://127.0.0.1:<port> works fine.
Workaround 1 — SSH Tunneling (Recommended & Most Reliable):
Because Hermes Dashboard has strict Host Header validation and hardens public access by enforcing authentication providers when bound to 0.0.0.0, the most reliable access path is using an SSH tunnel. Run this on your local machine:
ssh -L 9119:127.0.0.1:9119 root@<VPS_Tailscale_IP>
Then access via http://127.0.0.1:9119 locally.
Workaround 2 — Standalone Data Server (Recommended for Kanban-only view): When the user needs to view task/Kanban data over the network but Hermes Dashboard's Host Header validation blocks public access (or Basic Auth UI crashes on v0.11.0), build a lightweight standalone web server that reads the SQLite database directly. This completely bypasses Dashboard's restrictions and gives a usable Kanban column view.
# kanban_server.py — full Kanban board viewer on port 9120
#!/usr/bin/env python3
import sqlite3, json, http.server, socketserver, urllib.parse
PORT = 9120
KANBAN_DB = "/root/.hermes/kanban/boards/nv-office/kanban.db" # change per board
class KanbanHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/api/tasks":
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
conn = sqlite3.connect(KANBAN_DB)
rows = conn.execute(
"SELECT id, title, body, status, assignee, priority, created_at, completed_at "
"FROM tasks ORDER BY priority, created_at"
).fetchall()
conn.close()
data = json.dumps([dict(zip(
["id","title","body","status","assignee","priority","created_at","completed_at"], r
)) for r in rows], ensure_ascii=False)
self.wfile.write(data.encode())
else:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
html = """<!DOCTYPE html>
<html lang="vi"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>NV-Office · Kanban</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;padding:20px}
h1{font-size:24px;margin-bottom:20px;color:#333}
.board{display:flex;gap:16px;overflow-x:auto;padding-bottom:20px}
.column{min-width:280px;background:#e8e8e8;border-radius:12px;padding:12px}
.column h2{font-size:14px;text-transform:uppercase;color:#666;margin-bottom:12px}
.card{background:white;border-radius:8px;padding:12px;margin-bottom:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.card .id{font-size:11px;color:#999;margin-bottom:4px}
.card .title{font-size:14px;font-weight:600;color:#222;margin-bottom:4px}
.card .meta{font-size:12px;color:#888}
.badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;display:inline-block}
.badge-ready{background:#d3f9d8;color:#2b8a3e} .badge-todo{background:#ffd43b;color:#664d00}
.badge-running{background:#339af0;color:#fff} .badge-blocked{background:#ff6b6b;color:#fff} .badge-done{background:#51cf66;color:#fff}
</style></head><body>
<h1>📋 Kanban Board</h1>
<div class="board" id="board"></div>
<script>
const columns = {"ready":"Ready","todo":"To do","running":"In progress","blocked":"Blocked","done":"Done"};
fetch('/api/tasks').then(r=>r.json()).then(tasks=>{
const board = document.getElementById('board'); board.innerHTML = '';
for(const [status,label] of Object.entries(columns)){
const col=document.createElement('div'); col.className='column';
let h = `<h2>${label} (${tasks.filter(t=>t.status===status).length})</h2>`;
tasks.filter(t=>t.status===status).forEach(t=>{
h += `<div class="card"><div class="id">${t.id}</div><div class="title">${t.title}</div><div class="meta">@${t.assignee||'unassigned'}</div></div>`;
});
if(!tasks.some(t=>t.status===status)) h += '<div style="color:#aaa;font-size:13px">Trống</div>';
col.innerHTML=h; board.appendChild(col);
}
});
</script></body></html>"""
self.wfile.write(html.encode())
socketserver.TCPServer(("0.0.0.0", PORT), KanbanHandler).serve_forever()
Run background:
python3 /path/to/kanban_server.py
Access: http://<tailscale-ip>:9120 ✅ No Host header issues.
JSON API available at /api/tasks for integrations.
For board architecture, naming conventions, and task hierarchy setup, see the hermes-kanban skill.
Workaround 3 — Centralized Reverse Proxy (Nginx) for Host Header Bypass & Multi-Service Routing
If public access via Tailscale Funnel is required, set up Nginx as a centralized reverse proxy that:
1. Accepts all incoming traffic on one intermediate port.
2. Rewrites Host header to 127.0.0.1:<target_port> before forwarding.
3. Routes multiple backends under different paths from the same domain.
Architecture:
Tailscale Funnel (443)
└── https://vmi3427693.tail8c1aaf.ts.net/
│
└── Nginx (127.0.0.1:9121) ← intermediate proxy
├── / → Hermes Dashboard (127.0.0.1:9119) [Host rewritten]
├── /kanban-os/ → Kanban Server (127.0.0.1:9120)
└── /files/ → Kanban File Browser (127.0.0.1:9120)
Setup steps:
# 1. Create Nginx site config
sudo tee /etc/nginx/sites-available/hermes-dashboard > /dev/null << 'NGINXEOF'
server {
listen 127.0.0.1:9121;
server_name localhost;
# Custom Kanban OS
location /kanban-os/ {
proxy_pass http://127.0.0.1:9120/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# File browser (from Kanban server)
location /files/ {
proxy_pass http://127.0.0.1:9120/files/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Hermes Dashboard with Host header rewrite (bypasses validation)
location / {
proxy_pass http://127.0.0.1:9119/;
proxy_set_header Host 127.0.0.1:9119; # KEY: bypasses host validation
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
NGINXEOF
# 2. Enable and start (Ubuntu default: disabled)
sudo ln -sf /etc/nginx/sites-available/hermes-dashboard /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl start nginx && sudo systemctl enable nginx
# 3. Point Tailscale Funnel to Nginx
tailscale funnel reset
tailscale funnel --bg 9121
Access URLs (all on same Funnel domain):
- https://vmi3427693.tail8c1aaf.ts.net/ → Hermes Dashboard ✅ (no more "Invalid Host header")
- https://vmi3427693.tail8c1aaf.ts.net/kanban-os/ → Custom Kanban board viewer
- https://vmi3427693.tail8c1aaf.ts.net/files/ → Full filesystem browser (read tasks, markdown, etc.)
Injecting navigation into Dashboard HTML (Nginx sub_filter): Add links to features not native to Hermes Dashboard (file browser, Kanban OS) by injecting HTML at proxy time:
location / {
# ... proxy settings as above ...
sub_filter '</head>' '<style>.nav-bar{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;font-family:sans-serif;font-size:13px}.nav-bar a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;border:1px solid #30363d}.nav-bar a:hover{background:#1f6feb;color:#fff;border-color:#1f6feb}.nav-bar .sep{color:#30363d}</style></head>';
sub_filter '<div id="root"></div>' '<div class="nav-bar">
<a href="/">📋 Dashboard</a><span class="sep">|</span>
<a href="/files/">📁 Duyệt File</a><span class="sep">|</span>
<a href="/kanban-os/">📌 Kanban OS</a>
</div>
<div id="root"></div>';
sub_filter_once on;
}
⚠️ React SPA caveat: React re-renders <div id="root"> and may overwrite injected content. Inject the nav bar BEFORE <div id="root"> (or accept that native tools like direct URLs work without UI chrome).
Multi-route via Tailscale Serve (lighter, no Nginx):
tailscale serve --bg 9120 # / → Kanban server
tailscale serve --bg --set-path /dashboard 9119 # /dashboard → Dashboard
tailscale funnel --bg 9120 # expose both
This works, but /dashboard still hits the "Invalid Host header" error since Tailscale doesn't rewrite headers. Only Nginx can bypass that.
Nginx Troubleshooting:
- Port collision: sudo systemctl start nginx fails → lsof -i :<port> to find the other service.
- systemctl reload vs start: On Ubuntu, nginx.service is disabled by default. reload only works if already running. Always use start first, then enable.
- Duplicate MIME type warning: Remove sub_filter_types text/html; line — it's a benign duplicate.
- Funnel loss after serve config: Running serve --set-path while funnel is active may disable funnel (output: "Removing Funnel for..."). Always re-run funnel --bg <port> afterward.
# Kill stale processes on port 9119
lsof -ti:9119 | xargs kill -9
# Start dashboard (background)
hermes dashboard --port 9119 &
# Verify
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9119
# Expected: 200
# Get Tailscale IP
tailscale status
# Expose via Nginx (preferred)
# -> http://<tailscale-ip>:9119