Ditching ddclient and Open Ports: Migrating to Cloudflare Tunnel for a Safer Homelab

Ditching ddclient and Open Ports: Migrating to Cloudflare Tunnel for a Safer Homelab

For years, my homelab setup followed the classic self-hosting playbook: ddclient updating my dynamic IP on Cloudflare DNS, ports 80 and 443 forwarded on my router, and Nginx Proxy Manager sitting behind that handling TLS termination and reverse proxying to my various services. It worked. Until I started thinking about what "worked" actually meant from a security standpoint.

Here's the thing — exposing ports 80 and 443 on your residential IP is essentially painting a target on your home network. Anyone hitting your domain can resolve your actual home IP address with a simple dig command. Port scanners will find you. Bots will probe you. And if a vulnerability ever lands on your reverse proxy or one of the services behind it, the attack surface goes straight to your LAN.

I wanted to keep self-hosting everything — Nextcloud, Home Assistant, n8n, and a few others — but without my home IP being one DNS lookup away from the entire internet. Enter Cloudflare Tunnel.

The Old Setup: ddclient + Port Forwarding

Here's what the architecture looked like before:

┌─────────────────────────────────────────────────────────┐
│  INTERNET                                               │
│                                                         │
│   User ──► Cloudflare DNS ──► Your Home IP (exposed!)   │
│                                    │                    │
│                               Port 80/443               │
│                               forwarded                 │
└────────────────────────────────┬────────────────────────┘
                                 │
┌────────────────────────────────▼────────────────────────┐
│  HOME NETWORK                                           │
│                                                         │
│   Router ──► Nginx Proxy Manager ──► Nextcloud          │
│                      │               Home Assistant     │
│                      │               n8n                │
│                      │               ...                │
│                                                         │
│   ddclient container ──► updates Cloudflare DNS A record│
│   (polls public IP every 5 min)                         │
└─────────────────────────────────────────────────────────┘

The ddclient piece ran as a Docker container alongside everything else. Typical docker-compose.yml snippet:

ddclient:
  image: lscr.io/linuxserver/ddclient:latest
  restart: always
  volumes:
    - ./ddclient:/config

With a ddclient.conf pointing at Cloudflare's API to update the A record whenever my ISP-assigned IP changed.

The problems with this approach:

  • Your home IP is public. Anyone resolving your subdomain sees where you live, network-wise. Cloudflare's proxy ("orange cloud") helps, but you're still relying on the port-forwarding path being airtight.
  • Open ports on your router. Ports 80 and 443 are forwarded. That's two doors into your home network that rely entirely on Nginx Proxy Manager (or whatever sits behind them) never having a bad day.
  • DDoS exposure. A motivated attacker who discovers your real IP can bypass Cloudflare entirely and hit your residential connection directly.
  • ISP headaches. Some ISPs frown on inbound connections, throttle them, or outright block them via CGNAT.

The New Setup: Cloudflare Tunnel (Zero Trust)

The idea behind Cloudflare Tunnel is simple: instead of opening ports inbound, you establish an outbound-only connection from your network to Cloudflare's edge. No port forwarding. No exposed IP. Traffic flows through Cloudflare's network and gets proxied back to your services through the tunnel.

┌─────────────────────────────────────────────────────────┐
│  INTERNET                                               │
│                                                         │
│   User ──► Cloudflare Edge ──► Tunnel (outbound only)   │
│            (DDoS protection,     │                      │
│             TLS termination,     │  Your home IP is     │
│             access policies)     │  NEVER exposed       │
└──────────────────────────────────┬──────────────────────┘
                                   │
              Encrypted tunnel (no inbound ports needed)
                                   │
┌──────────────────────────────────▼──────────────────────┐
│  HOME NETWORK                                           │
│                                                         │
│   cloudflared ──► Nginx Proxy Manager ──► Nextcloud     │
│   (outbound        (still handles routing,  HA          │
│    connection        htaccess, etc.)        n8n         │
│    only)                                   ...          │
│                                            ...          │
│   Router: NO port forwarding needed                     │
└─────────────────────────────────────────────────────────┘

What changed:

  • No more port forwarding. Ports 80 and 443 are closed on the router. Zero inbound exposure.
  • No more ddclient. There's no DNS A record pointing to your home IP. The tunnel handles routing entirely.
  • Your home IP is invisible. A dig on your subdomain returns Cloudflare's IPs, and there is no path back to your real IP.
  • Cloudflare's edge does the heavy lifting. DDoS mitigation, TLS, and optionally Zero Trust access policies all sit in front of your services before traffic ever reaches your network.

The Migration: Simpler Than Expected

Honestly, the migration was smooth. Here's the play-by-play.

1. Create a Tunnel in Cloudflare Zero Trust

Head to the Cloudflare Zero Trust dashboard, go to Networks > Tunnels, and create a new tunnel. Cloudflare gives you a tunnel token — a long base64 string that authenticates your cloudflared connector.

2. Replace ddclient with cloudflared in Docker Compose

Out with the old, in with the new. The cloudflared container is dead simple:

cloudflared:
  image: cloudflare/cloudflared:latest
  restart: always
  command: tunnel run
  environment:
    - TUNNEL_TOKEN=<your-tunnel-token>

That's it. No config files, no API keys, no cron-like polling. The container connects outbound to Cloudflare's edge and maintains a persistent tunnel. If it drops, it reconnects. If your IP changes, it doesn't matter — the tunnel doesn't care about your public IP.

