Tutorials 10 min read

Caddy HTTPS Reverse Proxy in 2026: Complete Guide

Set up Caddy 2 with Docker Compose as an HTTPS reverse proxy: a valid Caddyfile, automatic Let's Encrypt certificates, a shared Docker network, checks and troubleshooting.

See our picks
S By Selfhostr Team · no sponsored content
Caddy HTTPS Reverse Proxy in 2026: Complete Guide
ⓘ ⓘ This article may contain affiliate links (no extra cost to you, it funds the site). As an Amazon Associate, Selfhostr earns from qualifying purchases. See the disclosure.
⏱️
~2 hours
Duration
🎚️
Advanced
Difficulty
💶
0 € (free software)
Cost
📦
Docker installed and domain name configuration.
Key Prerequisites

👍 What we like

  • ✓Automatic HTTPS with HTTP-to-HTTPS redirect, no certbot or cron job needed.
  • ✓Short, readable configuration that reloads without downtime.
  • ✓WebSockets and HTTP/3 work without special settings.
  • ✓Free software with a maintained official Docker image.

👎 What to watch

  • ✕Requires ports 80/443 reachable from the Internet and correct DNS for Let's Encrypt.
  • ✕The DNS challenge (blocked ports, wildcards) needs an image built with an extra module.
  • ✕On a Synology NAS, DSM already uses ports 80/443.
  • ✕Ports published by Docker bypass ufw, which requires care.

🏆 Our picks

Affiliate links · same price for you
Recommended
Synology DS920+

Synology DS920+

Voir sur Amazon

Amazon · price and availability on the listing

Essential
📦

Docker Desktop

Voir sur Amazon

Amazon · price and availability on the listing

See all our recommended gear in the shop.

📑 Contents ▾

Caddy is a web server written in Go. It obtains and renews its own TLS certificates (from Let’s Encrypt, with ZeroSSL as a fallback) as soon as a domain name appears in its configuration. That makes it an easy reverse proxy to put in front of your self-hosted services. This guide installs Caddy 2 with Docker Compose on a Linux server and publishes two demo services over HTTPS, each on its own subdomain. You can then replace them with your real applications (Nextcloud, Vaultwarden, Jellyfin…).

Everything uses current Caddy 2 syntax. The reverse_proxy directive already passes the original Host header through and adds X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. It also handles WebSockets. You don’t need to set any headers by hand.

Prerequisites

  • A 64-bit Linux server running Docker. Docker’s documentation currently supports Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS and 26.10. Derivatives such as Linux Mint are not officially supported.
  • A domain name with DNS A records (and AAAA if you have IPv6) for each subdomain, for example app1.exemple.fr and app2.exemple.fr. They must point to your connection’s public IP before Caddy starts for the first time.
  • Ports 80/TCP and 443/TCP reachable from the Internet, forwarded by your router to the server. Add 443/UDP if you want HTTP/3. Port 80 is used for the ACME HTTP challenge and the redirect to HTTPS; port 443 for the TLS-ALPN challenge and the traffic itself.
  • An email address for the ACME account. It is optional but recommended.

About hardware: a Synology DS920+ can run containers with Container Manager, but DSM already uses ports 80 and 443 itself. To run Caddy there, you have to publish other ports and adjust your router’s forwarding. Check the procedure in your DSM version’s documentation. This guide targets a regular Linux server, VM or mini PC. Docker Desktop is fine for local practice, but it isn’t a platform for a proxy exposed 24/7, and it won’t get a public certificate unless ports are forwarded to that computer.

Step-by-step installation

Step 1: Install Docker Engine and the Compose plugin

Docker’s official script installs Docker Engine and the docker compose plugin (Compose v2, written with a space). It does not install the old docker-compose binary. Docker intends the script for testing and quick setups; for production, its documentation recommends the apt repository (see docs.docker.com, “Install Docker Engine on Ubuntu”). You can first preview what the script will do with --dry-run.

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh --dry-run   # shows the actions without installing anything
sudo sh get-docker.sh
sudo usermod -aG docker $USER     # then log out and log back in

Check: both commands must print a version, and the test container must print “Hello from Docker!”.

docker --version
docker compose version
docker run --rm hello-world

Step 2: Create the directory layout and the shared network

Everything lives under /opt/caddy, with the Caddyfile in a conf/ subfolder. The image documentation recommends mounting the directory rather than the file alone: many editors replace the file when saving, and the container would then keep seeing the old version. An external Docker network, proxy, lets Caddy reach containers from other Compose projects by name.

