Caddy with Docker Compose #

Docker Compose is a highly efficient industry-standard tool for defining and running multi-container Docker applications. In modern production environments, a web server like Caddy almost never runs alone; it’s usually paired with backend services (Node.js, Go, Python), databases (PostgreSQL, MySQL), caches (Redis), and message queues. By writing a single declarative docker-compose.yml file, you can unite this entire stack into one integrated lifecycle that can be stored in version control.

This article dives deep into deploying Caddy with Docker Compose — from project structure setup, production-grade network isolation, special PHP-FPM integration using a shared socket, to local HTTPS scenarios for your development environment.


Why Choose Docker Compose for Caddy? #

To understand why Docker Compose is highly recommended over manually executing docker run commands, let’s look at the comparison:

Without Compose (manual docker run):       With Compose (One File):
──────────────────────────────────          ────────────────────────────
docker run ... caddy                        docker compose up -d
docker run ... app
docker run ... db
docker network create ...
docker network connect ...
docker network connect ...
(Very manual, error-prone)                  (Declarative, consistent, documented)

By switching to Docker Compose, you gain:

  • Environment Consistency (Reproducibility) — Other developer team members can run an identical application stack just by copying the Compose file.
  • Network Isolation — You can isolate the database from the internet and from Caddy, so only your backend application can connect to the database.
  • Automatic Volume Management — Ensures Let’s Encrypt TLS certificate data mapping stays consistently persistent.

To keep your project organized and manageable, use the following directory structure:

