DiyMediaServer
Featured image of post NPM to Caddy: Reverse Proxy Dozens of Containers

NPM to Caddy: Reverse Proxy Dozens of Containers

The most effective way to reverse proxy many Docker and LXC services on one system: migrate from Nginx Proxy Manager to a single Caddyfile with wildcard DNS-01 TLS.

8 years ago Nginx Proxy Manager was the right call. It was the first way I ever learned how to set up a reverse proxy on my home network without touching a line of Nginx syntax. Point at a container, tick a box for a Let’s Encrypt cert, and ten minutes later sonarr.example.com resolved with a padlock. For the first handful of services it felt like magic.

Several years later, at 31 services, it had become too much to manage through a web UI.

I wanted to add one security header across every proxy host, and I realized that meant opening the same web form 31 times. That was the day I went looking for an effective way to reverse proxy many services without a database standing between me and my own config. The answer was Caddy. This post is the honest case for switching, the actual migration path I took, and the Caddy patterns that make 31 subdomains manageable from a terminal.

💭
TL;DR: Nginx Proxy Manager is a great first reverse proxy, but its web UI and SQLite backend become a liability once you run dozens of internal services. Caddy replaces all of it with one text file plus wildcard TLS via DNS-01.

I ran this migration on Caddy 2.11.4 with the Cloudflare DNS module added via caddy add-package, running in an unprivileged Debian 13 LXC on Proxmox 9.2.3. Every config block below is lifted from the Caddyfile that now manages subdomains and TLS for my whole lab, with the domain and email swapped for placeholders.

What Nginx Proxy Manager Gets Right

Before we go too far, I want to give credit where it is due. NPM is the best on-ramp to reverse proxying that exists, and if you are happy with it, nothing here should push you off it.

  • It teaches the concepts without the syntax.
    • Proxy host, upstream, forward port, SSL cert. NPM’s UI makes those ideas concrete before you ever have to write an Nginx location block.
  • Certificates are automatic and visible.
    • You request a Let’s Encrypt cert from a dropdown, and the renewal status sits right there in the interface.
  • It is genuinely fast to start.
    • Three to five services, a couple of subdomains, no need for wildcard certs or bulk edits. NPM does that job in ten minutes and never gets in your way.

I outgrew NPM. That is a different statement from “NPM is bad.” If you run a small lab and prefer a GUI to a text file, stay there. The pain I am about to describe only shows up at scale.

Where It Started to Hurt

The 31-form marathon was just the last straw. What actually made me leave was everything underneath it.

Start with backups. The entire proxy layer for my homelab lived inside a SQLite database. I could not read the config as a text file. Nor could I restore a single host. My backup plan amounted to copying the volume and praying, and a rebuild meant restoring a blob of data and hoping the current version still liked the format.

Then there is the moving-parts problem. To write a proxy config that I would never actually get to read, NPM runs an Nginx instance, a Node.js admin app, and a SQLite database file underneath both. That is a lot of machinery stacked up to manage text I have no direct access to. As someone who values fewer moving parts, that math stopped adding up.

And there is no escape from the web UI. There is no way to bulk edit or find-and-replace. Every change is you, the mouse, and a browser form. None of this is a knock on NPM. It is the difference between a tool built for point-and-click simplicity and a lab that has grown into wanting a simple way to manage a reverse proxy.

Intel NUC 12 Pro (NUC12WSHi5)
The Intel NUC 12 Pro A compact "Wall Street Canyon" mini PC with a 12th-gen Core i5-1240P and Iris Xe that can drive up to four displays (dual Thunderbolt 4 + dual HDMI), plus 2.5GbE and Wi-Fi 6E. The H-chassis adds a 2.5″ bay alongside NVMe storage and up to 64GB RAM, making it a quiet, versatile homelab node or HTPC/office box.
Amazon Price: Loading...
Availability: Checking...
Contains affiliate links. I may earn a commission at no cost to you.

The Caddy Way: One Text File

The core of this whole migration fits in one sentence. My entire proxy layer is now a single Caddyfile that I can grep, diff, and edit over SSH in seconds.