sudo mkdir -p /opt/caddy/conf /opt/apps/demo
sudo chown -R $USER:$USER /opt/caddy /opt/apps
docker network create proxy

Check: the proxy network must appear in the list.

docker network ls --filter name=proxy

Step 3: Start the services to publish

Caddy needs targets that actually exist; otherwise it returns 502 errors. To test the whole chain end to end, start two small demo containers, traefik/whoami and nginx, attached to the proxy network. No ports are published, so only Caddy can reach them.

Create /opt/apps/demo/compose.yaml:

services:
  whoami:
    image: traefik/whoami
    container_name: whoami
    restart: unless-stopped
    networks:
      - proxy

  nginx-demo:
    image: nginx:alpine
    container_name: nginx-demo
    restart: unless-stopped
    networks:
      - proxy

networks:
  proxy:
    external: true
cd /opt/apps/demo
docker compose up -d

Check: both containers must be running.

docker compose ps

For a real application (Nextcloud, for example), follow the same pattern: add networks: [proxy] to its web service, declare the proxy network as external: true, and note the container name and its internal port. That pair is what goes after reverse_proxy.

Step 4: Write the Caddyfile

Create /opt/caddy/conf/Caddyfile. The unnamed block at the top holds global options (here, the ACME email). After it, each site gets its own block, starting with the domain name. Replace the domains and the address with your own.

{
	email vous@exemple.fr
}

app1.exemple.fr {
	reverse_proxy whoami:80
}

app2.exemple.fr {
	reverse_proxy nginx-demo:80
}

That’s all Caddy needs. From this file it knows to get a certificate for each domain, redirect HTTP to HTTPS and forward requests to the named container. Do not put http:// in front of the names: that would turn off automatic HTTPS for that site.

Tip for your first attempts: if your ports or DNS aren’t ready yet, repeated failures can get you blocked by Let’s Encrypt for a while because of its rate limits. While you set things up, you can add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to the global block. Browsers don’t trust staging certificates. Remove the line once everything works, then reload Caddy.

Step 5: Caddy’s Compose file

Create /opt/caddy/compose.yaml. The named volumes caddy_data (certificates, keys, ACME account) and caddy_config are mounted at /data and /config, the locations the official image uses. /data must persist: without it, Caddy would request all its certificates again every time the container is recreated. The official image advises against the latest tag in production, so this pins the 2.11 branch (check the current tag on Docker Hub).

services:
  caddy:
    image: caddy:2.11
    container_name: caddy
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

networks:
  proxy:
    external: true

volumes:
  caddy_data:
  caddy_config:

cap_add: NET_ADMIN is optional; it only lets the QUIC stack enlarge its UDP buffers for HTTP/3. Caddy’s admin API (port 2019) listens only on localhost inside the container. Never publish that port.

Step 6: Validate the configuration and start

cd /opt/caddy
docker run --rm -v "$PWD/conf:/etc/caddy:ro" caddy:2.11 \
  caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose up -d

Check: validate must end with “Valid configuration”. After startup, the logs must show the certificates being obtained (“certificate obtained successfully” for each domain).

docker compose logs -f caddy

Step 7: Reload after a change

After every Caddyfile change, reload without interrupting the service:

cd /opt/caddy
docker compose exec -w /etc/caddy caddy caddy reload

If the new configuration is invalid, the command prints the error and Caddy keeps running the old one.

Verification checks

From a machine outside your network, such as a phone on 4G/5G or a remote server:

curl -I http://app1.exemple.fr          # expected: 308 Permanent Redirect to https://
curl -I https://app1.exemple.fr         # expected: HTTP/2 200
curl -s https://app1.exemple.fr | grep -i x-forwarded   # whoami prints the headers it received
echo | openssl s_client -connect app1.exemple.fr:443 -servername app1.exemple.fr 2>/dev/null \
  | openssl x509 -noout -issuer -dates

The openssl output must show a Let’s Encrypt (or ZeroSSL) issuer and current validity dates. The whoami page must show X-Forwarded-Proto: https, which proves the request went through Caddy.

Security and best practices

  • Expose only 80 and 443. Never put SSH, Samba/SMB, the Docker socket or any admin interface (DSM, Portainer, Caddy’s API on 2019) on the Internet. For remote administration, use a VPN such as Tailscale.
  • Watch the firewall: according to Docker’s documentation, ports published by a container bypass ufw/firewalld rules. So don’t publish any ports on your applications (they go through the proxy network), and keep ufw for the host’s own services:
