Skip to content

Automatic HTTPS with Caddy

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026
We earn commissions when you shop through the links below. Full disclosure →

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.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

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

How to self-host Caddy →More self-hosted reverse proxy tools →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building 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.