Skip to content

Caddy and TLS

Caddy is a modern web server / reverse proxy with automatic HTTPS built in. Give it a hostname pointed at your public IP, and it obtains, renews, and serves a Let's Encrypt certificate with zero configuration.

Nothing else in the modern ecosystem is this frictionless. This whole guide is worth it for this chapter alone.

Why Caddy (and not OCI's Load Balancer, nginx + certbot, etc.)

Option Trade-off
Caddy Auto-TLS. Simple config. Fine performance for a lab. Small memory footprint. ← this guide
OCI Load Balancer Only the 10 Mbps LB is free (and only 1). Adds ~$0 for the LB itself but every gotcha around cert management, health checks, and shape upgrades. Also not the default free tier — you have to be careful not to pick the flexible LB by accident.
nginx + certbot Powerful, well-known. But you own the cert rotation, the reload, the HTTP-01 challenge, the http↔https redirect. It's fine — it's just work you don't have to do with Caddy.
Cloudflare tunnels / Argo Free tier is generous. But then Cloudflare terminates TLS and you're routing through their proxy, which is a different topology than "your IP, your cert." Great for many things; less "you own the whole stack."

For a lab where you want the whole stack under your control, Caddy wins on simplicity per line of config.

Install

On Ubuntu 24.04 (works identically on AMD and ARM):

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 install script sets up a caddy systemd service and creates /etc/caddy/Caddyfile with a "Hello from Caddy!" default.

First test — HTTP

Point your browser or curl at your instance's public IP on port 80:

curl -v http://<public-ip>/

You should see the "Hello, world!" default response. If not, check:

  • OCI security list allows port 80
  • nftables allows port 80
  • Caddy service is running: sudo systemctl status caddy

First HTTPS URL

Before Caddy can obtain a certificate, DNS must resolve your hostname to this instance's public IP. Skip ahead to DNS with Cloudflare, create an A record, wait for it to resolve, then come back here.

Verify DNS is live:

dig +short lab.example.com   # should return your instance's public IP

Once DNS resolves, edit /etc/caddy/Caddyfile:

{
    email you@example.com
}

lab.example.com {
    respond "Hello from Caddy on OCI!"
}

The { email ... } global block registers you with Let's Encrypt so you'll get email if a certificate is about to expire. Use a real address.

Reload Caddy:

sudo systemctl reload caddy

Watch the logs as Caddy obtains the cert:

sudo journalctl -u caddy -f

You should see (within 10-30 seconds):

certificate obtained successfully

Then in your browser:

https://lab.example.com/

Green padlock. Real cert. Zero manual work. This is the moment where OCI free tier feels magical.

Serve real content

For a static site:

lab.example.com {
    root * /var/www/lab
    file_server
    encode gzip zstd
}

Put your files at /var/www/lab/ (create the directory as sudo, then chown to your user or the caddy user). Reload:

sudo systemctl reload caddy

For a reverse proxy to a local app:

app.example.com {
    reverse_proxy localhost:8000
}

Anything listening on localhost:8000 — a FastAPI, an Express app, a Go binary — is now HTTPS-fronted with automatic cert management. No further work needed.

The Caddyfile format in 30 seconds

# Global options (optional)
{
    email you@example.com
    # servers { protocols h1 h2 h3 }   # HTTP/3 support
}

# One site
hostname.com {
    directive1 arg1 arg2
    directive2 arg1 {
        subdirective a
        subdirective b
    }
}

# Multiple hostnames sharing config
site-a.com, site-b.com {
    respond "Same content, two hostnames"
}

Comments start with #. Reload with sudo systemctl reload caddy after every edit. Caddy validates the file before reloading; if there's a syntax error, it refuses to reload and keeps serving the old config.

Cert renewal

Caddy renews certificates automatically 30 days before expiry. You don't have to do anything. The renewal happens in-process; no service restart is triggered.

You can verify what's stored:

sudo ls /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/

Each hostname has its cert and key here. Don't touch the files by hand.

Common Caddyfile patterns you'll want

HTTP → HTTPS redirect — automatic. Caddy always serves an HTTP → HTTPS redirect on port 80.

Custom error pages:

site.com {
    handle_errors {
        rewrite * /error-{err.status_code}.html
        file_server
    }
}

Basic auth for a specific path:

site.com {
    basicauth /admin/* {
        admin $2y$14$<bcrypt-hash>
    }
    reverse_proxy localhost:8000
}

Generate a bcrypt hash with caddy hash-password.

Header rewrites for a reverse proxy:

api.example.com {
    reverse_proxy localhost:8000 {
        header_up X-Real-IP {remote}
        header_up X-Forwarded-Proto {scheme}
    }
}

Next

Head to DNS with Cloudflare if you haven't set up DNS yet, or to Multiple hostnames on one IP to see how one instance can serve many sites.