// Reverse Proxy Guide

Caddy Reverse Proxy on Your VPS

Route traffic through a WireGuard tunnel to your home network with automatic HTTPS. Restrict private services to your home IP with a single reusable snippet — no cert management required.

Caddy v2
WireGuard Tunnel
Auto HTTPS
IP Access Control
Ubuntu 22 LTS
STEP 01

Overview & Traffic Flow

This setup uses Caddy on your VPS as the public-facing SSL gateway. Caddy handles automatic certificate issuance and renewal via Let's Encrypt, then proxies all traffic through a WireGuard tunnel to your home network where Nginx Proxy Manager (or any other reverse proxy) routes requests to individual Docker containers.

Public services are accessible to anyone. Private services abort connections from any IP that isn't your home — controlled entirely by a single reusable Caddy snippet.

  1. 01Client connects to subdomain.yourdomain.com (your VPS IP)
  2. 02Caddy handles TLS termination and checks the source IP for private services
  3. 03Traffic is proxied through the WireGuard tunnel to your home network
  4. 04Nginx Proxy Manager on your home network forwards to the right Docker container
ℹ️ Why Caddy over Nginx?Caddy obtains and renews Let's Encrypt certificates automatically with zero configuration. There's no certbot cron job, no manual cert paths, and no SSL block to write. You specify your domain — Caddy handles the rest.
STEP 02

Prerequisites

Before starting, make sure you have the following in place:

  • A VPS with a public IP address running Ubuntu 22 LTS
  • A WireGuard tunnel already configured between your VPS and home network — see the WireGuard guide if needed
  • DNS A records pointing your subdomains to your VPS IP
  • Nginx Proxy Manager (or similar) running on your home network, accessible from the VPS via the WireGuard tunnel IP
  • Ports 80 and 443 open in UFW: sudo ufw allow 80 && sudo ufw allow 443
⚠️ Don't mix Caddy and Nginx on port 443If you previously ran the VPS Static Site guide on this server, stop and disable that Nginx container before installing Caddy. Two services cannot bind to port 443 simultaneously.
STEP 03

Install Caddy on the VPS

Caddy is not in the default Ubuntu repos, so we add the official Caddy package repository first.

bash — VPS
# Install required packages sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl # Add Caddy's GPG key curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \ sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg # Add Caddy repository curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \ sudo tee /etc/apt/sources.list.d/caddy-stable.list # Update and install sudo apt update && sudo apt install -y caddy

Verify the installation and check that the service is running:

bash — VPS
caddy version sudo systemctl status caddy
💡 Caddy starts automaticallyThe apt install enables and starts Caddy immediately. It serves a default page on port 80 until you write your Caddyfile.
STEP 04

WireGuard Configuration

Since Caddy handles all routing at the application layer, your WireGuard config on the VPS needs to handle only tunnel routing — no DNAT port forwarding rules are needed. If you have existing port forwarding rules in your PostUp/PostDown blocks, remove them.

/etc/wireguard/wg0.conf — VPS
[Interface] Address = 10.0.0.1/24 PrivateKey = YOUR_VPS_PRIVATE_KEY ListenPort = 51820 # NAT masquerading only — no DNAT port forwards needed PostUp = iptables -A FORWARD -i wg0 -j ACCEPT PostUp = iptables -A FORWARD -o wg0 -j ACCEPT PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i wg0 -j ACCEPT PostDown = iptables -D FORWARD -o wg0 -j ACCEPT PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE [Peer] # Your home VM / NPM server PublicKey = YOUR_HOME_VM_PUBLIC_KEY AllowedIPs = 10.0.0.2/32
🚨 Remove any DNAT rulesDo not add PREROUTING DNAT rules to this WireGuard config. Caddy proxies at Layer 7 — iptables DNAT rules will conflict and cause routing issues.
STEP 05

Interactive Caddyfile Generator

Fill in your details and add your subdomains below. The Caddyfile updates live as you type. Check Public to allow access from anywhere, or leave unchecked to restrict to your home IP only.

// Caddyfile Generator

Fill in all fields — the config below updates automatically

Your home network's public IP
Used by Let's Encrypt for expiry alerts
Tunnel IP of your home NPM server
Generated Caddyfile
/etc/caddy/Caddyfile — copy to VPS
# Fill in the fields above to generate your Caddyfile
STEP 06

