Reverse Proxy Done Right: Automatic HTTPS with Caddy in Ten Minutes

TLS certificates that renew themselves

Contents

The last time I set up HTTPS the old-fashioned way, I spent forty minutes on the web server config and another twenty wiring Certbot into a cron job, then forgot about it — until eleven weeks later when a certificate quietly expired at 3am and I woke up to a wall of browser warnings. The renewal timer had been silently failing for a month. That is the classic Certbot failure: it works right up until the one time it doesn’t, and you only find out when a padlock turns red in production. Caddy exists to make that entire class of problem disappear. It obtains valid HTTPS certificates for you, renews them before they expire without any timer for you to forget about, and routes traffic to your applications using a configuration file short enough to read in a single breath. By the end of this guide you will have two services sitting safely behind it with proper TLS and roughly ten minutes of effort. This is what a reverse proxy is supposed to feel like.

What a Reverse Proxy Does and Why You Want One

Advertisement

A reverse proxy is a server that sits in front of your applications and forwards incoming requests to them. To the outside world it looks like a single, tidy front door; behind it you might run a dozen separate services on a jumble of internal ports. When a request arrives for app.example.com, the proxy decides which internal service should handle it and passes the request along, then relays the response back.

This pattern earns its keep in several ways. It gives you one place to terminate HTTPS, so individual applications need not each manage their own certificates. It lets you host many services on a single public IP address by routing on hostname. It centralises concerns like compression, headers, and access control. And it hides the messy internal topology of your network behind a clean, consistent interface. Almost every serious deployment uses one, whether the operators call it that or not.

Caddy Versus Nginx

Nginx is the venerable workhorse of the reverse-proxy world, and it is superb: fast, battle-tested, and capable of almost anything. But its configuration is verbose, and crucially it does nothing about certificates on its own. To get HTTPS with Nginx you typically bolt on Certbot, schedule renewals, and hope the moving parts stay aligned.

Caddy’s headline feature is that HTTPS is automatic and on by default. When you tell Caddy to serve a domain, it contacts Let’s Encrypt, completes the challenge, installs the certificate, and quietly renews it for the rest of time without you lifting a finger. The configuration file, called a Caddyfile, is dramatically terser than the Nginx equivalent. Nginx still wins on raw throughput at extreme scale and on the sheer breadth of its ecosystem, but for the homelab, the small business, and the developer who wants TLS to simply work, Caddy is hard to beat.

If you want the fuller comparison across the popular options, I’ve laid out the trade-offs in Traefik vs Nginx Proxy Manager. The short version: Traefik shines when your backends are containers that come and go and you want service discovery; Nginx Proxy Manager gives you a friendly GUI; and Caddy wins when you want the simplest possible path from “a domain” to “valid HTTPS in front of it.” This guide is about that last case.

Installing Caddy

Advertisement

On a Debian or Ubuntu system, Caddy provides an official repository so you get signed packages and clean upgrades:

1
2
3
4
5
6
7
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

The package installs Caddy as a systemd service that is already running and pointed at a configuration file in /etc/caddy/Caddyfile. You can confirm it is alive:

1
systemctl status caddy

A Minimal Caddyfile for Two Services

Here is the whole point of the exercise. Suppose you have two applications running locally: a dashboard on port 3000 and an API on port 8080. You own example.com and want each on its own subdomain with automatic HTTPS. Edit /etc/caddy/Caddyfile to read:

1
2
3
4
5
6
7
dashboard.example.com {
    reverse_proxy localhost:3000
}

api.example.com {
    reverse_proxy localhost:8080
}

That is the entire configuration. Reload Caddy to apply it:

1
sudo systemctl reload caddy

The moment you reload, Caddy notices the two public domains, reaches out to Let’s Encrypt, solves the challenge over port 80 or 443, and installs certificates. Visit https://dashboard.example.com and you are greeted with a valid padlock and your application behind it. No certificate files, no renewal scripts, no separate tooling. For this to work the domains must already resolve to your server’s public IP, which is the one piece of homework you do beforehand.

Wildcard Certificates via the DNS Challenge

The default challenge requires your server to be reachable on port 80 or 443 from the public internet. Sometimes that is impossible, perhaps because your services live behind a firewall, or because you want a single wildcard certificate covering *.example.com rather than one per subdomain. For these cases Caddy supports the DNS challenge, where it proves ownership by creating a temporary record in your DNS zone instead of answering an HTTP request.