That single change fixes everything that was a pain above:

  • Bulk changes take one edit.
    • Change a shared snippet once and every site that imports it updates. The 31-form marathon becomes a one-line change.
  • Backups are a file.
    • No database dump, no volume snapshot alignment. The Caddyfile is the backup.
  • You can dry-run before you apply.
    • caddy validate --config /etc/caddy/Caddyfile catches syntax errors before they hit production. The web UI never gave me a preview.

The thing that makes this scale is snippets and import. In Caddy, a snippet is a named block of config you define once and pull into any site. Here is the pattern that lets a host config fit on one screen. My standard HTTP upstream is one snippet:

# Standard HTTP upstream. reverse_proxy passes the Host header through by
# default, so no header_up Host is needed.
(proxy) {
	encode gzip
	reverse_proxy {args[0]} {
		header_up X-Real-IP {client_ip}
	}
}

With that defined, every ordinary service becomes a three-line block:

sonarr.example.com {
	import proxy http://172.27.0.13:8989
}

radarr.example.com {
	import proxy http://172.27.0.13:7878
}

Those upstreams are Docker containers on another host. This is what a Caddy proxy looks like when the proxy itself lives outside Docker: you point reverse_proxy at the container’s IP and port, and the container never has to know Caddy exists. Compare that to the NPM equivalent: two proxy hosts, each requiring a domain field, a scheme dropdown, a forward hostname, a forward port, an SSL tab, and a websocket checkbox, all entered by hand through a browser. The Caddyfile version is git commit-ready and readable at a glance.

Raspberry Pi 5 (8GB)
The Raspberry Pi 5 (8GB) is the biggest generational leap in Pi history. A quad-core Cortex-A76 at 2.4 GHz gives it roughly twice the CPU performance of a Pi 4, with PCIe 2.0 via the new FFC connector for NVMe storage, a real power button, and a dedicated Raspberry Pi RP1 I/O chip. Still sips power and fits in your hand, but now handles heavier Docker workloads, Pi-hole plus Home Assistant simultaneously, and even light Jellyfin hosting duties.
Amazon Price: Loading...
Availability: Checking...
Contains affiliate links. I may earn a commission at no cost to you.

The Setup: Caddy in a Proxmox LXC with a Wildcard Cert

I run Caddy as a plain binary in a dedicated Proxmox LXC, not in Docker. The reasoning is deliberate. The proxy is infrastructure, so I want to run it with the fewest possible moving parts. An LXC with an apt-installed binary is trivially snapshotted by Proxmox, and there is no Docker layer wrapping the one service that everything else depends on.

Create the container and install Caddy

Spin up an unprivileged Debian 13 LXC, give it a static IP on your LAN, and make sure ports 80 and 443 are reachable. Then install Caddy from the official repository:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

That gets you a running Caddy with a systemd config already enabled and started. Confirm it with systemctl status caddy.

There is one wrinkle that trips up almost everyone. The stock Caddy binary does not include DNS provider modules. To solve TLS challenges through Cloudflare DNS (the DNS-01 method), you need a build that includes the caddy-dns/cloudflare module. Every guide on the internet will tell you to compile one with xcaddy and a Go toolchain. You do not need any of that. Caddy can replace its own binary with one that has the module baked in:

sudo caddy add-package github.com/caddy-dns/cloudflare

That one command grabs the latest Caddy release with the Cloudflare module baked in and swaps it into place at /usr/bin/caddy. Restart the service, then confirm the module is present with caddy list-modules | grep cloudflare.

There is a catch, and it is worth fixing now rather than discovering it at renewal time. The binary on disk no longer matches what apt installed, so the next apt upgrade of the caddy package quietly puts the stock module-less binary back, and certificate renewals start failing weeks later. The fix is to keep the package for its systemd unit, caddy user, and default config, but take the binary out of apt’s hands:

sudo apt-mark hold caddy

A held package is never upgraded by apt. From now on, updates come from Caddy itself:

