Caddy, when run in Docker Compose, is not just a web server; it’s an invisible force multiplier that handles TLS, proxies, and static files so seamlessly you might forget it’s there.

Let’s see Caddy in action. Imagine we have a simple Go application that listens on port 8080.

// main.go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from my app!")
	})
	fmt.Println("App listening on :8080")
	http.ListenAndServe(":8080", nil)
}

We’ll build this into a Docker image:

# Dockerfile
FROM golang:1.20-alpine
WORKDIR /app
COPY main.go .
RUN go build -o app
CMD ["./app"]

Now, our docker-compose.yml file:

# docker-compose.yml
version: '3.8'

services:
  my-app:
    build: .
    ports:
      - "8080:8080" # Expose app's internal port

  caddy:
    image: caddy:latest
    ports:
      - "80:80"   # HTTP
      - "443:443" # HTTPS
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile # Mount Caddyfile
      - caddy_data:/data                 # Persistent storage for Caddy
      - caddy_config:/config             # Persistent storage for Caddy config

volumes:
  caddy_data:
  caddy_config:

And the Caddyfile that tells Caddy what to do:

# Caddyfile
my-app.example.com {
    reverse_proxy my-app:8080
}

When you run docker-compose up -d, Caddy will:

  1. Automatically provision and renew TLS certificates for my-app.example.com.
  2. Listen on ports 80 and 443 on your host machine.
  3. Forward incoming requests for my-app.example.com to your my-app service on port 8080.

Your my-app service, running in its own container, doesn’t need to know anything about TLS or external domains. It just serves its content on its internal port. Caddy handles all the complexity of being internet-facing.

The mental model here is that Caddy acts as the public face of your application. It’s the gatekeeper, the security guard, and the receptionist. It handles the secure connection (TLS), the routing (which domain goes where), and can even serve static files or act as a load balancer if you have multiple backend services. Your application services can remain simple, listening on whatever ports they need internally, without being burdened by external concerns.

The volumes section for caddy_data and caddy_config is crucial. caddy_data stores certificates and other dynamic information Caddy needs to operate. caddy_config stores Caddy’s internal configuration, which can be useful for debugging or if you need to migrate Caddy’s state. Without these, Caddy would lose its certificates on restart, and you’d have to re-verify domain ownership.

A common mistake is forgetting to mount the Caddyfile or not giving Caddy the correct permissions to write to its data directory. If Caddy can’t write to /data, it won’t be able to store certificates. You can check this by exec-ing into the Caddy container (docker-compose exec caddy sh) and trying to touch /data/testfile.

The most surprising thing about Caddy is its ability to serve static files directly from a directory, even while also proxying other requests, all within the same Caddyfile block, without requiring a separate configuration entry for static file serving. It intelligently routes based on the request path.

Next, you’ll likely explore how to serve multiple services or subdomains from a single Caddy instance.

Want structured learning?

Take the full Caddy course →