Skip to content

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.

browser → Cloudflare edge (Access login) → tunnel → cloudflared → NPM → service

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

  1. Log in to the Cloudflare Zero Trust Dashboard

  2. Navigate to Networks > Connectors > Cloudflare Tunnels

  3. Click Create a tunnel, choose Cloudflared as the connector type, and select Next

  4. Enter a name for the tunnel (e.g. the host name, <YOUR_HOSTNAME>)

  5. Click Save tunnel

  6. 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:

docker network create tunnel-net

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
.env.example
TUNNEL_TOKEN=<YOUR_TUNNEL_TOKEN>

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.

  1. Navigate to Access controls > Applications

  2. Click Add an application → Self-hosted

  3. Configure:

    Field Value
    Application name e.g. Homelab
    Session Duration 30 days is convenient for personal use; use less for admin apps
    Application domain *.<YOUR_DOMAIN>
  4. 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).

  1. Configure the policy:

    Field Value
    Policy Name e.g. Allow Me
    Action Allow
  2. Under Configure rules:

    • Include: Select Emails → add each allowed email address
    • Or use Email ending in for a whole domain
  3. 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.

  1. Navigate to Networks > Connectors > Cloudflare Tunnels

  2. Click your tunnel → Published application routes tab → Add a published application route

  3. Fill in the details:

    Field Value
    Subdomain *
    Domain <YOUR_DOMAIN>
    Path leave empty
    Service Type HTTP
    URL npm:80 (container name + internal port)
  4. Under Additional application settings, enable Protect with Access: cloudflared then validates the Access JWT on every request and rejects anything that skipped the Access layer.

  5. 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

  1. Go to Cloudflare API Tokens
  2. Click Create Token → use the Edit zone DNS template
  3. Under Zone Resources, select your domain
  4. Click Continue to summary → Create Token and copy it

2. Request the Wildcard Certificate in NPM

  1. In NPM, go to SSL Certificates → Add SSL Certificate → Let's Encrypt

  2. Fill in the details:

    Field Value
    Domain Names *.<YOUR_DOMAIN>, <YOUR_DOMAIN>
    Email Address your email
    Use a DNS Challenge ✅ enabled
    DNS Provider Cloudflare
    Credentials File Content paste your API token
  3. 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:

  1. Select Proxy Hosts → Add Proxy Host

  2. On the Details tab, fill in:

    Field Value
    Domain Names <SERVICE>.<YOUR_DOMAIN>
    Scheme http
    Forward Hostname / IP container name (on tunnel-net) or LAN IP of the service
    Forward Port the service's port
    Websockets Support enable if the service requires it
  3. On the SSL tab:

    Field Value
    SSL Certificate select the *.<YOUR_DOMAIN> wildcard cert
    Force SSL ✅ enabled
  4. 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-all http_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 (a trusted_proxies-style setting, or the proxy's own real-IP config) should trust it only from the cloudflared and NPM containers, not a whole Docker range; otherwise IP-based rules and login bans can be fooled. Fixed container IPs on tunnel-net make 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 cloudflared and 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.

Sources & further reading