sudo caddy upgrade
sudo systemctl restart caddy

caddy upgrade replaces the binary with the latest release with the same modules installed, so the Cloudflare module survives every update, which is exactly the promise apt could not make. The tradeoff is that Caddy updates are now your job instead of unattended-upgrades’ job, so check caddy version against the releases page once in a while. If you ever want apt back in charge, sudo apt-mark unhold caddy reverses it.

Wildcard TLS via DNS-01

This is the piece that makes many internal subdomains painless. With a DNS-01 challenge, Caddy proves it owns a name by writing a temporary TXT record through the Cloudflare API, so nothing has to listen on port 80 to the public internet. That matters for LAN-only services, because Let’s Encrypt could never reach them over HTTP-01 anyway.

Left alone, Caddy would request a separate certificate for every site block that way. I don’t let it, for a privacy reason that is easy to miss: every certificate ever issued is published to public Certificate Transparency logs, so per-subdomain certs put the name of each internal service into a searchable public index (look your domain up on crt.sh sometime). One wildcard certificate for *.example.com avoids that. The logs show the wildcard entry and nothing else, and outsiders learn nothing about what actually runs in the lab.

Since Caddy 2.10, getting the wildcard takes no special option. If the Caddyfile contains a wildcard site block, every subdomain site it covers automatically shares that block’s certificate instead of requesting its own. So this short block sits near the top of my Caddyfile:

# Holds the *.example.com wildcard cert; every subdomain block shares it.
# Also catches requests for subdomains that don't exist.
*.example.com {
	abort
}

The abort means any subdomain you never defined gets its connection closed instead of a blank page. And do not delete this block once you rely on it: remove it and Caddy quietly goes back to issuing individual certs, and your hostnames start leaking into CT logs again. (On Caddy 2.8 and 2.9 the same behavior needed the auto_https prefer_wildcard global option; 2.10 made it the default and removed the option.)

The other half of the story is plain DNS. Certificates prove who a host is; something still has to point sonarr.example.com at the proxy in the first place. That happens entirely inside the LAN: add one wildcard record (*.example.com pointing at the Caddy LXC’s IP) to your local resolver, Pi-hole in my case, and every subdomain resolves to Caddy without you ever touching DNS again. No public A records are needed at all. DNS-01 only ever creates that short-lived TXT record at Cloudflare, so the public internet never learns where your hosts live.

First, create a scoped Cloudflare API token. In the Cloudflare dashboard, go to My Profile, then API Tokens, and create a token with Zone > DNS > Edit permission limited to the zone you use for the lab. Copy the token, because Cloudflare only shows it once.

Store it where Caddy’s systemd unit can read it, out of the Caddyfile itself, in /etc/caddy/caddy.env:

CLOUDFLARE_API_TOKEN=your-scoped-token-here

That file holds a live credential, so lock it down: sudo chmod 600 /etc/caddy/caddy.env.

Then point Caddy’s global options block at it. This block is set once and forgotten:

{
	email [email protected]
	acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	servers {
		trusted_proxies static 10.10.10.0/24
	}
}

The email line is your ACME account contact. acme_dns cloudflare tells Caddy to solve every challenge through Cloudflare DNS, which is what unlocks real certificates for LAN-only hosts. The servers block is only there because another proxy sits in front of this one for external traffic: my WireGuard VPS tunnel forwards public requests over the tunnel subnet, and trusted_proxies tells Caddy to believe the client IP headers that edge proxy sends instead of logging every external visitor as the tunnel address. If nothing forwards traffic to your Caddy, drop the servers block.

That {env.CLOUDFLARE_API_TOKEN} placeholder only works if the token is in Caddy’s environment when systemd starts the service, which means the unit needs an EnvironmentFile= line pointing at /etc/caddy/caddy.env. Do not add it by editing the packaged unit file directly. The next apt upgrade of the caddy package will replace /lib/systemd/system/caddy.service and silently throw your change away. The clean way is a systemd drop-in: a small override file in /etc/systemd/system/caddy.service.d/ that systemd merges on top of the packaged unit, and that survives package upgrades untouched. Mine lives at /etc/systemd/system/caddy.service.d/env.conf:

