DEV Community

Cover image for Using Caddy with Docker for Production: A Practical Guide
Shahadath Hossen Sajib
Shahadath Hossen Sajib

Posted on

Using Caddy with Docker for Production: A Practical Guide

Modern web apps need a few things on a VPS: HTTPS with TLS certificates, a reverse proxy for APIs, static file hosting for frontends, and flexible domain/subdomain routing. Traditionally, you might use Nginx plus Certbot, but that requires manual cert renewal and complex configs. Caddy is different — it automates HTTPS and simplifies configuration. In practice, combining Caddy with Docker gives a clean, robust deployment. This guide covers real-world patterns: hosting a frontend SPA, proxying APIs, handling subdomains, and using Caddy as the gateway in a full Docker stack.

Why Use Caddy Instead of Nginx?

Here are some key advantages of Caddy for most Docker-backed deployments:

Automatic HTTPS

By default, Caddy provisions and renews TLS certificates for your domains, and redirects HTTP to HTTPS. For example, simply writing example.com { reverse_proxy localhost:3000 } will auto-enable HTTPS (with certificates from Let's Encrypt). You don't need a separate certbot or cron jobs.

Simple Configuration

Caddyfiles use a clean, readable syntax. Site blocks and directives like reverse_proxy, file_server, and try_files make common tasks straightforward (contrast this with Nginx's more verbose config). This improves maintainability.

Docker Friendly

The official Caddy Docker image has no external dependencies and is lightweight. It supports named volumes for /data (for certs and state) and /config, which you should persist across restarts. Running Caddy in Docker is as simple as docker pull caddy and mounting your Caddyfile and site content.

Basic Caddy with Docker Setup

A minimal Docker Compose setup with Caddy looks like this:

services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./site:/srv
      - caddy_data:/data
      - caddy_config:/config
volumes:
  caddy_data:
  caddy_config:
Enter fullscreen mode Exit fullscreen mode
  • The /etc/caddy/Caddyfile volume is your server configuration.
  • ./site (mounted to /srv) holds your static files (e.g., React/Vue build output).
  • /data is where Caddy stores TLS certs, keys, OCSP staples, etc. Persist /data – do not treat it as ephemeral.
  • /config holds Caddy's JSON runtime config.

Persisting /data and /config is critical: without it, Caddy would re-issue certificates on every restart (and risk hitting Let's Encrypt rate limits). The Docker Hub docs emphasize that the data directory "must not be treated as a cache" and should persist across container restarts. Using named volumes (caddy_data, caddy_config) handles this automatically.

What is a Caddyfile?

The Caddyfile is the core configuration file of Caddy. It defines how your server behaves — similar to what multiple Nginx config blocks would do, but in a much simpler format. With it, you can:

  • Configure HTTPS automatically
  • Serve static sites
  • Proxy APIs to backend services
  • Handle multiple domains or subdomains

In short: Docker runs Caddy, but the Caddyfile defines what Caddy does.

Example Caddyfile (Basic Static Site)

example.com {
    root * /srv
    file_server
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • example.com → domain Caddy listens to
  • root * /srv → points to your static files (from ./site)
  • file_server → enables static file hosting
  • HTTPS is automatically handled by Caddy (no extra config needed)

Why this setup matters

With just Docker Compose + a Caddyfile:

  • You get automatic HTTPS
  • No Certbot or manual SSL setup
  • Clean separation between infra (Docker) and routing (Caddyfile)
  • Production-ready foundation for all later patterns

This is the base layer that everything else in your deployment builds on.

Production Patterns with Caddy + Docker

Once your basic setup is running, the real power of Caddy comes from how you structure it in production. The following patterns cover common, real-world scenarios — serving SPAs, proxying APIs, handling subdomains, and managing multiple services behind a single gateway. You can mix and match these depending on your architecture, whether you're deploying a simple app or a full microservices stack.

Pattern 1: Hosting a Frontend SPA with Caddy

Most modern frontends (React, Vue, etc.) are single-page applications (SPAs). After building (npm run build), you serve the static files. Caddy can do this simply:

example.com {
    root * /srv
    try_files {path} /index.html
    file_server
    encode gzip
    header {
        Cache-Control "public, max-age=31536000, immutable"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • root * /srv tells Caddy where your static files live (the ./site volume).
  • SPA fallback: try_files {path} /index.html means if the requested file doesn't exist, serve index.html instead. This is crucial for client-side routing in SPAs. Without this, refreshing at a nested route (e.g. /dashboard) would return 404.
  • file_server enables serving the files.
  • We also enable gzip compression (encode gzip) and set aggressive caching headers for static assets.

As the Caddy docs explain, this pattern "tries" the file on disk and falls back to the SPA's index.html. The official examples recommend wrapping SPA routing and headers appropriately. For instance, if you have hashed asset filenames, you might add a Cache-Control header so browsers know they can cache assets long-term.

Pattern 2: Reverse Proxy API with Subdomain

Often, your API is running on a different port or container. You can use Caddy to proxy it under its own subdomain. For example:

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

Here, backend would be the Docker service name or IP. Caddy will automatically obtain a TLS certificate for api.example.com and proxy requests to the API container. This keeps clean URLs (no port in the URL) and offloads HTTPS to Caddy. The same could be done to another server: e.g. reverse_proxy 12.34.56.78:8000. Caddy handles the HTTPS and routing, so your frontend code can just call https://api.example.com/... without worrying about certs or ports.

Pattern 3: Using Caddy as the Gateway for a Full Docker Stack

In larger applications, you'll have multiple services (e.g., frontend, backend, database, Redis, admin UIs, etc.). In such cases, Caddy serves as the single entry point to the stack. All public traffic hits Caddy (on ports 80/443), and Caddy internally routes to the right containers. Internally, services communicate via Docker's network (by service name), while only Caddy has public ports.

Illustration: A typical Docker deployment with Caddy at the front. All traffic goes through Caddy (with ports 80/443 open) which proxies to internal services on the Docker network.

A sample docker-compose.yml might look like:

services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    cap_add:
      - NET_ADMIN    # for ACME/DNS or QUIC (optional)
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      api:
        condition: service_healthy
  api:
    image: my-backend-image
    expose:
      - "3000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 5
  pgadmin:
    image: dpage/pgadmin4
    expose:
      - "80"
    depends_on:
      - api
  # (other services like db, redis, etc.)
volumes:
  caddy_data:
  caddy_config:
Enter fullscreen mode Exit fullscreen mode

Caddy service: Only Caddy exposes ports 80 and 443. The cap_add NET_ADMIN is often added to allow HTTP/3 (QUIC) support, but it is optional.

Volumes: We mount our Caddyfile and the named volumes for /data and /config to persist certs and config.

depends_on & healthcheck: Here we demonstrate using Docker healthchecks. Caddy's container is set to wait (depends_on) until the api service passes its health check. This prevents Caddy from starting up too early and proxying to a not-yet-ready backend (which would cause 502 errors). Using health checks makes the startup more robust.

Pattern 4: Proxying with Real IP Headers

In production, you often want your backend to see the real client IP (for logging, rate-limiting, etc.). With Caddy, you can configure headers to pass through the client info. For example:

api.example.com {
    reverse_proxy api:3000 {
        header_up X-Real-IP {remote}
        header_up X-Forwarded-For {remote}
        header_up X-Forwarded-Proto {scheme}
        header_up Host {host}
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures your backend (e.g., an Express or NestJS app) can use X-Real-IP or X-Forwarded-For to know the original client IP.

Pattern 5: Handling WebSockets (Socket.IO, etc.)

If your app uses WebSockets (e.g., chat, live updates, Socket.IO), Caddy's reverse_proxy handles WebSocket upgrades automatically. You don't need special config for basic cases. You might route a specific path for sockets, e.g.:

example.com {
    reverse_proxy /socket.io* api:3000
    @api not path /socket.io*
    handle @api {
        reverse_proxy api:3000
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we proxy /socket.io (WebSocket) and other routes to the same API. In practice, you can often let a single reverse_proxy handle both HTTP and WS if they share a hostname, but defining matchers (like @socket) can avoid conflicts if you also host other services on the same domain.

Pattern 6: Exposing Admin Tools Safely

You may have internal tools (e.g., pgAdmin, Adminer, or Prometheus) that you want accessible via HTTP but don't want extra public ports. Caddy can route these under paths or subdomains. For example:

example.com {
    handle /pgadmin* {
        reverse_proxy pgadmin:80
    }
    handle {
        reverse_proxy api:3000
    }
}
Enter fullscreen mode Exit fullscreen mode

This makes https://example.com/pgadmin point to your pgAdmin container (which listens on port 80 internally). The advantage is that access is over HTTPS with your main cert, and you can later add authentication or IP filtering if needed, without exposing another port or domain.

Pattern 7: Domain Redirects (Canonical Host)

It's good practice to pick one primary domain and redirect others to it (for SEO and consistency). Caddy's redir directive makes this easy. For example:

example.net, www.example.net, example.org {
    redir https://example.com{uri} 301
}
Enter fullscreen mode Exit fullscreen mode

This matches any request to those secondary domains and permanently redirects it to https://example.com (preserving the path via {uri}). Caddy docs show similar examples. By issuing a 301 redirect, search engines will eventually consolidate authority on the main domain.

Pattern 8: Handling Large File Uploads

By default, Caddy may reject very large request bodies. If your API needs to accept big uploads, use the request_body directive to increase the limit. For example:

api.example.com {
    handle {
        request_body {
            max_size 500MB
        }
        reverse_proxy api:3000
    }
}
Enter fullscreen mode Exit fullscreen mode

This allows uploads up to 500 MB (otherwise Caddy would return HTTP 413 if exceeded). The docs show how max_size works – e.g., to set a 10MB limit. Adjust as needed for your application.

CI/CD-Friendly Static Deployments

One nice perk: Caddy will serve new static files as soon as they appear in the mounted directory. No container restart needed. For example, in a CI/CD pipeline, you could build your frontend, scp the files into the /srv directory on the VPS, and Caddy will automatically serve the updates. This means zero downtime for static site changes and no need to bounce the Caddy container for every release (unlike many Docker-based setups).

Production Tips and Common Pitfalls

Persist important volumes

As mentioned, always use named volumes for /data and /config (e.g. caddy_data and caddy_config). Losing these means losing your certificates and config state.

Open only needed ports

On your server's firewall (or cloud security group), only allow 80 and 443 through. All other services (DB, Redis, etc.) should NOT be publicly accessible. They talk to each other over the Docker network.

Validate your Caddyfile

Before reloading in production, run caddy validate --config /etc/caddy/Caddyfile (inside the container). This catches syntax errors ahead of time.

Zero-downtime reloads

Caddy can reload config without dropping connections. Use caddy reload (for Docker: docker exec -w /etc/caddy <container> caddy reload). The Docker Hub docs even suggest this pattern for graceful reloads.

Healthchecks

As shown above, use Docker healthchecks for backend services and depends_on with condition: service_healthy so that Caddy waits for them.

Logging

If things aren't working, check docker logs caddy and docker logs <service> for clues. Common issues include DNS typos (make sure service names match your Compose), wrong port numbers, or forgetting to expose a port on a container (use expose or ports appropriately).

Troubleshooting

If your site isn't reachable, verify DNS (A records for your domain pointing to the server) and that ports 80/443 are open. The official Caddy docs have good troubleshooting tips.

Final Thoughts

Using Caddy as your Docker gateway lets you offload many tedious tasks (TLS, redirects, proxying) to a simple configuration. Whether you're doing a simple static site or a complex microservice architecture, the same patterns scale up. The Caddyfile syntax is easy to understand, and the built-in automation (automatic HTTPS, reloads, etc.) reduces maintenance overhead.

Overall, for Docker-based deployments on a VPS, Caddy often means fewer moving parts and less "boring work" (no cron jobs for certs!). Once you've set up these patterns, you can add extra features like encryption or auth without re-architecting. In short: If you're already comfortable with Docker, adding Caddy as your front door is usually one of the cleanest, quickest ways to get secure, reliable hosting in production.

Top comments (0)