NORMAL
← cd ~/blog
deep-dive #caddy#tls#lets-encrypt#self-hosting#devops

Automatic HTTPS on a cheap VM with Caddy

The fastest way I've found to put a real, auto-renewing TLS cert in front of a backend on a bare VM: point a domain at the box, open two ports, and let Caddy do the ACME dance. No certbot, no cron, no renewal scripts.

You’ve got a backend running on a VM, listening on 127.0.0.1:3001. It works over SSH, but it’s invisible to the internet, and you want it reachable over HTTPS with a real certificate — for free, and without babysitting renewals. Here’s the whole path, including the two prerequisites that trip people up.

The mental model

A TLS certificate is issued by a CA (Let’s Encrypt) only after you prove you control the domain. The standard proof (the HTTP-01 challenge) is: the CA hands your server a token, then fetches http://your-domain/.well-known/acme-challenge/<token> and checks it matches. For that to work, three things must be true before you ever think about certificates:

  1. A domain resolves to your VM’s public IP (an A record).
  2. Port 80 is reachable from the internet (the challenge is plain HTTP).
  3. Port 443 is reachable (that’s where HTTPS will actually serve).

Caddy automates everything after those three — it obtains the cert, serves HTTPS, redirects HTTP→HTTPS, and renews forever. But it can’t fix the prerequisites, and when it “fails to get a certificate,” it’s almost always one of those three, not Caddy.

Step 1 — DNS

Point a domain (or a free subdomain from a dynamic-DNS provider) at the VM’s public IP with an A record. Confirm it before going further:

dig +short api.example.com   # must print your VM's public IP

If this doesn’t return your IP, stop — nothing downstream will work.

Step 2 — open the ports (both firewalls)

This is the step people miss on a managed cloud VM: there are usually two firewalls between the internet and your process — the cloud provider’s security group / security list, and the host OS firewall (iptables/ufw). You must open 80 and 443 in both. Opening one and not the other gives you a silent hang that looks exactly like a broken server.

# host firewall (example: iptables) — insert ACCEPTs before any REJECT rule
sudo iptables -I INPUT 1 -p tcp --dport 80  -j ACCEPT
sudo iptables -I INPUT 1 -p tcp --dport 443 -j ACCEPT
sudo apt install -y iptables-persistent    # persist across reboots
sudo netfilter-persistent save

…and separately add ingress rules for 80/443 in the cloud console’s security list.

Step 3 — install Caddy and point it at your backend

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 -y caddy

The entire configuration is a two-line Caddyfile:

api.example.com {
    reverse_proxy 127.0.0.1:3001
}

That’s it. The bare domain on line 1 tells Caddy: “get a cert for this, serve it on 443, redirect 80→443.” The reverse_proxy line forwards decrypted traffic to your local backend.

sudo systemctl reload caddy
sudo journalctl -u caddy -n 30 --no-pager   # watch for "certificate obtained successfully"

Within ~30 seconds you should see the cert issued, and https://api.example.com serves your backend with a valid certificate. Renewal is automatic — Caddy re-runs the challenge well before expiry, forever. You never touch it again.

Why Caddy over nginx + certbot

With nginx you’d write a server block, install certbot, run it, wire up a renewal timer, and reload nginx on renew. Caddy folds all of that into “put the domain on line 1.” For a single backend behind TLS, it’s the lowest-ceremony option that exists, and the auto-renewal means one less thing that silently expires at 3am six months later.

The one caveat that will bite you later: ephemeral IPs

If your VM has an ephemeral public IP (the default on many free tiers), it changes when you stop/start the instance. The moment it changes, your A record is stale → the domain points nowhere → the next cert renewal fails and, worse, existing traffic 404s. Two fixes: attach a reserved/static IP (usually free, survives restarts), or run a dynamic-DNS updater on the box that re-publishes the current IP on boot. Decide this up front; discovering it after a reboot takes down your “working” setup is a bad afternoon.

The debugging heuristic

When Caddy won’t get a cert, don’t debug Caddy — debug the prerequisites, in order: Does DNS resolve to this box? Is port 80 open in both firewalls? Is anything already bound to 80/443? Nine times out of ten the cert failure is really a port-80 reachability problem wearing a TLS costume.