sudo mkdir -p /etc/systemd/system/caddy.service.d
sudo tee /etc/systemd/system/caddy.service.d/env.conf > /dev/null <<'EOF'
[Service]
EnvironmentFile=/etc/caddy/caddy.env
EOF
sudo systemctl daemon-reload
sudo systemctl restart caddy

Confirm the token actually made it into the service with systemctl show caddy | grep CLOUDFLARE.

MINISFORUM MS-A2
This mini workstation built around a 16-core Ryzen 9 9955HX, with dual 10GbE SFP+ plus dual 2.5GbE, flexible storage (U.2 + M.2 including 22110), and triple 8K display outputs. It is ideal for running Proxmox and hosting Caddy as a reverse proxy in a homelab, offering high core counts and robust networking for managing many internal subdomains.
Amazon Price: Loading...
Availability: Checking...
Contains affiliate links. I may earn a commission at no cost to you.

The Migration Itself

The mapping from NPM to Caddy isn’t too bad once you see it. An NPM proxy host becomes a Caddy site block. The forward hostname and port become the argument to reverse_proxy. Any custom Nginx snippet you bolted on becomes a named directive inside the block.

Before writing any site blocks, get the full host list out of NPM. The config that lived in a database you could never read is one query away from becoming a migration checklist. The file is database.sqlite in NPM’s data volume:

sqlite3 database.sqlite "SELECT domain_names, forward_scheme, forward_host, forward_port FROM proxy_host WHERE is_deleted = 0;"

Every row is a site block you are about to write: domain, scheme, upstream IP, and port. Mine came out to 31 lines, and that printout became the cutover checklist.

The order that avoids downtime matters. Here is what I did, and it kept the lab online the whole way:

  1. Stand up Caddy in parallel on its own IP while NPM keeps running untouched.
  2. Move subdomains over in batches by repointing internal DNS one service at a time. Test each one before moving the next.
  3. Keep NPM as the fallback until zero hosts point at it.
  4. Decommission NPM and, if you like, hand its old IP to the Caddy container so nothing downstream notices.
📝
Note: If you change Caddy’s IP make sure you also change the DNS entries.

Per service, the cutover felt trivial: type a four-line block and run caddy reload, and refresh the browser.

Don’t bother revoking NPM’s old Let’s Encrypt certificates. Once your Caddy wildcard cert is issued and serving traffic, the NPM certs are dead weight. Leave them to expire on their own. There is no security benefit to revoking a cert nobody is presenting anymore, and revocation is one more manual step at the exact moment you want to be decommissioning, not fiddling about.

The gotchas I hit

  • Websockets. In NPM this was a checkbox you had to remember to tick. In Caddy, reverse_proxy upgrades websockets automatically, so most apps work with no extra directives.
  • Access Lists. If you leaned on NPM’s Access Lists, both halves have direct Caddy equivalents: an IP allowlist becomes a remote_ip matcher (@outside not remote_ip 172.27.0.0/24 followed by abort @outside), and basic auth becomes the basic_auth directive fed a hash from caddy hash-password.
  • Streaming and server-sent events. A few apps (ntfy, my search backend) buffer badly behind a default proxy. Caddy fixes it with flush_interval -1 to disable response buffering. I keep that in a separate snippet:
# Streaming upstream: disable response buffering.
(proxy_flush) {
	encode gzip
	reverse_proxy {args[0]} {
		header_up X-Real-IP {client_ip}
		flush_interval -1
	}
}
  • HTTPS upstreams like Proxmox and PBS. Most services behind the proxy speak plain HTTP, but Proxmox (port 8006) and Proxmox Backup Server (8007) terminate their own TLS, so Caddy has to make an HTTPS connection to the backend instead. The catch is that my Caddyfile points at them by IP address. During the TLS handshake, Caddy would send that IP as the server name (SNI), the upstream would answer with a certificate issued for its hostname, and Caddy would reject the mismatch with an error like certificate is valid for pve01.example.com, not 172.27.0.2. The fix is tls_server_name, which tells Caddy which hostname to send as SNI and to verify the upstream’s certificate against. In this snippet that hostname comes in as the second argument:
