Tailscale
Tailscale is a popular way to put Dashy on a private network you can reach from your phone, laptop, or any other device you've added to your tailnet, without exposing it to the public internet. With Tailscale Serve in front, requests reaching Dashy already carry the user's identity in HTTP headers, which plugs straight into Dashy's header auth for auto-login.
Headscale is a self-hosted control plane that speaks the same protocol. Everything in this guide works with both. There's a short section near the end on what changes when you swap Tailscale's coordination server for your own Headscale instance.
Contentsβ
- How it fits together
- Prepare Tailscale
- Run Tailscale + Dashy together
- Configure Dashy
- Funnel for public access
- Using Headscale instead
- Best practices
- Troubleshooting
- How it Works
How it fits togetherβ
The pieces:
tailscaledruns alongside Dashy, joins your tailnet, and obtains a free HTTPS cert for your tailnet hostname- Tailscale Serve terminates HTTPS and reverse-proxies to Dashy, adding
Tailscale-User-Login,Tailscale-User-Name, andTailscale-User-Profile-Picheaders describing the authenticated tailnet user - Dashy's header auth reads
Tailscale-User-Loginand matches it to a configured user - Traffic between user devices and your host runs over WireGuard, peer-to-peer where possible. Tailscale's control plane only mediates key exchange and routing, not the data itself
You need:
- A Tailscale account (free plan covers 100 devices and 3 users)
- An auth key, generated in the admin console
- Docker
- MagicDNS and HTTPS certificates both enabled in your tailnet settings (both on by default)
Prepare Tailscaleβ
- Sign in to the Tailscale admin console
- Open DNS and confirm MagicDNS is on, and HTTPS Certificates is on. Both are required for Tailscale Serve
- Open Settings > Keys > Generate auth key
- Pick Reusable: no, Ephemeral: no, Pre-approved: yes, optionally tag with
tag:container - Copy the key (
tskey-auth-...). You'll paste it into a.envnext to the compose file
Note your tailnet's domain (something like tail123abc.ts.net). Your Dashy host will get a hostname under it once the container starts, e.g. dashy.tail123abc.ts.net.
Run Tailscale + Dashy togetherβ
The Tailscale container shares its network namespace with Dashy, so the daemon listens on the tailnet and proxies to 127.0.0.1:8080 (Dashy in the same namespace).
Example docker-compose.yml
name: dashy-tailscale
services:
tailscale:
image: tailscale/tailscale:latest
hostname: dashy
restart: unless-stopped
environment:
TS_AUTHKEY: ${TS_AUTHKEY}?ephemeral=false
TS_STATE_DIR: /var/lib/tailscale
TS_SERVE_CONFIG: /config/serve.json
TS_USERSPACE: "false"
TS_EXTRA_ARGS: --advertise-tags=tag:container
volumes:
- ./ts-state:/var/lib/tailscale
- ./ts-config:/config
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
- sys_module
dashy:
image: lissy93/dashy:4.1.5
restart: unless-stopped
network_mode: service:tailscale
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 8080
volumes:
- ./user-data:/app/user-data
depends_on:
tailscale:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
.env next to the compose:
TS_AUTHKEY=tskey-auth-xxx...
Then ts-config/serve.json:
{
"TCP": {
"443": { "HTTPS": true }
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": { "Proxy": "http://127.0.0.1:8080" }
}
}
}
}
${TS_CERT_DOMAIN} is substituted by the Tailscale container at runtime with the hostname it gets in your tailnet (e.g. dashy.tail123abc.ts.net).
Bring it up:
docker compose up -d
The first run takes ~30 seconds while Tailscale joins the tailnet and provisions an HTTPS cert. After that, your tailnet name shows up in the admin console under Machines, and the URL becomes reachable from any device on the tailnet.
Notice what's not in the compose: Dashy has no ports: mapping. It's only reachable through Tailscale, never directly.
Configure Dashyβ
In /user-data/conf.yml:
appConfig:
...
disableConfigurationForNonAdmin: true
auth:
enableHeaderAuth: true
users:
- user: [email protected]
hash: "0000000000000000000000000000000000000000000000000000000000000000"
type: admin
- user: [email protected]
hash: "0000000000000000000000000000000000000000000000000000000000000000"
type: normal
headerAuth:
userHeader: Tailscale-User-Login
proxyWhitelist:
- 127.0.0.1
Where:
enableHeaderAuth- Turns on header auth modeusers- The email Tailscale sends inTailscale-User-Loginmust matchuserhere.typecontrols admin status in Dashyhash- Required by Dashy's user schema even though the password is never checked under header auth. Quote it so YAML doesn't parse an all-zero placeholder as the number 0userHeader- The headertailscale servesets on every proxied request, containing the tailnet user's login (typically email)proxyWhitelist- Just127.0.0.1becausetailscaledand Dashy share a network namespace, so requests appear to come from localhost
Restart Dashy after editing.
Open https://dashy.<your-tailnet>.ts.net from any device on your tailnet. You'll land on the dashboard with the right admin level for your email. No login prompt.
Funnel for public access (optional)β
Tailscale Funnel exposes a tailnet service to the public internet through Tailscale's edge. It uses the same daemon and config, you just flip a switch in serve.json:
{
"TCP": {
"443": { "HTTPS": true }
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": { "Proxy": "http://127.0.0.1:8080" }
}
}
},
"AllowFunnel": {
"${TS_CERT_DOMAIN}:443": true
}
}
You also need to enable Funnel under Settings > Funnel in the admin console and add your node to the allow-list.
Important caveat on auth: Funnel requests come from the public internet, where the caller isn't a tailnet member. Tailscale doesn't inject identity headers on Funnel traffic, only on Serve traffic from authenticated tailnet peers. Header auth in Dashy would 401 every public visitor.
For a publicly-exposed Dashy via Funnel, you have two options:
- Use Dashy's built-in HTTP auth (
ENABLE_HTTP_AUTH=truewith users inconf.yml) so the browser prompts for credentials - Front Funnel with another auth layer, or just don't expose via Funnel and stick to Serve plus the Tailscale apps on the user's devices
For a "Funnel only allows my tailnet users in, but they show up as authenticated" effect, use Serve, not Funnel. Funnel is for "I want anyone on the internet to reach this".
Using Headscale insteadβ
Headscale is a self-hosted reimplementation of Tailscale's coordination server, compatible with the official Tailscale clients. Run your own and you don't depend on Tailscale's cloud for control-plane traffic.
For Dashy, everything in this guide applies unchanged. The only differences are:
- Point the
tailscaleddaemon at your Headscale server via theTS_EXTRA_ARGSenv var:TS_EXTRA_ARGS: --login-server=https://headscale.example.com --advertise-tags=tag:container - Generate auth keys on the Headscale server (
headscale preauthkeys create --user <user>) instead of the Tailscale admin console - ACLs are defined in Headscale's policy file rather than the Tailscale admin UI
- MagicDNS HTTPS certificates require Headscale β₯0.23. Older versions don't auto-provision certs, so you'd need to terminate TLS yourself with a reverse proxy. Funnel is also Headscale β₯0.23
The serve.json shape, the Dashy header-auth config, and the identity headers (Tailscale-User-Login etc.) are identical. Headscale uses the same protocol, so the client behaves the same way.
Best practicesβ
- Don't bind Dashy's port to the host. Only
tailscaledshould reach it, which is the case if you leaveports:off the dashy service and usenetwork_mode: service:tailscale - Treat the auth key as a credential. Tag it (
tag:container) and rotate it periodically. Prefer non-ephemeral keys for the Dashy node so it survives restarts - Use Tailscale ACLs to limit who in your tailnet can reach the Dashy node, especially for shared organisations. Configure under Access controls in the admin console
- Pin
tailscale/tailscaleto a version tag in production rather than:latest. The Docker image follows the daemon's release cadence - Match Dashy's
users[]to actual tailnet emails. Every tailnet member who should reach Dashy needs an entry. Promote to admin by settingtype: admin - For self-hosting purists, Headscale removes the dependency on Tailscale's coordination server. Otherwise prefer the hosted plan, the free tier is generous and the engineering team owns reliability for you
Troubleshooting common Tailscale issuesβ
Container starts but no machine appears in the Tailscale admin consoleβ
Problem: docker compose logs tailscale shows the daemon running but it never registers.
Solution: The auth key is wrong, expired, or the wrong type. Generate a fresh Reusable: No, Ephemeral: No, Pre-approved: Yes key and update .env. Auth keys are single-use unless marked reusable.
"no HTTPS server configured for ${TS_CERT_DOMAIN}"β
Problem: Serve config references the env-substituted hostname but Tailscale didn't substitute it.
Solution: Older tailscale/tailscale images didn't expand ${TS_CERT_DOMAIN} in serve.json. Update to a recent image (v1.66+ or latest), or hardcode your tailnet hostname in serve.json (dashy.tail123abc.ts.net:443).
HTTPS cert never issuedβ
Problem: Browser shows a TLS error visiting the tailnet URL.
Solution: HTTPS certificates aren't enabled for your tailnet. Open DNS in the admin console and turn on both MagicDNS and HTTPS Certificates. Cert issuance takes about a minute after serve.json is applied.
"401 Unauthorized - not from trusted proxy" from Dashyβ
Problem: Reaches Dashy but the header auth middleware rejects it.
Solution: tailscaled and Dashy must share a network namespace via network_mode: service:tailscale for the source IP to be 127.0.0.1. If they're on different networks, the source IP will be the Tailscale container's IP instead. Update proxyWhitelist accordingly, or fix the compose to share the namespace.
"401 Unauthorized - missing user header"β
Problem: Source IP check passes, but Tailscale-User-Login isn't present.
Solution: The request didn't come through tailscale serve (maybe Funnel, maybe a direct hit on the tailnet IP without going via Serve). Confirm serve.json is loaded by running docker compose exec tailscale tailscale serve status and re-issuing the request through the HTTPS URL.
Logged in but admin features stay lockedβ
Problem: Authenticates fine, but admin actions return 403.
Solution: The email in Tailscale-User-Login doesn't exactly match a users[].user with type: admin. Run docker compose exec tailscale tailscale whois <your-tailnet-ip> to see what login Tailscale will pass through.
Funnel users see 401β
Problem: You enabled AllowFunnel, but visitors from outside the tailnet get 401.
Solution: Expected. Funnel traffic doesn't carry tailnet identity, so header auth has nothing to match. Either drop header auth and use built-in HTTP auth for Funnel-exposed Dashy, or only use Funnel for fully-public read access (no auth, guest mode).
Tailscale container exits with "cannot allocate memory" or DEVICE_RESETβ
Problem: Crash loop on a low-memory host.
Solution: Either bump memory, or set TS_USERSPACE: "true" to use the userspace WireGuard implementation (slower but no kernel module dependency). Remove the /dev/net/tun volume mount and the net_admin and sys_module capabilities when in userspace mode.
MagicDNS hostname doesn't resolve from the device I'm usingβ
Problem: Other tailnet devices can't reach the Dashy URL even though the machine shows up in the admin console.
Solution: Confirm MagicDNS is on globally (admin console DNS) and on the client (tailscale netcheck and the Tailscale app's settings). On Linux clients add --accept-dns=true to tailscale up. Older Linux setups sometimes need nameserver 100.100.100.100 in /etc/resolv.conf.
Headscale setup, certs not issued automaticallyβ
Problem: Using Headscale, the tailnet hostname works but HTTPS isn't auto-provisioned.
Solution: Headscale's auto-cert support requires version 0.23 or newer. Either upgrade Headscale, or run your own reverse proxy with HTTPS (Caddy, Traefik) in front of the Tailscale daemon and route to it as you would any other internal service.
Config change to auth.headerAuth not picked upβ
Problem: Updated userHeader, proxyWhitelist, or users in conf.yml, but Dashy still uses the old values.
Solution: The server reads the auth config only at boot. Restart the Dashy container after any change.
How it Worksβ
Server sideβ
tailscaled runs WireGuard for the data plane and a small HTTPS server in the same container for Tailscale Serve. When a tailnet peer hits https://dashy.<tailnet>.ts.net, the request is delivered over WireGuard directly to your host. tailscaled terminates TLS using the cert it auto-provisioned, then makes an internal HTTP request to http://127.0.0.1:8080 (Dashy in the same namespace), adding three headers:
Tailscale-User-Login- the user's tailnet login, typically their emailTailscale-User-Name- the display nameTailscale-User-Profile-Pic- a URL to their avatar
Dashy's header auth middleware checks req.socket.remoteAddress against proxyWhitelist (which is 127.0.0.1 because of the shared namespace), reads Tailscale-User-Login, and sets req.auth = { user: <email> }. The SPA's HeaderAuth.js then fetches /get-user, matches the email to users[], and sets the session cookie. Same code path as the Cloudflare Tunnel guide, just a different proxy and a different header name.
Identity flows from the user's tailnet membership all the way to Dashy's role-based UI without the user ever entering a password.