your-project/
  ├── docker-compose.yml       ← Main multi-service configuration file
  ├── docker-compose.dev.yml   ← Override file for local development
  ├── .env                     ← Environment variables file (DON'T commit to Git!)
  ├── .env.example             ← Example template of the .env file (must be committed)
  ├── Caddyfile                ← Main Caddy configuration
  └── app/
      ├── Dockerfile           ← Your backend build configuration
      └── src/                 ← Backend application source code

Minimal Setup: Caddy and a Backend Application #

Let’s create the most basic Docker Compose configuration connecting Caddy as a reverse proxy to a Node.js application container.

1. Create the docker-compose.yml File #

services:
  caddy:
    image: caddy:2.8.4
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

  app:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    command: node server.js
    # IMPORTANT: We don't need to open ports (port mapping) for the 'app' container
    # to the host. Security is preserved because only Caddy has access to the app's port.

volumes:
  caddy_data:      # Stores SSL certificates persistently
  caddy_config:    # Stores internal adapted configuration files

2. Create the Caddyfile #

In your Caddyfile, simply reference the service name you defined in the docker-compose.yml file (that is, app):

# Point at your domain
yoursite.com {
    # Docker's internal DNS resolves 'app' to the Node.js container's internal IP
    reverse_proxy app:3000
}

You can run this entire stack with:

# Run all services in the background
docker compose up -d

# Check the status of active containers
docker compose ps

Production Configuration: Caddy + Node.js + PostgreSQL Network Isolation #

In a real production scenario, you must apply the principle of least privilege to your network architecture. The database must not be directly accessible from the internet, and it shouldn’t even be reachable directly by Caddy.

Here’s a production-grade docker-compose.yml with dual network isolation:

services:
  caddy:
    image: caddy:2.8.4
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - frontend_network
    depends_on:
      - app

  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://app_user:***@db:5432/app_database
      PORT: 3000
    networks:
      - frontend_network    # Connects the app with Caddy
      - backend_network     # Connects the app with the Database
    depends_on:
      db:
        condition: service_healthy # Waits until the database is ready to accept connections
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app_database
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend_network   # Only on the backend network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_database"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

networks:
  frontend_network:
    # Network serving public web traffic
  backend_network:
    # Highly secure private internal network

volumes:
  caddy_data:
  caddy_config:
  postgres_data:

Visualizing the Network Flow Architecture #

flowchart TD
    INTERNET["INTERNET"] --> caddy["caddy (frontend_network)"]
    caddy --> app["app (frontend_network & backend_network)"]
    app --> db["db (backend_network)"]

With the separated network structure above:

  • The Caddy process can’t reach the Database directly, minimizing the blast radius of exploitation if Caddy gets compromised.
  • The Database sits in backend_network, tightly closed off from outside server access.
  • The app acts as a bridge because it’s connected to both networks.

You must put the database password in your local .env file:

# .env file (store on the local host, DON'T commit to Git)
DB_PASSWORD=OurSecretDatabaseKey123!

PHP-FPM Integration with a Shared Unix Socket Volume #

Running PHP applications (like WordPress or Laravel) in Docker with high performance requires FastCGI integration. Communication between Caddy and PHP-FPM runs much faster over a Unix Socket than over a TCP connection (port 9000).

To achieve this in Docker Compose, you need to map a shared volume to share the .sock file:

services:
  caddy:
    image: caddy:2.8.4
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./public:/var/www/html:ro        # Web root mounted read-only
      - caddy_data:/data
      - caddy_config:/config
      - php_socket:/run/php              # Where the socket file lives
    depends_on:
      - php-fpm

  php-fpm:
    image: php:8.3-fpm-alpine
    restart: unless-stopped
    volumes:
      - ./public:/var/www/html:rw        # PHP-FPM needs write access
      - php_socket:/run/php              # Shares the socket with Caddy
    # We configure PHP-FPM to listen on a unix socket file
    command: >
      sh -c "
        sed -i 's|listen = 127.0.0.1:9000|listen = /run/php/php-fpm.sock|' 
          /usr/local/etc/php-fpm.d/www.conf &&
        sed -i 's|;listen.owner = www-data|listen.owner = root|' 
          /usr/local/etc/php-fpm.d/www.conf &&
        sed -i 's|;listen.group = www-data|listen.group = root|' 
          /usr/local/etc/php-fpm.d/www.conf &&
        sed -i 's|;listen.mode = 0660|listen.mode = 0666|' 
          /usr/local/etc/php-fpm.d/www.conf &&
        php-fpm
      "      

volumes:
  caddy_data:
  caddy_config:
  php_socket:      # Shared named volume for the PHP unix socket

Your Caddyfile configuration to serve PHP-FPM via the Unix Socket:

yoursite.com {
    root * /var/www/html
    
    # Forward all php file requests to the Unix socket in the shared volume
    php_fastcgi unix//run/php/php-fpm.sock
    
    encode gzip
    file_server
}

Local HTTPS in the Development Environment #

One of Caddy’s most beloved features is its ability to provide automatic HTTPS for local environments (localhost) using Caddy’s internal Certificate Authority (CA). You can write a dedicated Compose file for development so your browser trusts your local certificates.

1. Create the docker-compose.dev.yml File #

services:
  caddy:
    image: caddy:2.8.4
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
      - caddy_data_dev:/data
      - caddy_config_dev:/config
      # Map the internal local certificate folder to the host so it can be read
      - ./dev-certificates:/root/.local/share/caddy/pki/authorities/local

  app:
    image: node:20-alpine
    environment:
      NODE_ENV: development
    volumes:
      - ./app:/app
    command: node server.js

volumes:
  caddy_data_dev:
  caddy_config_dev:

2. Create the Caddyfile.dev File #

{
    # Force the use of the internal Certificate Authority (Local CA)
    local_certs
}

# Our local domain
localhost, app.localhost {
    reverse_proxy app:3000
}

Run your development stack:

docker compose -f docker-compose.dev.yml up -d

3. Install Caddy’s Root CA into Your Host System #

To stop your browser from showing the Your connection is not private warning (untrusted SSL), you must register the Root CA generated by Caddy (which was exported to the ./dev-certificates/ folder) into your host computer’s certificate database.

If your host machine has the Caddy CLI installed, just run:

# Add Caddy's local root certificate to your host system's trust store
# This command will ask for your host OS admin password confirmation
caddy trust

After running the command above, your browser can now open https://localhost with a valid green padlock indicator.


Frequently Used Docker Compose CLI Commands #

Here’s a summary of the commands you need to know when managing Caddy with Docker Compose:

# 1. Run the application stack in the background
docker compose up -d

# 2. Restart and force the image build process (if the Dockerfile changed)
docker compose up -d --build

# 3. Completely stop the services (removing containers and internal networks)
docker compose down

# 4. Completely stop the services AND delete their VOLUMES (CAUTION: SSL certificates LOST!)
docker compose down -v

# 5. Check Caddy's active logs in real time
docker compose logs -f caddy

# 6. Validate the Caddyfile inside the container
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

# 7. Reload the configuration without downtime
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

# 8. Open a shell terminal in the Caddy container
docker compose exec caddy sh

Pitfalls to Avoid #

1. The docker compose down -v Command in Production #

The -v flag instructs Docker to delete all persistent volumes associated with the stack. Running it on a production server deletes the caddy_data named volume holding your Let’s Encrypt SSL certificates. Solution: Just run docker compose down without the -v flag for routine maintenance.

2. The Caddyfile Isn’t Picked Up After Editing #

When you edit the Caddyfile on the host, the Caddy container doesn’t apply the change automatically. Solution: You must always trigger a manual reload using: docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

3. Backend Services Fail to Connect to the Database at Startup #

Even if you write depends_on: - db, Docker’s built-in dependency only ensures the database container has started, not that the database engine is ready to serve requests. As a result, your backend may crash with a connection refused error on first start. Solution: Apply a healthcheck to your database and use the condition: service_healthy parameter in the depends_on block of your backend application service.


When to Switch to Alternatives / Not Use This #

Keep using Docker Compose if:
  ✓ You manage a multi-service architecture (Caddy + App + Database + Cache) in an integrated way.
  ✓ You want to document your network topology and server volumes as code (IaC).
  ✓ You need a local development environment identical to production.
  ✓ You want to streamline your developer team's workflow.

Consider other methods if:
  ✗ You only need Caddy as a simple static file server on a single VM (Use APT).
  ✗ Your infrastructure is managed by a large orchestration system like Kubernetes or Docker Swarm (Use Helm Chart/Kubernetes Manifest).

Summary #

  • Declarative Orchestration — Docker Compose simplifies managing Caddy along with all its service dependencies in one configuration file.
  • Secure the Database — Always separate frontend and backend networks so your database is fully protected from outside access.
  • PHP Socket Connection — Leverage Unix Socket file mapping through a shared volume for super-fast interaction between Caddy and PHP-FPM.
  • Development SSL — Use the local_certs feature and the caddy trust command to make testing encrypted HTTPS web traffic on your local machine painless.
  • Graceful Reload — Run docker compose exec caddy caddy reload to reload the configuration file in a production container without triggering downtime.
  • Protect Volumes — Avoid running docker compose down -v on production servers so your valuable TLS certificates aren’t deleted.

← Previous: Docker   Next: Compiling from Source →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact