DEV Community

Denis Smoliakov
Denis Smoliakov

Posted on

Automatic HTTPS for Your Dockerized App with Caddy

Most "serve your app over HTTPS" tutorials still reach for Nginx plus certbot: hand-written reverse-proxy configs, a cron job for certificate renewal, and a debugging session for every acme-challenge failure. Caddy removes all of that. It obtains and renews TLS certificates automatically, and the entire setup fits in two lines of config.

In this tutorial you'll put a Dockerized application behind Caddy so that it's served over HTTPS with zero certificate management. No certbot, no renewal timers, no Nginx.

Prerequisites

  • A Linux server with Docker and Docker Compose installed (tested on Ubuntu 22.04+).
  • A domain name you control, with DNS access.
  • Ports 80 and 443 reachable from the internet (needed for the ACME HTTP-01 challenge).

Step 1 — Run your app on an internal port

You can use any containerized app. For this tutorial, a minimal example:

# docker-compose.yml
services:
  app:
    image: nginx:alpine   # stand-in for your real application
Enter fullscreen mode Exit fullscreen mode

The important part: your app must be reachable from the Caddy container, not from the internet. Either don't publish its ports at all, or bind them to localhost only. Caddy will be the only service exposed publicly.

If your real app listens on port 3000, leave it unexposed:

services:
  app:
    image: your-app:latest
    # no `ports:` here — reachable only inside the compose network
Enter fullscreen mode Exit fullscreen mode

Step 2 — Add Caddy to the stack

Add a caddy service to the same compose file:

services:
  app:
    image: your-app:latest

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3 (QUIC)
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:
Enter fullscreen mode Exit fullscreen mode

Two things matter here:

  • caddy_data persists TLS certificates across container rebuilds. Losing it forces re-issuance and counts against Let's Encrypt rate limits.
  • Port 443/udp enables HTTP/3; it's optional but costs nothing.

Now the Caddyfile. Create Caddyfile next to your compose file:

app.example.com {
    reverse_proxy app:3000
}
Enter fullscreen mode Exit fullscreen mode

Replace app.example.com with your domain and app:3000 with your app's service name and port. That's the entire configuration. Caddy reads the site address, sees it's a real domain (not localhost), and automatically switches on TLS: it will solve the ACME HTTP-01 challenge, obtain the certificate from Let's Encrypt, store it in /data, and renew it in the background whenever it nears expiry.

Step 3 — Point DNS and open the firewall

Create an A record for your domain pointing at the server's public IP. If you run ufw, allow the web ports:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
Enter fullscreen mode Exit fullscreen mode

Step 4 — Start the stack and verify

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Watch Caddy obtain the certificate on first run:

docker compose logs -f caddy
Enter fullscreen mode Exit fullscreen mode

You should see lines like:

certificate obtained successfully
certificate stored in storage
Enter fullscreen mode Exit fullscreen mode

Then verify from any machine:

curl -I https://app.example.com
Enter fullscreen mode Exit fullscreen mode

Expect HTTP/2 200 (or HTTP/3 from a browser). Your app is now served with a valid, automatically-renewed certificate.

Useful variations

Parameterize the domain. Pass it through the environment instead of editing the Caddyfile:

{$DOMAIN} {
    reverse_proxy app:3000
}
Enter fullscreen mode Exit fullscreen mode
  caddy:
    environment:
      DOMAIN: app.example.com
Enter fullscreen mode Exit fullscreen mode

Test against staging. If you're iterating and don't want to burn Let's Encrypt rate limits, point Caddy at the staging CA while you experiment:

{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

app.example.com {
    reverse_proxy app:3000
}
Enter fullscreen mode Exit fullscreen mode

Remove the acme_ca line for production.

Conclusion

Caddy turns "HTTPS for my container" from an ops project into two lines of config: one site block, one reverse_proxy. Certificates are issued on first start and renewed silently forever after, and the persistent /data volume keeps them safe across rebuilds. If you're starting a new Docker deployment, there's little reason to manage certificates by hand anymore.

Top comments (0)