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.
Recommended Project Directory Structure #
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
appacts 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_certsfeature and thecaddy trustcommand to make testing encrypted HTTPS web traffic on your local machine painless.- Graceful Reload — Run
docker compose exec caddy caddy reloadto reload the configuration file in a production container without triggering downtime.- Protect Volumes — Avoid running
docker compose down -von production servers so your valuable TLS certificates aren’t deleted.