Skip to content
appsgit

Deploy guide

How to self-host Caddy with Docker Compose

Caddy Docker Compose setup as a reverse proxy with automatic HTTPS: Caddyfile, persistent certificate storage, HTTP/3, routing to other containers and reloads.

  • Updated
  • Beginner
  • About 10 minutes

You will need

  • 1 vCPU / 256 MB RAM
  • Docker + Docker Compose v2
  • A domain with DNS records pointing at the server
  • Ports 80 and 443 reachable from the internet

What is Caddy?

Caddy is a modern web server and reverse proxy written in Go, best known for automatic HTTPS: give it a domain name and it obtains and renews a Let's Encrypt or ZeroSSL certificate with no extra steps. It supports HTTP/2 and HTTP/3 out of the box, has a famously short config format (the Caddyfile), and is open source under the Apache-2.0 license. In a self-hosted setup it is the front door that serves all your apps over HTTPS.

Requirements

  • A Linux server with Docker Engine and Docker Compose v2. Caddy is tiny: 256 MB of RAM is plenty.
  • A domain with A records (or a wildcard) for each hostname you want to serve.
  • Ports 80 and 443 open to the internet, and no other web server already using them.

Step 1: Prepare the server

This guide assumes Ubuntu 24.04 with Docker installed from the official Docker Engine guide.

mkdir -p ~/caddy/conf ~/caddy/site && cd ~/caddy
docker network create proxy

The shared proxy network lets Caddy reach other Compose projects by container name, so those apps never need published ports.

Step 2: Create the Docker Compose file

This follows the Compose example from the official Caddy image docs. Save it as docker-compose.yml:

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
      - ./site:/srv
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

volumes:
  caddy_data:
  caddy_config:

networks:
  proxy:
    external: true

443/udp enables HTTP/3, and NET_ADMIN lets the QUIC library tune UDP buffer sizes. The 2.11 tag follows Caddy 2.11 patch releases.

Now create conf/Caddyfile:

{
    email you@example.com
}

example.com {
    root * /srv
    file_server
}

app.example.com {
    reverse_proxy whoami:80
}

The first block sets the contact email for certificate expiry notices. The second serves static files from ./site. The third proxies to a container named whoami on the proxy network.

Step 3: Start and open the app

Start a test container on the shared network, then Caddy:

docker run -d --name whoami --network proxy --restart unless-stopped traefik/whoami:v1.12
echo '<h1>It works</h1>' > site/index.html
docker compose up -d
docker compose logs -f caddy

Within a few seconds the logs show "certificate obtained successfully" for each hostname. Open https://example.com for the static page and https://app.example.com to see the whoami response headers. HTTP requests redirect to HTTPS automatically.

Step 4: Put it behind HTTPS

Caddy is the HTTPS layer, so this step is about adding your real apps. In each app's Compose file, join the external proxy network and remove its ports: section:

services:
  myapp:
    image: your/app
    networks: [proxy]

networks:
  proxy:
    external: true

Add a site block such as myapp.example.com { reverse_proxy myapp:3000 } and reload. Removing published ports matters: Docker-published ports bypass ufw, so an app published on 0.0.0.0 stays reachable over plain HTTP no matter what the firewall says. If an app must stay on the host network, publish it on 127.0.0.1 and proxy to host.docker.internal after adding extra_hosts: ["host.docker.internal:host-gateway"] to Caddy.

Backups and upgrades

Back up the conf folder and the caddy_data volume (certificates and keys):

docker run --rm -v caddy_caddy_data:/data -v "$PWD":/backup alpine \
  tar czf /backup/caddy-data-$(date +%F).tgz -C /data .

Apply Caddyfile changes without downtime:

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

To upgrade, run docker compose pull && docker compose up -d. For a new minor version, change the tag and skim the release notes.

Troubleshooting

  • Certificate errors in the logs: DNS does not point at this server yet, or ports 80 and 443 are blocked by a cloud firewall. Fix it and Caddy retries automatically.
  • 502 Bad Gateway: Caddy cannot reach the upstream. Check the container name, port and that both are on the proxy network.
  • "address already in use" on port 80: Apache or Nginx is running on the host. Stop and disable it.
  • Too many certificates already issued: you hit Let's Encrypt rate limits by recreating Caddy without its /data volume. Keep the volume and wait for the limit to reset.

Next steps

Add basic_auth or forward authentication through Authentik for private apps, use encode zstd gzip for compression, set security headers with the header directive, and build a custom image with xcaddy if you need DNS-challenge plugins for wildcard certificates.

Spotted something out of date? Tell us and we will update the guide.

FAQ

Caddy questions

Still curious? Email info@appsgit.com.

What ports does Caddy use?

Caddy listens on port 80 for HTTP (redirected to HTTPS and used for certificate validation) and port 443 for HTTPS. Publishing 443/udp as well enables HTTP/3. The admin API on port 2019 stays inside the container.

Is Caddy free?

Yes. Caddy is free and open source under the Apache-2.0 license, including automatic HTTPS, and there is no paid edition of the server.

Caddy vs Nginx: which is better?

Caddy turns on HTTPS automatically, renews certificates itself and needs a few lines of config for a reverse proxy. Nginx is extremely fast and flexible but needs Certbot and longer configuration. For self-hosting apps, Caddy is usually the quicker and safer choice.

Caddy vs Traefik?

Caddy uses a short, readable Caddyfile you edit by hand. Traefik discovers containers through Docker labels and reconfigures itself as they start and stop. Choose Caddy for simplicity, Traefik if you run many changing containers.

Why must I persist Caddy's /data folder?

Caddy stores TLS certificates, private keys and its ACME account in /data. The official image docs warn it must not be treated as a cache. Losing it forces new certificates on every recreate and can hit Let's Encrypt rate limits.

How do I reload the Caddyfile without downtime?

Run docker compose exec -w /etc/caddy caddy caddy reload. Caddy validates the new config and applies it gracefully, keeping existing connections open.