Cloudflare Tunnel
Dashy works well behind Cloudflare Tunnel with Cloudflare Access doing auth at the edge. This is one of the most common ways to expose a homelab dashboard to the public internet without opening any inbound ports, without managing TLS, and without standing up your own identity provider.
The flow: Cloudflare authenticates the user (via Google, GitHub, email OTP, etc.) at the edge, signs an identity into request headers, and forwards the request to a cloudflared daemon you run on your network. cloudflared proxies to Dashy. Dashy reads the email from the header and matches the user against its config.
Contentsβ
- 1. How it fits together
- 2. Create the tunnel in Cloudflare
- 3. Run cloudflared
- 4. Add a Cloudflare Access policy
- 5. Configure Dashy
- 6. Best practices
- Troubleshooting
- How it Works
1. How it fits togetherβ
Three Cloudflare pieces, plus Dashy on your side:
- Cloudflare Tunnel exposes Dashy to the public internet over an outbound-only connection. No port forwarding, no inbound firewall rules
- Cloudflare Access sits in front of the tunnel and enforces an identity policy. Anyone reaching the tunnel has already authenticated to Cloudflare
cloudflaredis the daemon you run alongside Dashy. It dials out to Cloudflare and forwards authenticated requests to Dashy's local port- Dashy's header auth reads the email Cloudflare sets in
Cf-Access-Authenticated-User-Emailand matches it to a user
You need:
- A Cloudflare account
- A domain on Cloudflare (any TLD, free plan is fine)
- Docker (or any way to run
cloudflared) - Free tier covers up to 50 Access users
2. Create the tunnel in Cloudflareβ
Cloudflare's dashboard handles most of this.
- Go to Zero Trust dashboard
- Open Networks > Tunnels
- Click Create a tunnel
- Pick Cloudflared as the connector, click Next
- Name it (e.g.
homelab), click Save tunnel - Copy the tunnel token shown on the install screen. You'll paste this into Docker compose in the next step. Keep it secret, it grants tunnel access
- Click Next without installing the package on the host (Docker is easier)
- Add a Public hostname:
- Subdomain: e.g.
dashy - Domain: your Cloudflare domain
- Type:
HTTP - URL:
dashy:8080(the in-network service name + port Dashy listens on)
- Subdomain: e.g.
- Click Save
Cloudflare now routes https://dashy.example.com through the tunnel to your cloudflared daemon, which forwards to dashy:8080 on whatever Docker network they share.
3. Run cloudflaredβ
A minimal compose with Dashy + cloudflared together:
Example docker-compose.yml
name: dashy-cfaccess
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
dashy:
condition: service_healthy
dashy:
image: lissy93/dashy:4.1.5
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 8080
volumes:
- ./user-data:/app/user-data
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
Set the token via env or a .env file alongside the compose:
CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiMDF...rest-of-token
Bring it up:
docker compose up -d
Notice what's not in the compose: Dashy has no ports: mapping. It's only reachable from inside the Docker network, which means only cloudflared can talk to it. That's the first half of the security model.
4. Add a Cloudflare Access policyβ
Without Access, the tunnel is open to anyone who knows the URL. Add a policy so Cloudflare enforces auth at the edge.
- In Zero Trust, open Access > Applications
- Click Add an application
- Choose Self-hosted
- Set Application name to
Dashy - Set Session duration to whatever feels right (e.g. 24 hours)
- Under Application domain, pick the subdomain and domain you used in step 2 (
dashy.example.com) - Click Next
- Add a policy:
- Policy name:
Trusted users - Action:
Allow - Include:
Emails ending in @example.com, orEmailswith specific addresses, orEveryonefor open access
- Policy name:
- Optionally add a second policy for admins with stricter rules
- Click Next and Add application
Cloudflare now bounces anyone visiting https://dashy.example.com to its login page first, only forwarding the request after auth.
5. 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: Cf-Access-Authenticated-User-Email
proxyWhitelist:
- 172.18.0.2
Where:
enableHeaderAuth- Turns on header auth modeusers- Required. The email the proxy sends must matchuserhere for the user to be admitted. Thetypefield controls admin status in Dashyhash- Required by Dashy's user schema even though the password is never checked under header auth. Quote it to stop YAML parsing the all-zero placeholder as the number 0userHeader- The header Cloudflare Access sets after a user authenticates. Matches case-insensitivelyproxyWhitelist- Required. The IPcloudflaredconnects from. Find it withdocker compose exec dashy getent hosts cloudflaredor checkdocker network inspect. This is the single biggest control: requests from any other IP get 401 before headers are even read
Restart Dashy after editing.
Open https://dashy.example.com. Cloudflare bounces you to its login page, you authenticate, and you land on the Dashy dashboard auto-logged-in as that email.
6. Best practicesβ
- Don't bind Dashy's port to the host. Only
cloudflaredshould reach it, which is the case if you leave theports:block out of the dashy service - Treat the tunnel token as a credential. Anyone with it can connect a
cloudflaredto your tunnel and impersonate it - Set
proxyWhitelistto the smallest possible set of IPs. For Docker compose that's typically just the onecloudflaredcontainer address - Pin
cloudflaredto a version tag in production rather than:latest. Cloudflare updates the daemon often - Configure Access policies before public release. A tunnel without an Access policy is wide open
- Use Access groups in the dashboard for "who is an admin" rather than relying solely on Dashy's
type: admin, so identity changes don't require redeploying Dashy - For machine-to-machine access (CI, healthchecks, monitoring), use Access Service Tokens and a separate bypass policy keyed on the token. Don't try to share user credentials
- If Dashy and
cloudflaredaren't on the same Docker network, force them on one with an explicitnetworks:block
Troubleshooting common Cloudflare Tunnel issuesβ
401 Unauthorized from Dashy after the Cloudflare login screenβ
Problem: You authenticate at Cloudflare, get redirected to Dashy, but see "Unauthorized - not from trusted proxy".
Solution: proxyWhitelist doesn't include cloudflared's container IP. Run docker compose exec dashy getent hosts cloudflared to find it, paste that IP into proxyWhitelist, restart Dashy.
"Unauthorized - missing user header"β
Problem: Server logs show that header auth ran but the user header was empty.
Solution: The request reached Dashy from a trusted IP but without Cf-Access-Authenticated-User-Email. This usually means the tunnel doesn't have an Access policy in front of it, so traffic is reaching Dashy unauthenticated. Open the Access application in Zero Trust and confirm there's at least one policy attached.
User logs in but admin features stay lockedβ
Problem: You authenticate, the dashboard renders, but Save Config and other admin actions are disabled.
Solution: The email Cloudflare sends must exactly match a users[].user entry with type: admin. Check the JWT in Cf-Access-Jwt-Assertion (decode at jwt.io) to see the actual email Cloudflare is sending. Often a personal Google account vs work account mismatch.
Loop or repeated redirects to the Cloudflare login pageβ
Problem: Each refresh bounces back through Cloudflare's login.
Solution: Session cookies are being blocked or stripped. Check that the tunnel's public hostname uses HTTPS (it does by default), that no upstream proxy strips third-party cookies, and that the Access application's session duration is non-zero.
"Critical Configuration Load Error" on first loadβ
Problem: SPA errors out before showing the dashboard.
Solution: Usually a CORS or network reachability issue, but for Cloudflare Tunnel this is most often a cloudflared to dashy networking problem inside Docker. docker compose logs cloudflared will show connection attempts. Confirm both services are on the same Docker network and that the tunnel's public hostname URL matches the service name and port (e.g. dashy:8080, not localhost:8080).
cloudflared can't connect to the tunnelβ
Problem: cloudflared keeps logging connection errors.
Solution: Check the token. Tokens are tunnel-specific, and rotating one in the dashboard invalidates the previous one. If you recreated the tunnel, paste the new token into .env and restart.
Schema warning "hash must be string" on Dashy startupβ
Problem: Dashy logs warn about user hashes not being strings.
Solution: YAML parsed an all-zeros (or all-digit) hash as a number. Wrap the value in quotes: hash: "0000..." rather than hash: 0000....
Want to bypass Cloudflare Access for automationβ
Problem: A monitoring or CI agent needs to hit /healthz or scrape Dashy, but Cloudflare Access blocks it.
Solution: In the Access application, add a Service Token, then create a Bypass policy keyed on that token. The agent sends Cf-Access-Client-Id and Cf-Access-Client-Secret headers to authenticate as a machine. Don't share user logins.
How it Worksβ
Server sideβ
When Dashy has enableHeaderAuth: true, services/app.js installs a small middleware in front of every API and asset route. The middleware does two things per request:
- Check
req.socket.remoteAddressagainstproxyWhitelist. If the source IP isn't in the list, return 401 immediately. The header is never even inspected - Read the header named by
userHeader(case-insensitive). If empty, return 401. Otherwise setreq.auth = { user: <email> }and let the request through
The IP check is the security boundary. Cloudflare adds Cf-Access-Authenticated-User-Email only after a successful Access policy match, and cloudflared is the only thing that can talk to Dashy (no exposed port). So the only way that header can be set on a request reaching Dashy is via the Cloudflare to cloudflared to dashy path, after a real Cloudflare Access authentication.
The SPA fetches /get-user once on load, which returns whatever email the server saw. The client-side code in src/utils/auth/HeaderAuth.js matches that email against the configured users[] to determine type: admin vs normal, then sets the session cookie. The route guard in src/router.js skips its usual /login bounce when enableHeaderAuth is on, since the SPA can't tell "not logged in" from "header auth still resolving" and the server is authoritative anyway.