# HTTPS upstream with a real ACME cert (Proxmox, PBS). Caddy calls by IP, so
# args[1] supplies the cert hostname for SNI + verification.
(proxy_tls) {
	encode gzip
	reverse_proxy {args[0]} {
		header_up X-Real-IP {client_ip}
		transport http {
			tls_server_name {args[1]}
		}
	}
}

A Proxmox node then reduces to import proxy_tls https://172.27.0.2:8006 pve01.example.com.

  • One stubborn upstream. My OPNsense box runs lighttpd, which negotiates HTTP/2 via ALPN, and HTTP/2 cannot carry websocket upgrades. I had to pin that single hop to HTTP/1.1 with versions 1.1 inside its transport block. That is the kind of one-off you write inline rather than in a shared snippet:
# OPNsense: lighttpd negotiates HTTP/2 over ALPN, and HTTP/2 cannot carry
# websocket upgrades. Pin this one hop to HTTP/1.1.
opnsense.example.com {
	encode gzip
	reverse_proxy https://172.27.0.1:443 {
		header_up X-Real-IP {client_ip}
		transport http {
			tls_server_name opnsense.example.com
			versions 1.1
		}
	}
}

Advanced Topics

Once the basics work, Caddy has depth that NPM never exposed.

The admin API. Caddy ships a JSON admin API on localhost:2019.

Danger: Read this before you touch it: this API can replace your entire running configuration in less than a second. Not one host. All of it. Anyone who can reach localhost:2019 can wipe or rewrite every proxy you run, so it must never be exposed to the network as-is.

The saving grace is that a stock install binds the admin endpoint to localhost:2019 only, so nothing on your network can reach it until you deliberately publish it. If you need a piece of it, for example so a dashboard can poll upstream health, publish only the read-only path and lock it to your LAN:

:2020 {
	@upstreams {
		path /reverse_proxy/upstreams
		remote_ip 172.27.0.0/24
	}
	handle @upstreams {
		reverse_proxy localhost:2019 {
			header_up Host localhost:2019
		}
	}
	handle {
		respond 403
	}
}

That block publishes only /reverse_proxy/upstreams to the LAN and returns 403 for everything else. The full config-replacement endpoints on 2019 stay bound to localhost, unreachable from the network. Copy this pattern exactly. Do not widen the path matcher to the whole API “for convenience,” because that convenience is a remote config wipe waiting to happen.

Matchers for traffic control. The @upstreams name above is a matcher. Matchers route by path, header, source IP, or method. Send one URL prefix to one backend and everything else to another, all inside a single site block.

Custom headers and security. Shared security headers belong in a snippet you import everywhere, so a policy change is a one-line edit across all 31 hosts:

(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Frame-Options "SAMEORIGIN"
		X-Content-Type-Options "nosniff"
	}
}

Add import security_headers next to the import proxy line in each site block. One caution before you copy that: HSTS orders browsers to refuse plain HTTP for the whole max-age window, so a broken cert locks you out of any HTTP fallback until it is fixed. Use a short max-age like 3600 while testing and raise it once everything is stable.

Logs. NPM showed per-host access logs in its UI. Caddy logs to the systemd journal by default, and adding a log directive inside any site block turns on structured access logs for that host. Put it in a shared snippet if you want it everywhere, and read the results with journalctl -u caddy.