sudo ufw allow OpenSSH        # before enabling, so you don't lock yourself out
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbose
  • HSTS is not enabled by default. Add it per site only when you’re sure you’ll stay on HTTPS, because browsers remember it: header Strict-Transport-Security "max-age=31536000" inside the site block.
  • Protect applications that have no login with the basic_auth directive (generate the hash with docker compose exec caddy caddy hash-password), or keep them on the local network.
  • Updates: now and then, run docker compose pull && docker compose up -d in /opt/caddy after reading the release notes. Include the caddy_data volume in your backups.
  • Expiry notifications: Caddy doesn’t send emails, and Let’s Encrypt stopped its expiry emails in 2025. To get alerts, set up external monitoring (an HTTPS probe that checks the expiry date).

Troubleshooting common errors

  • “timeout during connect” or “connection refused” in the ACME logs. Cause: port 80 or 443 doesn’t reach the server (router, ISP, firewall, CGNAT). Fix: check port forwarding and test from outside. If your ISP blocks these ports, use the DNS challenge. It requires a Caddy image built with your DNS provider’s module (see the Caddy documentation).
  • “NXDOMAIN” or wrong IP. Cause: DNS doesn’t point to you yet, or a stale AAAA record is left over. Fix: dig +short app1.exemple.fr A and dig +short app1.exemple.fr AAAA must return your public addresses.
  • 502 Bad Gateway. Cause: the target container isn’t on the proxy network, has a different name, or the internal port is wrong. Fix: docker network inspect proxy must list the container, and the port in reverse_proxy must be its internal port, not a published one.
  • Caddyfile changes aren’t applied. Cause: you forgot to reload, or mounted the file alone instead of the directory. Fix: mount ./conf:/etc/caddy, then run the caddy reload command from step 7.
  • “address already in use” at startup. Cause: another web server (Apache, nginx, DSM) already holds 80/443. Fix: find it with sudo ss -tlnp | grep -E ':80|:443', then stop it or change its ports.
  • Let’s Encrypt rate-limit error. Cause: too many failed attempts. Fix: switch to the staging URL (step 4), fix the problem, wait for the block to expire, then switch back to production.

FAQ

Do I need to configure X-Forwarded-* or Host headers?

No. reverse_proxy keeps the original Host header and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. Some applications still need to be told to trust the proxy (for example trusted_proxies in Nextcloud’s configuration). Check their documentation.

How do I check that automatic renewal works?

Caddy renews in the background, well before expiry, and retries at increasing intervals if it fails. Run docker compose logs caddy | grep -i renew, and rerun the openssl command from the Verification section now and then to check the notAfter date.

How long are certificates valid?

Let’s Encrypt currently issues 90-day certificates by default. It has announced a gradual reduction: 64 days for the default profile from February 2027, then 45 days in February 2028. With Caddy you don’t need to change anything; renewal adjusts automatically.

What about services reachable only on my local network?

Local names such as nas.home.arpa, *.internal or localhost get a certificate from Caddy’s local authority, not from Let’s Encrypt. You then need to install that authority’s root (stored in /data/caddy/pki/authorities/local/) on your devices. You can also force this behavior with tls internal in a site block.

Can I use certbot to manage these certificates?

No. Caddy manages its own certificates in /data. certbot can’t see them and shouldn’t touch them.

Read also

Host this yourself

No hardware to manage? This kind of service runs on a managed VPS, ready in minutes. See PlanetHoster plans

Tags: homelabself-hostingcaddyreverse-proxyletsencrypt

Related

Tutorials

Configure Dockge to manage your Docker containers in 2026

Learn how to install and configure Dockge, a lightweight and self-hosted Docker Compose stack manager, perfect for homelabs and self-hosting.

Read
Tutorials

Migrating from a Synology to a DIY NAS in 2026: Step-by-Step Guide

Migrate from a Synology to a DIY NAS on Ubuntu Server 24.04: mirrored ZFS pool, verified rsync copy over SSH, Samba share limited to the local network, and a firewall.

Read
Tutorials

Install OPNsense on a Mini-PC in 7 Steps for 2026

Install OPNsense on an x86-64 mini-PC in 7 steps: verified vga USB image, ZFS install, WAN/LAN interfaces, wizard, Dnsmasq DHCP, Unbound DNS filtering and backup, using the menus from the official documentation.

Read