Publishing an internal app with no open inbound ports
A working Cloudflare Tunnel setup: named tunnel, ingress rules, an Access policy in front, and the two mistakes that leave an internal app on the public internet.
The pitch for Cloudflare Tunnel is that you stop listening for inbound connections entirely, letting Cloudflare run as a reverse proxy for your app, removing the need for port forwarding.
The setup
Install cloudflared on the host that can reach the app, authenticate it once against your zone, and create a named tunnel:
cloudflared tunnel login
cloudflared tunnel create internal-apps
cloudflared tunnel route dns internal-apps grafana.example.com
tunnel create writes a credentials file and prints a tunnel UUID. tunnel route dns creates a proxied CNAME pointing at <uuid>.cfargotunnel.com - that record only resolves through Cloudflare, which is the point.
Then the config, at /etc/cloudflared/config.yml:
tunnel: internal-apps
credentials-file: /etc/cloudflared/internal-apps.json
ingress:
- hostname: grafana.example.com
service: http://localhost:3000
- hostname: legacy.example.com
service: https://10.0.4.20:8443
originRequest:
# Internal cert is self-signed; the hop stays inside the VPC.
noTLSVerify: true
httpHostHeader: legacy.internal
# Ingress rules are matched in order and the last one MUST be a catch-all.
- service: http_status:404
Run it as a service and you are serving:
cloudflared service install
systemctl enable --now cloudflared
Two things worth knowing about this file. Rules are evaluated top to bottom, first match wins, so put specific hostnames above general ones. And the final entry must be a catch-all with no hostname - without it, cloudflared refuses to start rather than guessing.
For high availability, run the same tunnel on two or more hosts. Replicas of one tunnel share the hostname and Cloudflare load-balances across the connections, so a host reboot is not an outage.
Mistake one: assuming the tunnel is the security control
It is not. A tunnel is transport. grafana.example.com is now a public hostname that anybody on the internet can request, and the only thing between them and your Grafana login page is Grafana’s login page.
The access control is a separate product. In Zero Trust → Access → Applications, add a self-hosted application for the hostname and attach a policy:
- Action: Allow
- Include: Emails ending in
@example.com, and a device posture or country rule if you have one - Require: your identity provider group, e.g.
platform-engineers
Now unauthenticated requests never reach cloudflared at all - they get an identity challenge at the edge. Cloudflare mints a signed JWT in a cookie after login and the application only sees requests carrying it.
Do this before you create the DNS route, not after.
Mistake two: trusting the JWT is enough
Access stops anonymous traffic reaching the origin, but the origin is still reachable by anything else that can route to it. If someone gets onto your internal network, or another service on the same host is compromised, localhost:3000 has no authentication in front of it.
Two hardening steps that cost very little:
- Validate the Access JWT at the origin. Cloudflare sends it in the
Cf-Access-Jwt-Assertionheader; verify the signature against your team’s public keys and reject requests without it. That makes the app refuse traffic that did not come through Access, even from inside. - Bind the app to loopback, not
0.0.0.0, socloudflaredis genuinely the only path in.
For service-to-service calls where there is no human to authenticate, use a service token and a policy that accepts it, rather than punching an exception in the policy for a CIDR.
Beyond HTTP
ingress handles HTTP(S). For SSH, RDP, SMB or arbitrary TCP to a private subnet, the model is different: add the network CIDR to the tunnel and let WARP-enroled clients route to it.
cloudflared tunnel route ip add 10.0.4.0/24 internal-apps
Users then reach 10.0.4.20:22 directly, with Gateway policies deciding who may.
Verifying it, properly
Do not test from a machine that is already on the network. From an unrelated connection with WARP off:
# Should return a 302 to the Access login, not your app.
curl -sI https://grafana.example.com | head -n 1
# Should find nothing listening.
nmap -Pn -p 80,443,3000 <your-public-ip>
If the first command returns your application’s HTML, the Access policy is not attached to the hostname you think it is.