Troubleshooting

  • caddy reload fails. Run caddy validate --config /etc/caddy/Caddyfile first. It reports the exact line for missing snippet names, unbalanced braces, or a bad directive before anything goes live.
  • The wildcard cert never issues, ACME DNS errors in the log. This is almost always the Cloudflare token. Confirm the module is compiled in with caddy list-modules | grep cloudflare, verify the token has Zone:DNS:Edit on the right zone, and check that the environment variable is actually loaded (systemctl show caddy | grep CLOUDFLARE). If you are stuck, temporarily drop acme_dns and test one public host over HTTP-01 to isolate whether the break is DNS or networking.
  • Issuance hangs at “waiting for DNS propagation”. This one bites homelabs specifically. Before asking Let’s Encrypt to validate, Caddy checks that the TXT record is visible, and it does that through the system resolver. If your LXC’s resolver is your own split-horizon DNS, the one that answers for the lab domain locally, that check may never see the public record. Point the challenge at a public resolver by swapping the acme_dns one-liner for the fuller issuer form:
{
	email [email protected]
	cert_issuer acme {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
		resolvers 1.1.1.1
	}
}
  • Proxy works from outside but not inside, or the reverse. This is a DNS resolution mismatch. Internal subdomains need consistent resolution, so point your local resolver (or split-horizon DNS) at the Caddy IP for the wildcard record.
  • App loads but shows the wrong client IP. If clients reach Caddy directly, the fix is the header_up X-Real-IP {client_ip} line in the snippet the site imports. If another proxy forwards traffic to Caddy, as my VPS edge does, its subnet must be listed under trusted_proxies in the global block, or Caddy ignores the forwarded headers and every visitor shows up as the proxy’s own IP.
  • A service loses websockets after cutover. Confirm the upstream is not being forced to HTTP/2. Pin that hop to versions 1.1 inside the transport http block, as with the OPNsense example above.

Frequently Asked Questions

➤ Is Caddy harder to learn than Nginx Proxy Manager?
The first site block is harder than clicking through NPM, because you write text instead of filling a form. After that Caddy is easier. Once you have a working snippet, adding a service is a three-line block you copy and edit.
➤ Do I need to open port 80 or 443 to the internet for certificates?
No. With a DNS-01 challenge, Caddy proves domain ownership through your DNS provider’s API instead of an inbound HTTP request. That means you can issue real, trusted certificates for LAN-only services that the public internet can never reach.
➤ Why Caddy and not Traefik for many Docker services?
Traefik’s strength is Docker label discovery, which fits an all-Docker host well. My lab is mixed LXC, VM, and Docker, so a central Caddyfile with explicit upstreams is cleaner for me than labels scattered across hosts. Both are solid; the model fit my layout better.
➤ Can I manage the Caddyfile in git and reload safely?
Yes, and this is the main payoff. Commit the Caddyfile to a private repo, run caddy validate to catch errors, then caddy reload to apply with zero downtime. Every change is diffable and revertible, which no GUI-driven proxy gives you.
➤ Does a reverse proxy make my services secure?
Not on its own. A reverse proxy is not a firewall, despite how often it gets framed that way on Reddit. It terminates TLS and routes traffic. Real security is defense in depth: a firewall, network segmentation, authentication, and keeping services patched.

The Takeaway

The switch had nothing to do with performance or features. Both proxies serve pages fine, and neither one is going to bottleneck a homelab. What changed was ownership of the config. It went from a database I had to visit through a browser to a file I own and can read.

At five services, NPM’s web UI is a convenience. A few dozen or more and it becomes a liability. Caddy gave me the most effective way to reverse proxy many Docker and LXC services on one system: bulk edits in seconds, config tracked in git, backups that are one file, and rebuilds that are one paste into a new LXC. If you are running a public edge as well, Caddy fits there too. My WireGuard VPS tunnel already uses Caddy at the VPS end, so the internal and external proxies now speak the same language.

One last thing worth repeating. A reverse proxy is not a security product, no matter how often that claim gets made online. It routes traffic and handles certificates. Keep a real firewall in front of it, segment your network, and treat the proxy as one layer among several. Defense in depth is the only honest answer.

If you want a starting point, copy the global block, the wildcard block, the (proxy) snippet, and three example service blocks from above into /etc/caddy/Caddyfile, swap in your own domain and upstream IPs, run caddy validate, then caddy reload. That is the whole workflow, and it stays the same whether you have three services or 31.