Skip to content

Automatic HTTPS with Caddy

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

Put a reverse proxy in front of your self-hosted apps and get automatic, auto-renewing Let's Encrypt certificates with a two-line Caddyfile.

Before you start
  • 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.
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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.

Next steps

Run Claude Code with Ollama on Your Own VPSDeploy Coolify on a VPSHow to Deploy AnythingLLM on a VPSHow to Deploy authentik on a VPSHow to Deploy Beszel on a VPSHow to Deploy Bitwarden on a VPSHow to Deploy Checkmate on a VPSHow to Deploy docker-mailserver on a VPSHow to Deploy Gatus on a VPSHow to Deploy Gitea on a VPSHow to Deploy Grafana on a VPSHow to Deploy Headscale on a VPSHow to Deploy Healthchecks on a VPSHow to Deploy Home Assistant on a VPSHow to Deploy Immich on a VPSHow to Deploy Jan on a VPSHow to Deploy LibreChat on a VPSHow to Deploy LocalAI on a VPSHow to Deploy Mailcow on a VPSHow to Deploy Mailu on a VPSHow to Deploy n8n on a VPSHow to Deploy NetBird on a VPSHow to Deploy Netdata on a VPSHow to Deploy Nextcloud on a VPSHow to Deploy Next.js to a VPSHow to Deploy Ollama on a VPSHow to Deploy Open WebUI on a VPSHow to Deploy OpenHands on a VPSHow to Deploy Pangolin on a VPSHow to Deploy Passbolt on a VPSHow to Deploy Plausible Analytics on a VPSHow to Deploy Psono on a VPSHow to Deploy Stalwart on a VPSHow to Deploy Supabase on a VPSHow to Deploy TeamPass on a VPSHow to Deploy Twenty CRM on a VPSHow to Deploy Uptime Kuma on a VPSHow to Deploy Vaultwarden on a VPSHow to Deploy wg-easy on a VPSHow to Deploy Zabbix on a VPSDocker & Compose on Ubuntu 26.04Building AI Workflows with n8nInstall Open WebUI with OllamaAdding AI-Powered Insights to Plausible AnalyticsBuilding AI-Powered Apps with Supabase and pgvector

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.