← Learn··Updated 31 May 2026·8 min read

Caddy: reverse proxy and automatic HTTPS

Install Caddy on the VPS, let it obtain a Let's Encrypt certificate on its own, and forward the first real service down the WireGuard tunnel. By the end, one homelab service is live on your domain with valid HTTPS.

Networking
Expose your homelab to the internetPart 5 of 7
#networking
#caddy
#reverse-proxy
#tls
#https
#ai-assisted

level:Intermediate

This is Part 5 of Expose your homelab to the internet, and it is the payoff. In Part 4 you built the encrypted WireGuard tunnel between the VPS and your home server — the VPS can already ping 10.8.0.2 and reach into the homelab. What is still missing is the front door: something on the VPS that listens on the public internet, speaks HTTPS, and forwards each request down that tunnel to the right service. That something is Caddy, and by the end of this part one real homelab service will be live on app.example.dev with a valid certificate.

Everything below uses the same shared facts as the rest of the series. The domain is example.dev and the host we are publishing is app.example.dev. The VPS is a Debian 12 box with the public IP 203.0.113.10. The WireGuard interface wg0 carries the 10.8.0.0/24 network, with the VPS at 10.8.0.1 and the homelab at 10.8.0.2. The example service in the homelab listens on 10.8.0.2:8080. The home router has no port forwards and the home IP never appears in DNS.

What a reverse proxy is, and why Caddy

A reverse proxy is a server that sits in front of your actual services. Every inbound HTTP and HTTPS request hits the proxy first; the proxy looks at the request — chiefly the hostname — and forwards it to the correct backend, then relays the response back to the visitor. The visitor only ever talks to the proxy. The backends can live anywhere the proxy can reach them, including down a private tunnel. If you want the longer version, the explainer What a reverse proxy actually does covers it from scratch.

On the VPS the reverse proxy plays one more role: it is where TLS terminates. A visitor opens an encrypted HTTPS connection to the proxy, the proxy decrypts the request, and the trip onward to the backend happens over the already-private WireGuard tunnel. The public internet only ever sees the encrypted connection to the VPS.

There are several good reverse proxies — Nginx, Traefik, HAProxy. We use Caddy for one reason above all: automatic HTTPS. Caddy obtains a TLS certificate from Let's Encrypt the first time a hostname is requested, installs it, serves HTTPS, and renews it before it expires — with no extra configuration. There is no certbot to run, no renewal cron job to remember, no --deploy-hook to wire up. You write the hostname in a config file and Caddy does the certificate dance for you.

ℹ️ Note — Automatic HTTPS is not magic; it is Caddy speaking the ACME protocol to Let's Encrypt on your behalf. The part you have to get right is making sure Let's Encrypt can verify you control the domain, which is what the DNS record from Part 2 and the open ports from Part 3 are for. We come back to that below.

Install Caddy on the VPS

Caddy publishes an official Debian/Ubuntu apt repository. Installing from it (rather than downloading a binary) means you get a systemd service, a sensible default config layout, and apt upgrade updates like any other package. Run everything in this section over SSH on the VPS, as root or with sudo.

First, install the prerequisites and add Caddy's signing key and repository. This is the documented procedure from the Caddy project, verbatim — read it before you paste it.

Add the Caddy apt repository (on the VPS)

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

The first line installs the tools needed to verify and fetch over HTTPS. The second downloads Caddy's GPG key and stores it where apt can find it. The third adds the repository definition so apt knows where to pull the package from.

Now refresh the package index and install Caddy itself.

Install Caddy (on the VPS)

sudo apt update
sudo apt install -y caddy

The package installs Caddy as a systemd service that starts on boot, and ships a default Caddyfile at /etc/caddy/Caddyfile (it serves a placeholder welcome page on port 80). Confirm the service is alive:

Check the service (on the VPS)

systemctl status caddy

You should see active (running). That default config is what we replace next.

The Caddyfile

Caddy is configured by a plain-text file called a Caddyfile. For our purposes it is wonderfully small. Open the file for editing and replace its contents with a single site block for our host.

/etc/caddy/Caddyfile (on the VPS)

app.example.dev {
    reverse_proxy 10.8.0.2:8080
}

Two lines, and every part matters:

  • app.example.dev is the site address. It must match the DNS A record you created in Part 2 — the one pointing app.example.dev at the VPS public IP 203.0.113.10. Caddy uses this hostname both to decide which requests this block handles and as the name it requests a certificate for. Because it is a real public hostname (not :80 or localhost), Caddy switches on automatic HTTPS for it.
  • reverse_proxy 10.8.0.2:8080 tells Caddy: for any request to this host, forward it to 10.8.0.2 on port 8080. That address is the homelab's end of the WireGuard tunnel. So the request that arrived encrypted from the internet is decrypted by Caddy and sent over the private tunnel to the service running at home.

That is the whole edge for one service. Here is the full path a request takes, end to end.

flowchart LR
    U["Visitor"] -->|"https://app.example.dev"| DNS["DNS A record<br/><i>app.example.dev → 203.0.113.10</i>"]
    DNS --> C["VPS 203.0.113.10<br/><i>Caddy terminates TLS, :80/:443</i>"]
    C -->|"WireGuard tunnel wg0"| H["Homelab 10.8.0.2<br/><i>behind NAT, no open ports</i>"]
    H --> S["Service on :8080"]

The visitor only ever reaches the VPS; the decrypted request travels the private tunnel to the service at home.

How the automatic HTTPS actually works

When Caddy first needs a certificate for app.example.dev, it asks Let's Encrypt to issue one and must prove it controls that hostname. By default it uses the HTTP-01 challenge: Let's Encrypt connects back to http://app.example.dev/ on port 80 and expects Caddy to serve a specific token there. This is why two things from earlier parts have to be true:

  • The DNS A record for app.example.dev must resolve to the VPS (203.0.113.10), so Let's Encrypt's validation request lands on the machine where Caddy is running. (See Part 2.)
  • Ports 80 and 443 must be open to the internet on the VPS firewall — port 80 for the challenge, port 443 for the HTTPS traffic afterwards. You opened these in Part 3.

If both hold, the first request to https://app.example.dev triggers issuance, Caddy obtains the certificate within seconds, and serves the site over HTTPS. From then on Caddy renews automatically, well before expiry, with no further action from you.

ℹ️ Note — There is a second way to prove control: the DNS-01 challenge, where Caddy creates a TXT record via your DNS provider's API instead of answering on port 80. HTTP-01 cannot issue wildcard certificates (like *.example.dev); DNS-01 can. We do not need it for a single host, but it becomes the right tool when we front an entire cluster in Part 6 — so keep it in the back of your mind.

The backend must be reachable on the tunnel

There is one requirement that trips people up, and it lives at the homelab end, not on the VPS. Caddy reaches the backend at 10.8.0.2:8080 — the homelab's address on the WireGuard interface. For that to work, the service at home must actually be listening on an address the tunnel can reach.

Many services default to binding only to 127.0.0.1 (localhost) — reachable from the same machine, but not from the wg0 interface, and therefore not from the VPS. The service must instead bind to either:

  • 0.0.0.0:8080 — listen on all interfaces (simplest; fine because the only external route in is the tunnel itself), or
  • 10.8.0.2:8080 — listen specifically on the WireGuard address.

Check what your service is bound to on the homelab host:

Check the listening address (on the homelab)

sudo ss -tlnp | grep ':8080'

If you see 127.0.0.1:8080 the service is localhost-only; if you see 0.0.0.0:8080 or 10.8.0.2:8080 the tunnel can reach it. How you change the bind address depends on the service — for a Docker container it usually means publishing the port as -p 8080:8080 (binds all interfaces) rather than -p 127.0.0.1:8080:8080; for a native app it is a --bind/--host flag or a config setting.

⚠️ Warning — If the service is bound only to 127.0.0.1, Caddy's reverse_proxy will fail with connection-refused errors and visitors get 502 Bad Gateway — even though the certificate issues fine and the service works locally at home. A 502 with the cert in place almost always means the backend bind address, not Caddy. Fix the bind, do not start re-debugging the tunnel.

Reload and test

Before reloading, validate the config so a typo does not take Caddy down. Run this on the VPS.

Validate the Caddyfile (on the VPS)

caddy validate --config /etc/caddy/Caddyfile

A clean run prints Valid configuration. If it complains, it tells you the line — fix it and validate again. Once it is valid, reload Caddy to apply it. Reload (not restart) swaps the config in place with no dropped connections.

Reload Caddy (on the VPS)

sudo systemctl reload caddy

Now test from anywhere — your laptop, your phone on mobile data, anywhere off the home network — to prove it is genuinely public.

Test the live site (from anywhere)

curl -I https://app.example.dev

You want to see HTTP/2 200 (or whatever status your service returns for the root path) and no certificate errors. curl validating the certificate without -k means the chain is trusted — that is the Let's Encrypt cert working. Then open https://app.example.dev in a browser: you should get the homelab service with a padlock and a valid certificate issued to app.example.dev.

If the certificate does not issue, the answer is almost always in Caddy's logs. Check them on the VPS:

Watch Caddy's logs (on the VPS)

journalctl -u caddy -f

The most common causes, in order: the DNS A record does not yet point at the VPS (or has not propagated), port 80 is not actually open to the internet, or the hostname in the Caddyfile does not exactly match the DNS name. The log lines from Caddy's certificate manager name the problem directly — read them rather than guessing.

💡 Tip — Adding more services later is just more site blocks. A second service is another mydomain.dev { reverse_proxy ... } block in the same Caddyfile, a matching DNS record, and a reload — Caddy issues a separate certificate for each. That works beautifully for a handful of individually-listed hosts. When you want one wildcard certificate fronting a whole cluster of services you have not enumerated in advance, that is the cluster approach in Part 6.

What you have now

One real service from your homelab is live on app.example.dev, served over HTTPS with a certificate Caddy obtained and will renew on its own. The home router still has no port forwards, your home IP is still nowhere in DNS, and the only thing exposed to the internet is the one VPS — which decrypts each request and hands it down the private WireGuard tunnel. That is the entire pattern working end to end.

For many readers this is the finish line: a single self-hosted service, on a real domain, reachable from anywhere, with home kept private. If you want to take it further, Part 6 — Routing into k3s from the edge replaces per-service blocks with a wildcard domain and a cluster ingress, so one VPS fronts every service you deploy. When you are ready, that is where to go next.