Apply the Caddyfile

Copy the generated Caddyfile from above and write it to /etc/caddy/Caddyfile on your VPS, then validate and reload.

bash — VPS
# Open the Caddyfile and paste the generated config sudo nano /etc/caddy/Caddyfile

After saving, validate the syntax and reload:

bash — VPS
# Validate config for syntax errors caddy validate --config /etc/caddy/Caddyfile # Format the Caddyfile (optional but recommended) caddy fmt --overwrite /etc/caddy/Caddyfile # Reload Caddy — applies changes without dropping connections sudo systemctl reload caddy

Check the service status and follow logs to confirm a clean start:

bash — VPS
sudo systemctl status caddy sudo journalctl -u caddy -f
💡 Always use systemctl reload, not restartReload applies the new config gracefully without dropping active connections. Use restart only if reload itself fails.
STEP 07

Update Nginx Proxy Manager

Since Caddy now handles SSL termination on the VPS, your NPM proxy hosts need to be updated. NPM no longer needs to manage certificates or enforce access rules — it simply routes plain HTTP from the WireGuard tunnel to your containers.

  • Scheme: Change to http — SSL is terminated at Caddy, not NPM
  • SSL Tab: Select HTTP Only — no certificates needed here
  • Force SSL: Disable — Caddy enforces HTTPS on the public side
  • Access Lists: Set to Publicly Accessible — Caddy is the gatekeeper, NPM access lists are redundant
ℹ️ NPM is now just a routerAll security and SSL logic lives in the Caddyfile on the VPS. NPM's only job is to forward plain HTTP from the tunnel IP to the right container port.
STEP 08

Testing Your Setup

With a peer connected, run these checks to confirm everything is working end-to-end.

Test a public service from anywhere:

bash — any machine
curl -I https://public.yourdomain.com # Expect: HTTP/2 200

Verify a private service blocks outside access:

bash — machine outside your home network
curl -I https://private.yourdomain.com # Expect: connection closed — Caddy aborts non-home IPs

Verify SSL certificate details:

bash — VPS
echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null \ | openssl x509 -noout -dates

Confirm the WireGuard tunnel and peer reachability:

bash — VPS
sudo wg show # latest-handshakes should show a recent timestamp ping -c 3 10.0.0.2 # Must succeed for Caddy to reach NPM
STEP 09

Adding New Services

New private service:

  • Add a DNS A record for the new subdomain pointing to your VPS IP
  • Add a proxy host in NPM (HTTP only, no SSL)
  • That's it. Add the subdomain in the generator above (unchecked), re-paste the Caddyfile, and run sudo systemctl reload caddy
💡 The home_only snippet does the heavy liftingEvery block that uses import home_only is automatically protected. Adding new private services is just a matter of adding one subdomain block — no extra IP rules needed.

New public service:

  • Add the DNS A record and NPM proxy host as above
  • Add the subdomain in the generator (check Public), copy the updated Caddyfile to /etc/caddy/Caddyfile
  • Run sudo systemctl reload caddy
STEP 10

Troubleshooting

Caddy fails to start or reload:

bash — VPS
caddy validate --config /etc/caddy/Caddyfile sudo journalctl -u caddy -n 50 --no-pager
  • Placeholder text still in Caddyfile instead of real domain names
  • Email not set in the global options block — required for Let's Encrypt
  • DNS not yet propagated — run dig subdomain.yourdomain.com to verify
  • Ports 80/443 blocked by UFW — run sudo ufw status

Private service accessible from outside:

  • Verify import home_only is present in that subdomain block
  • Confirm the home IP in the snippet matches your current public IP (curl ifconfig.me from home)
  • Run sudo systemctl reload caddy after any Caddyfile change

Connection refused on a proxied service:

bash — VPS, run in order
# 1. Is the WireGuard tunnel up? sudo wg show # 2. Can the VPS reach the home tunnel IP? ping -c 3 10.0.0.2 # 3. Can Caddy reach NPM on the home side? curl -v http://10.0.0.2:PORT # 4. Follow Caddy logs while making a test request sudo journalctl -u caddy -f
💡 Caddy logs in real timeRun sudo journalctl -u caddy -f in one terminal while making test requests in another. Caddy logs each request with the upstream it tried to reach, making it easy to trace exactly where a failure occurs.