External Access with Cloudflare Tunnel & NPM¶
This is how remote access works in my setup today. One cloudflared connector carries every public hostname to
Nginx Proxy Manager (NPM), and NPM routes each name to its service. A wildcard DNS route and a wildcard Cloudflare
Access application sit in front, so every request has to pass the Access login before it reaches NPM.
In practice: add a proxy host in NPM under the public domain and it's reachable from outside (behind Access) right away. No extra DNS records, no extra tunnel routes.
Why this design
It replaced one tunnel per host. The trade-offs are listed on that page; the short version is that routing now lives in one place (NPM, next to the LAN names), there's one token and one connector, and NPM can reach services on every host over the LAN. The price is that NPM and the connector are the only way in from outside, and that anything added to NPM under the public domain is published at once. That's why both get extra care (below).
Not everything goes through the tunnel. Plex handles its own remote access. Streaming video through the free tunnel goes against Cloudflare's CDN terms, and the Plex apps can't complete an Access login anyway.
Cloudflare Tunnel¶
1. Create the Tunnel¶
-
Log in to the Cloudflare Zero Trust Dashboard
-
Navigate to Networks > Connectors > Cloudflare Tunnels
-
Click Create a tunnel, choose Cloudflared as the connector type, and select Next
-
Enter a name for the tunnel (e.g. the host name,
<YOUR_HOSTNAME>) -
Click Save tunnel
-
On the Install connector page, select Docker and copy the token from the provided command: it's the long string after
--token
2. Create the shared network¶
cloudflared and NPM talk over an external Docker network. Create it once on the host:
Containers that NPM should reach by name (instead of by LAN IP and port) join it too.
3. Deploy the connector¶
Run cloudflared as its own stack. This is the only place the tunnel token lives.
/opt/docker/cloudflared/compose.yaml
services:
cloudflared:
image: cloudflare/cloudflared:2026.10.3 # pinned; updates arrive as reviewed PRs
container_name: cloudflared
restart: unless-stopped
command: tunnel run
env_file:
- .env
dns:
- <YOUR_DNS_SERVER_IP>
networks:
- tunnel-net
networks:
tunnel-net:
external: true
No healthcheck on cloudflared
The image is distroless (no shell, curl or wget), so there's nothing to run a CMD-SHELL check with.
Monitor the whole path from outside instead, see Monitoring.
Nginx Proxy Manager¶
1. Create the container¶
/opt/docker/npm/compose.yaml
services:
npm:
image: jc21/nginx-proxy-manager:2.16.0 # pinned; updates arrive as reviewed PRs
container_name: npm
restart: unless-stopped
dns:
- <YOUR_DNS_SERVER_IP>
ports:
- "80:80"
- "127.0.0.1:81:81" # admin UI is plain HTTP: keep it off the LAN/internet (or bind to <YOUR_SERVER_IP>)
- "443:443"
volumes:
- /mnt/appdata/npm/data:/data
- /mnt/appdata/npm/letsencrypt:/etc/letsencrypt
networks:
- tunnel-net
healthcheck:
test: ["CMD", "/usr/bin/check-health"] # ships with the image
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
networks:
tunnel-net:
external: true
Ports 80/443 are for LAN clients (Internal access). Tunnel traffic doesn't need them: cloudflared
reaches npm:80 over tunnel-net.
dns: matters for a reverse proxy
NPM resolves upstream names when requests come in. Point it at your LAN resolver so .<YOUR_LOCAL_DOMAIN> names
work. If that resolver rate-limits, a busy proxy gets dropped queries, which show up as random 502s:
see DNS rate limiting a whole server subnet.
Zero Trust Application¶
To keep published sites behind Cloudflare Access, create an Access application and an Access policy.
Per the official docs, the Access application is what controls who can reach the published route. Access applications are deny-by-default: a user must match an Allow policy to get in.
-
Navigate to Access controls > Applications
-
Click Add an application → Self-hosted
-
Configure:
Field Value Application name e.g. HomelabSession Duration 30 daysis convenient for personal use; use less for admin appsApplication domain *.<YOUR_DOMAIN> -
Click Policies tab
Create an Access Policy¶
Access Policies
There are many options for the access policy. Email is the easiest to show here, but you can include, exclude or require other things too (identity provider groups, countries, device posture, hardware keys).
-
Configure the policy:
Field Value Policy Name e.g. Allow MeAction Allow -
Under Configure rules:
- Include: Select
Emails→ add each allowed email address - Or use Email ending in for a whole domain
- Include: Select
-
Click Next through Setup, then Add application
DNS Records¶
Creation of DNS Record¶
Tell Cloudflare where to route traffic. Per the official docs, this is done right after the connector is running.
-
Navigate to Networks > Connectors > Cloudflare Tunnels
-
Click your tunnel → Published application routes tab → Add a published application route
-
Fill in the details:
Field Value Subdomain *Domain <YOUR_DOMAIN>Path leave empty Service Type HTTPURL npm:80(container name + internal port) -
Under Additional application settings, enable Protect with Access:
cloudflaredthen validates the Access JWT on every request and rejects anything that skipped the Access layer. -
Select Save. Cloudflare automatically creates the CNAME DNS record.
SSL Certificate¶
Before publishing any sites, set up a wildcard SSL certificate in NPM so every subdomain is covered.
1. Create a Cloudflare API Token¶
- Go to Cloudflare API Tokens
- Click Create Token → use the Edit zone DNS template
- Under Zone Resources, select your domain
- Click Continue to summary → Create Token and copy it
2. Request the Wildcard Certificate in NPM¶
-
In NPM, go to SSL Certificates → Add SSL Certificate → Let's Encrypt
-
Fill in the details:
Field Value Domain Names *.<YOUR_DOMAIN>,<YOUR_DOMAIN>Email Address your email Use a DNS Challenge ✅ enabled DNS Provider CloudflareCredentials File Content paste your API token -
Agree to the Let's Encrypt ToS and click Save
This single certificate covers all subdomains. NPM renews it by itself; still keep an eye on expiry (below).
Publish a Site¶
Within Nginx Proxy Manager:
-
Select Proxy Hosts → Add Proxy Host
-
On the Details tab, fill in:
Field Value Domain Names <SERVICE>.<YOUR_DOMAIN>Scheme httpForward Hostname / IP container name (on tunnel-net) or LAN IP of the serviceForward Port the service's port Websockets Support enable if the service requires it -
On the SSL tab:
Field Value SSL Certificate select the *.<YOUR_DOMAIN>wildcard certForce SSL ✅ enabled -
Click Save
Because the wildcard tunnel route already points *.<YOUR_DOMAIN> → npm:80, the new subdomain is reachable from
outside straight away, behind the Access login.
Internal access¶
On the LAN I don't use the public names. Every service also gets a local name, <SERVICE>.<YOUR_LOCAL_DOMAIN>,
as a second proxy host in NPM. A wildcard DNS rewrite in AdGuard Home points those names at NPM's LAN IP, and NPM
serves them with a certificate from a private root CA (made with mkcert) that the LAN devices trust.
External: browser → Cloudflare (Access) → tunnel → NPM → service <SERVICE>.<YOUR_DOMAIN>
Internal: browser → AdGuard Home → NPM → service <SERVICE>.<YOUR_LOCAL_DOMAIN>
Keeping the two name spaces apart means a LAN-only service is simply one that has no <YOUR_DOMAIN> proxy host, and
LAN traffic never depends on Cloudflare or the internet link. Setup: Internal TLS with a private CA
and AdGuard Home → DNS rewrites.
Hardening the wildcard model¶
The wildcard is convenient precisely because it publishes by default. These keep that from biting:
- Narrow Access apps per service where you can. The most specific hostname wins in Access, so an application
for
<SERVICE>.<YOUR_DOMAIN>overrides the*.<YOUR_DOMAIN>one. Give admin UIs (the proxy itself, your deploy tool, anything root-equivalent) their own app with a stricter policy: only your account, a short session, MFA or a hardware key. Or don't publish them at all and reach them over Tailscale. - Bypass only for apps with their own strong auth. Native apps can't do the Access login. Use a Bypass policy only for those hostnames, and only when the app has its own login with MFA enforced for every user and login rate limiting or bans.
- Consider deny-by-default at the tunnel. Instead of
*→npm:80, list each hostname as its own route and end with a catch-allhttp_status:404. A forgotten NPM host then isn't exposed automatically. More clicks per service, much smaller surprise. - Only trust forwarded headers from the proxy chain. Apps behind the proxy that read
X-Forwarded-For(atrusted_proxies-style setting, or the proxy's own real-IP config) should trust it only from thecloudflaredand NPM containers, not a whole Docker range; otherwise IP-based rules and login bans can be fooled. Fixed container IPs ontunnel-netmake that possible. - Remove stale DNS records. A wildcard keeps names you stopped using alive, and records pointing at deleted tunnels return errors. Delete records and proxy hosts when a service goes away.
- Monitor certificate expiry, both the public wildcard and any internal certs.
- Review updates to
cloudflaredand NPM by hand. They're the only way in from outside, so a bad automatic update could lock you out while you're away. My Renovate config automerges most patch/minor updates but not these two.
Monitoring the path¶
An uptime check on a public hostname that sits behind Access only ever sees the Access login page, so it reports UP even when the tunnel or the backend is dead. Check something that goes all the way through instead: give the monitor an Access service token (sent as headers, no login page), or point it at an unauthenticated health endpoint of a service that is allowed past Access, and match a keyword in the response rather than just the status code. Point the alert at a LAN address of your notification service, so it still arrives when the tunnel is the thing that's down.
Single point of failure
One connector on one host means remote access dies with that host. cloudflared supports running a second
replica with the same token on another machine: Cloudflare spreads connections across both. And keep an
independent way in (Tailscale) for when the tunnel itself is the problem.