beginner

How to set up a reverse proxy with Caddy and Docker

Put your self-hosted containers behind one HTTPS entry point. Caddy fetches and renews Let’s Encrypt certificates automatically, so a working reverse proxy is only a few lines of config.

A reverse proxy sits in front of your application containers and is the single public entry point to your server. Clients connect to it over HTTPS on port 443; it terminates TLS and forwards each request to the right backend container over Docker’s internal network, choosing the backend by the hostname that was requested. The backends themselves publish nothing to the host — only the proxy binds ports 80 and 443. That is what lets one machine, on one IP address, serve a dozen services, each on its own subdomain and each with a real certificate, without them fighting over ports.

Why Caddy

Caddy obtains and renews TLS certificates from Let’s Encrypt (and ZeroSSL as a fallback) on its own. There is no certbot, no renewal cron job, and no manual step — you name a domain in the config and Caddy completes the ACME challenge the first time that hostname is requested, then keeps the certificate fresh for as long as it runs. The configuration file, the Caddyfile, is also far shorter than the nginx or Traefik equivalent: a complete HTTPS reverse proxy for one service is three lines. Caddy defaults to HTTPS, HTTP/2, and modern cipher suites, so the secure setup is the one you get without extra tuning. For a self-hoster that automatic-certificate behaviour is the whole appeal.

Before you start

  • A domain name you control, and the ability to edit its DNS records.
  • A DNS A record (or AAAA for IPv6) pointing your hostname at the server’s public IP address. It must resolve before Caddy requests a certificate, because Let’s Encrypt validates by connecting back to that name.
  • Ports 80 and 443 reachable from the internet — forwarded through your router and open in any firewall. Port 80 carries the ACME HTTP challenge and the HTTP→HTTPS redirect; 443 serves real traffic.
  • Docker 24+ and Docker Compose v2 on the server.

Caddy proves it controls your domain by answering a challenge from Let’s Encrypt on port 80. If the DNS record does not yet resolve to this server, or port 80 is closed, the challenge fails, no certificate is issued, and every request returns a TLS error. Let’s Encrypt also rate-limits failures, so confirm "dig +short your.domain" returns your server’s IP and that port 80 is open before you start the stack — do not loop on retries while DNS is still wrong.

The compose file

The pattern that scales is a shared, external Docker network — conventionally named "proxy" — that every stack attaches to. Marking it external: true means no single compose project owns it; Caddy lives on it, and each backend joins it too, so Caddy can reach any container by its service name. Docker’s embedded DNS resolves a name like whoami to the container’s internal address, so Caddy never needs a published host port for the backend. Create the network once with "docker network create proxy", then bring up the proxy. The example below runs Caddy plus a tiny whoami test backend; swap whoami for your real service once it works.

docker-compose.yml
services:
  caddy:
    image: caddy:2.8.4-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - proxy

  # A throwaway backend so you can confirm the proxy works.
  # Replace it with your real service.
  whoami:
    image: traefik/whoami:v1.10.3
    restart: unless-stopped
    networks:
      - proxy

volumes:
  caddy-data:
  caddy-config:

networks:
  proxy:
    external: true

The caddy-data volume is important: it holds the issued certificates and the ACME account key. Keep it, and Caddy reuses certificates across restarts instead of asking Let’s Encrypt for new ones every time — which matters because of those rate limits.

The Caddyfile

The Caddyfile lives next to your compose file and is mounted read-only. Each block starts with a site address — the public hostname — and that hostname alone is what tells Caddy to fetch a certificate for it. Inside, reverse_proxy forwards to the backend by its Docker service name and the port the container actually listens on (its internal port, not a host-published one). The whoami image listens on port 80:

Caddyfile
whoami.example.com {
	reverse_proxy whoami:80
}

# Add one block per service. Each gets its own certificate.
# app.example.com {
# 	reverse_proxy myapp:3000
# }

Replace whoami.example.com with your real subdomain and reverse_proxy whoami:80 with your service’s name and port. Bring the stack up with "docker compose up -d", watch the first run with "docker compose logs -f caddy", and you should see Caddy obtain the certificate and start serving. Visiting the hostname over HTTPS now returns the whoami page — proof the proxy, TLS, and Docker DNS are all wired correctly.

Adding your other stacks

Every other service joins the same way, even from a separate compose project. In that stack, attach the service to the external proxy network and declare the network at the bottom of its compose file; then add a matching block to the Caddyfile. This is exactly what the ready-made Caddyfile tab on each stack page on this site gives you — copy it in and add the network lines:

another-stack/docker-compose.yml
services:
  vaultwarden:
    image: vaultwarden/server:1.32.5-alpine
    restart: unless-stopped
    networks:
      - proxy

networks:
  proxy:
    external: true

Caddy proxies WebSocket upgrades transparently — unlike older nginx setups, you do not add Connection or Upgrade headers by hand. Apps that need live updates, like Vaultwarden’s notifications hub, work through a plain reverse_proxy line with no extra configuration.

Common problems

  • Certificate never issues: almost always DNS or ports. Confirm "dig +short your.domain" returns this server’s public IP, that ports 80 and 443 are forwarded and open, and read "docker compose logs caddy" for the ACME error. While testing, you can point Caddy at Let’s Encrypt’s staging CA to avoid burning the production rate limit.
  • 502 Bad Gateway: Caddy was reached but could not talk to the backend. The service name or port in reverse_proxy is wrong, or the backend is not on the proxy network. Check the name matches the compose service exactly, use the container’s internal port, and run "docker network inspect proxy" to confirm both containers are attached.
  • Site loads on HTTP but not HTTPS, or shows a self-signed warning: the certificate was not issued — work the certificate checklist above. Caddy serves a temporary internal certificate until the real one is in place, which browsers reject.

Once Caddy is running, adding a service is two steps for the rest of your server’s life: put the container on the proxy network, and add a three-line block to the Caddyfile. Every stack on StackBuilder ships a Caddyfile block built for exactly this setup, so you can paste it in and move on.

Stacks for this

Related stacks