This requires a Caddy build that includes the plugin for your DNS provider, available from the download page or via xcaddy. With the provider module in place, a wildcard configuration looks like this:

1
2
3
4
5
6
*.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    reverse_proxy app1 localhost:3000
}

You supply an API token scoped to edit your DNS zone, and Caddy handles the rest. The DNS challenge is also the only way to obtain certificates for internal-only services that never face the public web. A wildcard certificate has the further advantage of not enumerating every subdomain in public certificate transparency logs, which slightly reduces how much a curious observer can learn about your internal layout. If you already run your own resolver — say from following running your own DNS-over-HTTPS resolver at home — the DNS challenge slots neatly into the same zone you already control.

Running Caddy in a container

If your services already live in containers, so should the proxy. A minimal Compose setup looks like this, with the Caddyfile and the certificate store both mounted so nothing is re-issued on every restart:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
volumes:
  caddy_data:
  caddy_config:

The caddy_data volume is the important one — it holds the issued certificates and the account key. Lose it and every restart re-runs the ACME challenge from scratch, which is exactly the loop that gets you rate-limited (more on that below). Reference your backends by their Compose service names inside the Caddyfile rather than localhost, since inside the container localhost means the proxy itself, not the host.

Headers and Compression

A reverse proxy is the natural place to apply cross-cutting niceties, and Caddy makes them concise. Enabling compression and adding sensible security headers takes only a few directives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
dashboard.example.com {
    encode gzip zstd
    header {
        Strict-Transport-Security "max-age=31536000;"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    reverse_proxy localhost:3000
}

The encode directive compresses responses on the fly, the header block hardens the browser’s behaviour, and the -Server line strips the header that would otherwise advertise what you are running. These are the kinds of touches that are tedious to retrofit into every application but trivial to apply once at the proxy.

Common Pitfalls

The failures people hit are nearly always one of a small handful. The first is ports: Caddy needs inbound 80 and 443 open for the standard challenge, so check your firewall and any router port forwarding. If certificate issuance hangs, this is the usual culprit.

The second is DNS. The domain must resolve to your server before Caddy can prove ownership, and freshly changed records can take time to propagate. Verify with dig dashboard.example.com and make sure the answer is your actual public address.

The third is rate limits. Let’s Encrypt limits how many certificates you can request for a domain in a given week, and a misconfiguration that retries in a loop can exhaust that allowance, leaving you temporarily blocked for several days. This catches people who tear down and rebuild a server repeatedly while experimenting, each attempt burning through the weekly budget. The fix is to do your experimenting against the staging environment, which issues untrusted certificates but has far looser limits, and only switch to production once the configuration is settled. While testing, point Caddy at the staging certificate authority so failed attempts do not count against you:

1
2
3
{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

Remove that global block once everything works, reload, and Caddy will fetch real, trusted certificates.

A fourth, subtler failure is the container localhost trap mentioned above: a Caddyfile that says reverse_proxy localhost:3000 works on a bare host but points at nothing when Caddy runs in its own container. Use the backend’s service name or the host’s Docker gateway address instead. And a fifth is IPv6: if your DNS has an AAAA record but your server isn’t actually reachable over IPv6, Let’s Encrypt may try the challenge over v6 and fail. Either make v6 reachable or drop the AAAA record.

When issuance misbehaves, Caddy’s own logs are the first place to look. Run journalctl -u caddy -f (or docker logs -f caddy) while you reload, and it narrates each step of the ACME exchange — which challenge type it chose, whether the authorisation succeeded, and the exact error if not. Nine times out of ten the log names the problem outright.

Ten Minutes Well Spent

The promise at the top was modest: two services, real HTTPS, no certificate babysitting, in roughly ten minutes. Caddy delivers on it because it inverts the usual priorities, treating encryption as the default rather than an advanced add-on. You write a few lines describing which domain maps to which service, and the tedious machinery of certificate issuance and renewal simply vanishes into the background.

Is it worth it, and who is it for? If you are running a large fleet with hundreds of routes, complex canary rules, and a team that already knows Nginx cold, the migration probably isn’t worth the disruption — stay where you are. But for the homelab, the side project, the small business, and anyone who has ever been burned by a silently dead renewal timer, Caddy is close to a no-brainer. The ten-minute setup is real, the self-renewing certificates are the whole point, and once you have experienced a reverse proxy that manages its own TLS, the old way of cron jobs and manual renewals starts to look like a chore you are glad to have left behind. I moved my own homelab to it years ago and have not thought about certificate expiry since — which is exactly the amount of attention certificate expiry deserves.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.