3. Configure Public Hostnames in Zero Trust

In the Cloudflare Zero Trust dashboard, under your tunnel's configuration, you add public hostnames. Each hostname maps a subdomain to a local service:

Public Hostname Service Target
cloud.example.com http://nginx-proxy-manager:80
ha.example.com http://nginx-proxy-manager:80
auto.example.com http://nginx-proxy-manager:80

Notice how every subdomain points to Nginx Proxy Manager. I didn't bypass NPM — I kept it in the chain. The tunnel delivers traffic to NPM, and NPM handles the routing, SSL (internally), .htaccess-style protections, and any other per-service configuration I had before.

This was key for me. I didn't want to reconfigure every service's access control and proxy headers. NPM already had all of that dialed in. Cloudflare Tunnel just replaced the "how traffic gets to NPM" part.

4. Close the Ports, Remove ddclient

Once the tunnel was confirmed working:

  • Removed port forwarding rules for 80/443 on the router
  • Removed the ddclient container and its config
  • Deleted the old DNS A records that pointed to my home IP (the tunnel manages DNS automatically via CNAME)

5. Remove the Old ddclient DNS Records

This is easy to forget: go into Cloudflare DNS and delete the A records that ddclient was maintaining. The tunnel creates its own CNAME records that point your subdomains through the tunnel. Leaving stale A records around defeats the purpose — your home IP would still be resolvable.

Keeping Nginx Proxy Manager in the Loop

Some guides suggest pointing each tunnel hostname directly at the target service (e.g., http://nextcloud:8080). That works, but I prefer keeping NPM in the middle for a few reasons:

  • Centralized routing config. I manage all proxy rules in one place, with a GUI I'm already familiar with.
  • Access control. NPM handles .htpasswd / basic auth for services that don't have their own authentication.
  • Custom headers and overrides. Some services need specific proxy headers (X-Forwarded-For, X-Real-IP, websocket upgrades). NPM already has those configured.
  • SSL transition. I used to run Let's Encrypt certificates on NPM for all my subdomains. With the tunnel handling TLS, those are no longer needed (more on that below).

The only thing that changed in NPM is that traffic now arrives from cloudflared on the Docker network instead of from the router. From NPM's perspective, it's the same job — just a different source.

The Let's Encrypt Gotcha

One thing worth calling out: before the migration, I was using NPM's built-in Let's Encrypt integration to generate and manage SSL certificates for all my subdomains. NPM would handle TLS termination, force HTTPS, and everything was encrypted end-to-end from the browser.

With Cloudflare Tunnel, that changes. Cloudflare's edge now handles TLS termination — the browser talks HTTPS to Cloudflare, and then the tunnel delivers traffic to your local services over an already-encrypted tunnel connection. So inside your network, the traffic arriving at NPM from cloudflared is plain HTTP.

That means you need to disable "Force HTTPS" and turn off SSL on your NPM proxy hosts. It feels a bit wrong at first — you're looking at http:// in your proxy host config and your brain screams — but it makes sense: the encryption is handled by the tunnel itself, not by a Let's Encrypt cert on your local reverse proxy. Keeping HTTPS enabled on the NPM side would mean cloudflared tries to connect to NPM over HTTPS, which either fails or creates unnecessary overhead with self-signed cert warnings.

The Let's Encrypt certificates you had before? You can just let them expire. They're no longer needed.

A Word on Cloudflare Zero Trust Access Policies

While you're in the Zero Trust dashboard setting up the tunnel, it's worth glancing at Access Policies. These let you add an authentication layer in front of any of your public hostnames — before traffic even reaches your network.

For example, you can require an email-based one-time PIN, integrate with an identity provider (Google, GitHub, SAML, etc.), or restrict access to specific email addresses. This is especially useful for admin panels or services that have weak (or no) built-in authentication.

I won't go deep into this here — it deserves its own post — but know that it exists and it's free on the Zero Trust free tier. It's a very nice extra layer on top of whatever authentication your services already provide.

Before and After: What Actually Changed

Aspect Before (ddclient) After (Cloudflare Tunnel)
Home IP exposure Visible via DNS lookup Hidden behind Cloudflare
Router ports 80 + 443 forwarded None
DNS updates ddclient polling every 5 min Not needed (tunnel is IP-agnostic)
DDoS protection Partial (Cloudflare proxy) Full (no direct path to home IP)
Docker footprint ddclient container cloudflared container
Config complexity ddclient.conf + Cloudflare API One tunnel token env var
Nginx Proxy Manager Still in use Still in use, unchanged
Zero Trust policies Not available Optional authentication layer

Final Thoughts

The migration took about 30 minutes, and the result is a meaningfully more secure setup with less configuration to maintain. No config files for ddclient, no port forwarding rules to remember, no home IP leaking through DNS. The cloudflared container is a single docker-compose service with one environment variable.

If you're currently running the ddclient + port forwarding setup and have your domains on Cloudflare, there's very little reason not to make this switch. The free tier covers everything I've described here, and you get the peace of mind of knowing your home IP isn't one dig away from anyone on the internet.

Your homelab should be your playground, not your attack surface.

0
Did you enjoy this article? Give it a like.

Comments (0)

Leave a comment

Your comment will appear after review.

Related articles