Automatic HTTPS with Caddy
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Put a reverse proxy in front of your self-hosted apps and get automatic, auto-renewing Let's Encrypt certificates with a two-line Caddyfile.
- A VPS with a public IP — any $5 box works (1 vCPU / 1 GB RAM is plenty for Caddy).
- A domain name with an A record pointed at that IP, already propagated.
- Docker and Docker Compose installed on the server.
- Ports `80` and `443` open to the internet — Caddy needs both for the ACME challenge.
Most self-hosted apps serve plain HTTP on some port. Caddy sits in front,
terminates TLS, and gets a real Let's Encrypt certificate automatically — no
certbot cron jobs, no renewal timers to babysit, no openssl incantations.
Point a domain at it and it handles the rest, including renewal, OCSP
stapling, and HTTP→HTTPS redirects, with zero ongoing maintenance. This guide
targets Caddy 2.x (the caddy:2 image), which is what you want — the 1.x
line is unmaintained.
The trade you're making versus Nginx + Certbot: less config, fewer moving parts, but less granular control over the TLS handshake itself. For a handful of self-hosted apps behind one box, that trade is almost always worth it.
Point your domain at the box first
Caddy proves domain ownership using the ACME HTTP-01 challenge: Let's Encrypt's servers reach out to your domain on port 80 and expect Caddy to answer with a specific token. That means DNS has to resolve to this server before Caddy can issue anything — there's no way around this ordering.
Create an A record for your subdomain (e.g. app.example.com) pointing at
the server's public IPv4 address (and an AAAA record too, if the box has
IPv6). Then confirm it's actually resolving before you touch Caddy:
dig +short app.example.com
# should print the server's IP — if it prints nothing, DNS hasn't propagated yet
Propagation is usually fast (minutes) but can take longer depending on your
registrar's TTL. If dig returns nothing, wait and retry rather than
starting Caddy — see the DNS section under Troubleshooting for what happens
if you jump the gun.
Open ports 80 and 443
Caddy needs both ports open, not just 443: port 80 handles the ACME HTTP-01 challenge and the automatic HTTP→HTTPS redirect. On the host firewall:
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
If you're on a cloud VPS (Hetzner, DigitalOcean, AWS, etc.), this is not
enough by itself. Most providers put a separate network-level firewall in
front of the box — Hetzner Cloud Firewall, DigitalOcean Cloud Firewalls, AWS
Security Groups — and by default several of them only open SSH, leaving 80
and 443 blocked at the network edge even though ufw on the box allows
them. Check your provider's firewall panel and explicitly allow inbound TCP
80 and 443 from 0.0.0.0/0 (and ::/0 for IPv6). This is one of the most
common "Caddy just hangs, no certificate" causes on a fresh VPS, and it
won't show up in Caddy's own logs at all — from Caddy's point of view, no
request ever arrived.
The whole config is the Caddyfile
Caddy's headline feature: name a site and a backend, and it handles
certificates for you. Assuming an app listening on localhost:3001:
# /etc/caddy/Caddyfile
app.example.com {
reverse_proxy localhost:3001
}
That's it — no separate TLS block, no cert paths, no renewal cron. On the
first request to app.example.com, Caddy notices it doesn't have a
certificate, requests one from Let's Encrypt in the background, and serves
plain HTTP briefly until it's issued (usually a couple of seconds). After
that it's HTTPS-only, with HTTP requests automatically redirected.
Run Caddy alongside your app in Compose
The clean pattern is to put Caddy in the same Docker network as the app it proxies, so it can reach it by service name:
services:
app:
image: louislam/uptime-kuma:1
restart: unless-stopped
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
With app and caddy on the same Compose network, the Caddyfile target
becomes the service name and its internal port — not localhost:
app.example.com {
reverse_proxy app:3001
}
Two volumes matter here, not just one. caddy_data stores the issued
certificates and the ACME account key — losing it means every restart
looks like a brand-new install to Let's Encrypt, which is how people
accidentally burn into rate limits. caddy_config stores Caddy's autosaved
running configuration; it's smaller in consequence but still worth
persisting so a container replacement doesn't silently reset it.
Bring it up and confirm both containers are actually running:
docker compose up -d
docker compose ps
# NAME IMAGE STATUS
# app ... Up
# caddy caddy:2 Up
Verify HTTPS is actually issued and working
Don't just eyeball the padlock icon — check it from the command line so you know exactly what happened.
Watch Caddy issue the certificate. Tail the logs right after you hit the
domain for the first time (or right after docker compose up -d if DNS was
already live):
docker compose logs -f caddy
A healthy first issuance looks like this (Caddy logs structured JSON by default):
{"level":"info","logger":"tls.obtain","msg":"acquiring lock","identifier":"app.example.com"}
{"level":"info","logger":"tls.obtain","msg":"obtaining certificate","identifier":"app.example.com"}
{"level":"info","logger":"tls.issuance.acme","msg":"trying to solve challenge","identifier":"app.example.com","challenge_type":"http-01"}
{"level":"info","logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"app.example.com"}
{"level":"info","logger":"tls.obtain","msg":"releasing lock","identifier":"app.example.com"}
Confirm the cert from outside. curl -v shows the full TLS handshake
and the certificate chain that was actually presented:
curl -v https://app.example.com 2>&1 | grep -E "subject:|issuer:|SSL certificate|HTTP/2"
# subject: CN=app.example.com
# issuer: C=US; O=Let's Encrypt; CN=E5
# SSL certificate verify ok.
# < HTTP/2 200
The issuer line is the useful part: if it says Let's Encrypt, the real
production certificate issued. If it says something like Caddy Local Authority, you're looking at Caddy's internal self-signed CA, which means
the ACME step never completed and Caddy fell back to a local cert to avoid
serving plain HTTP — that's a signal to go check the logs, not a working
state.
Confirm the redirect. Plain HTTP should 308-redirect to HTTPS, not serve content:
curl -I http://app.example.com
# HTTP/1.1 308 Permanent Redirect
# Location: https://app.example.com/
Add more apps in seconds
Each new app is one more block in the same Caddyfile:
git.example.com { reverse_proxy gitea:3000 }
notes.example.com { reverse_proxy outline:3000 }
Reload without dropping connections:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
caddy reload does a graceful config swap — existing connections finish,
new ones use the new config — so you don't need to restart the container.
Each new hostname gets its own certificate issued independently on first
hit.
Harden it for production
The two-line Caddyfile is correct but bare. A few additions are worth making before you put anything real behind it.
Security headers. Caddy doesn't set HSTS or other hardening headers by
default — add them explicitly with a header block:
app.example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
reverse_proxy app:3001
}
The -Server line removes the Server response header entirely rather
than advertising Caddy to anyone probing the box. Be deliberate with
preload on HSTS — submitting a domain to the HSTS preload list is very
hard to reverse.
Restrict access to admin UIs. A lot of self-hosted tools (dashboards,
*-admin panels) shouldn't be open to the whole internet just because
they're behind TLS now. Caddy's basic_auth directive gates a site behind
HTTP basic auth, with the password stored as a bcrypt hash — never
plaintext in the Caddyfile:
docker compose exec caddy caddy hash-password
# prompts for a password, prints a $2a$... bcrypt hash
admin.example.com {
basic_auth {
admin $2a$14$replace-with-the-hash-from-above
}
reverse_proxy admin-app:8080
}
For something more binary, restrict by source IP instead using a named matcher:
admin.example.com {
@notallowed not remote_ip 203.0.113.0/24
respond @notallowed 403
reverse_proxy admin-app:8080
}
Log rotation. With no log directive, Caddy writes only its own
startup/admin logs to stdout, which docker compose logs reads and which
Docker's own log driver rotates according to its daemon config (worth
checking docker info | grep -i logging isn't set to unbounded). If you
want structured per-site access logs instead, Caddy writes and rotates them
itself:
app.example.com {
log {
output file /var/log/caddy/app.example.com.log {
roll_size 10MB
roll_keep 10
roll_keep_for 720h
}
}
reverse_proxy app:3001
}
Caddy rolls even without those sub-options (100 MB / 10 files / 90 days by default) — the block above just makes the retention explicit rather than relying on the defaults.
Troubleshooting
"DNS problem: NXDOMAIN" in the logs. This is Let's Encrypt telling
Caddy the domain doesn't resolve yet — you started Caddy (or hit the
domain) before the A record propagated:
{"level":"error","logger":"tls.obtain","msg":"could not get certificate from issuer","identifier":"app.example.com","error":"... urn:ietf:params:acme:error:dns ... DNS problem: NXDOMAIN looking up A for app.example.com - check that a DNS record exists for this domain"}
Fix: confirm with dig +short app.example.com, wait for it to return the
right IP, then hit the domain again (or caddy reload) — Caddy retries
issuance on the next request, you don't need to restart the container.
Certificate never issues, no error either. Almost always the cloud
firewall, not Caddy — see "Open ports 80 and 443" above. Test from outside
the box, not from localhost on the box itself:
curl -v --connect-timeout 5 http://app.example.com
# curl: (28) Connection timed out — nothing reached the box at all
A connection timeout (not "connection refused") on port 80 or 443 from an
external host, while ufw status shows the ports open locally, points
straight at the provider's network firewall panel.
Port already in use. If Caddy's container won't start and logs
address already in use for :80 or :443, something else on the host —
Nginx or Apache from an earlier setup, or a stray Caddy process — already
owns it:
ss -tlnp | grep -E ':80|:443'
Stop or reconfigure whatever owns the port; Caddy can't share it.
Hitting Let's Encrypt rate limits while testing. Let's Encrypt caps failed validations at roughly 5 per account/hostname/hour, and duplicate certificates for the same exact hostname set at 5 per week. If you're iterating on a Caddyfile and re-triggering issuance repeatedly, switch to the staging CA first — it has much higher limits and issues certs your browser won't trust, which is exactly what you want while debugging:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
app.example.com {
reverse_proxy app:3001
}
Once docker compose logs caddy shows a clean issuance against staging,
delete the acme_ca override (or point it back at the production
directory) and reload — Caddy will issue a real, trusted certificate on the
next request.
Caddyfile syntax error. A malformed Caddyfile makes the container crash-loop rather than start with a broken config. Validate before you reload:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
It reports the exact line and the parse error (typically a missing {/}
or a directive typo), which is faster than reading through
docker compose logs caddy for a startup stack trace.
Where to go next
Need the Docker base layer first? See
Docker & Compose on Ubuntu.
Then pick an app from the self-hosting guides and drop it
behind Caddy — the pattern above (one Caddyfile block per hostname, shared
caddy_data/caddy_config volumes, headers and auth on anything
admin-facing) scales to as many apps as one box can run.