Caddy in Docker #
Running Caddy inside a Docker container is one of the most popular approaches to adopting modern containerization-based infrastructure. Running Caddy in Docker gives you clean process isolation, easy development testing, consistent configuration between your local machine and production servers, and streamlined deployments through CI/CD workflows.
However, behind its popularity lies one fatal mistake that new Caddy-in-Docker users make all the time: forgetting to configure a persistent volume for TLS certificate storage. On bare metal, Caddy writes automatic certificates to disk and reads them back on restart. Inside a Docker container, if you don’t define a volume explicitly, all the TLS certificates issued by Let’s Encrypt are stored in the container’s writable layer, which is temporary (ephemeral). When your container is removed or updated, those certificates are gone forever, forcing Caddy to request new ones. Let’s Encrypt enforces strict Rate Limits, so repeated requests in a short time can get your domain blocked for days.
This article dives deep into how to deploy Caddy in Docker correctly, following industry best practices.
Caddy Container Architecture #
Before we get to the install commands, let’s understand how configuration and certificate data maps between your Linux host system and Caddy’s internal container environment through the diagram below:
flowchart TD
subgraph Host ["Linux Host System"]
Caddyfile["Caddyfile (Host File)"]
DataVol[("caddy_data (Named Volume)")]
ConfigVol[("caddy_config (Named Volume)")]
subgraph Container ["Caddy Container (caddy:2.8.4)"]
Core["Caddy Runtime Engine"]
CertStorage["/data/caddy/ (SSL Certs)"]
ConfigStorage["/config/caddy/"]
ConfigMapping["/etc/caddy/Caddyfile"]
end
end
Caddyfile -. Bind Mount .-> ConfigMapping
DataVol -. Volume Mount .-> CertStorage
ConfigVol -. Volume Mount .-> ConfigStorage
style Host stroke:#0288d1,stroke-width:2px
style Container stroke:#43a047,stroke-width:2pxThrough the volume mapping above, we ensure that the Caddyfile configuration on the host is read directly by Caddy, and all SSL certificates downloaded from Let’s Encrypt stay safe in the host’s named volume even when the Caddy container is destroyed and recreated.
Official Caddy Images on Docker Hub #
The Caddy core development team maintains official images on Docker Hub. You need to understand the differences between the provided tag variants to pick the most suitable image for your server:
# Standard Variant (Based on Alpine Linux)
# This is the recommended default image because it's small and secure.
docker pull caddy:latest
docker pull caddy:2 # Tracks Major version 2 releases
docker pull caddy:2.8 # Tracks Minor version 2.8 releases
docker pull caddy:2.8.4 # Pinned to a specific version (highly recommended for production!)
# Explicit Alpine Variant
docker pull caddy:2.8.4-alpine
# Builder Variant
# A special image equipped with the Go compiler and xcaddy utilities.
# Use it only for compiling custom plugins in a multi-stage Dockerfile.
docker pull caddy:builder
docker pull caddy:2.8.4-builder
[!TIP] Always Pin Your Version in Production! Avoid using the
caddy:latestorcaddy:2tags on your production server. Those dynamic tags risk unexpected automatic updates when the server pulls the image again, potentially triggering configuration incompatibilities (breaking changes). Use a specific tag likecaddy:2.8.4and do version upgrades deliberately through code review.
First Try — Without Configuration (Test Run) #
If you just want to verify that Docker and the Caddy image work properly on your server without writing any configuration file yet, you can run this throwaway command:
# Run a temporary Caddy container responding with simple text on port 8080
docker run --rm -p 8080:80 caddy caddy respond --listen :80 "Hello from Caddy in Docker!"
Open a new terminal and test it with curl:
curl http://localhost:8080
# Expected output: Hello from Caddy in Docker!
The --rm flag ensures Docker removes this container automatically when you stop it (by pressing Ctrl+C). This scenario is only for a quick test and must not be used for a real server because it has no data persistence.
The Correct Deployment Steps #
Let’s create a production Caddy container with persistent volumes, complete port mapping, and an automatic restart policy.
Here’s the standard command to run a Caddy container safely:
docker run -d \
--name caddy \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
-p 443:443/udp \
-v /var/www/Caddyfile:/etc/caddy/Caddyfile:ro \
-v caddy_data:/data \
-v caddy_config:/config \
caddy:2.8.4
Detailed Explanation of Command Parameters #
You need to understand the role of every flag used above:
-d— Runs the container in the background (detached mode) so your terminal stays free.--name caddy— Gives the container a consistent name to simplify subsequent commands (like viewing logs or reloading).--restart unless-stopped— Configures the Docker daemon to automatically restart the Caddy container if it crashes, the server dies, or Docker restarts. Docker won’t restart it if you stop it manually.-p 80:80— Maps host TCP port 80 to the container. This port must be open because Caddy uses it to serve the automatic HTTP-to-HTTPS redirect and to answer Let’s Encrypt’s HTTP-01 challenge validation.-p 443:443— Maps TCP port 443 for securing encrypted HTTPS (TLS) traffic.-p 443:443/udp— Maps UDP port 443. This step is optional but highly recommended so Caddy can serve the HTTP/3 (QUIC) protocol, which delivers much faster performance.-v /var/www/Caddyfile:/etc/caddy/Caddyfile:ro— Bind mounts the physical Caddyfile from the host into the container. The:ro(read-only) rule ensures the Caddy container isn’t allowed to modify the Caddyfile contents for security.-v caddy_data:/data— Creates a named volume calledcaddy_datamapped to the internal/datadirectory. This is where Caddy stores all the SSL certificates and ACME configuration files issued by the CA.-v caddy_config:/config— Creates a named volume calledcaddy_configto store the internal configuration files adapted from the Caddyfile.
Why Choose a Named Volume over a Bind Mount for /data?
#
You’re advised to use a named volume (like caddy_data:/data) instead of a direct bind mount to a host folder (like /home/you/data:/data). This is because:
# ANTI-PATTERN: Using a direct bind mount for sensitive data directories
-v /home/you/caddy_data:/data
# Problem: The Caddy container runs with an internal non-root UID/GID.
# If the host folder is owned by the root user with strict permissions,
# Caddy inside the container will crash with "Permission Denied"
# when trying to write new TLS certificates to that folder.
# CORRECT: Using a named volume
-v caddy_data:/data
# Solution: Docker automatically sets the proper ownership and
# permissions on the internal filesystem, so Caddy can write and
# read data without being blocked by host permission issues.
The Caddyfile in a Docker Environment #
There’s one golden rule you must follow when writing a Caddyfile to run inside Docker: Never use localhost or 127.0.0.1 as your proxy upstream web server.
In the Docker ecosystem, every container has its own isolated network environment (network namespace). The localhost address inside the Caddy container refers to the Caddy container itself, not your host machine or other backend application containers.
# ANTI-PATTERN: Using localhost to define a backend in Docker
yoursite.com {
# This configuration WILL FAIL! Caddy will look for port 3000
# inside itself, not in your application container.
reverse_proxy localhost:3000
}
# CORRECT: Use the container name or Docker network service name
yoursite.com {
# Docker's internal DNS automatically resolves the container name
# 'app_container' to the correct internal IP address.
reverse_proxy app_container:3000
}
For this to work, the Caddy container and your backend application container must be on the same Docker network.
# Step 1: Create a new Docker network
docker network create web_network
# Step 2: Run your backend application container inside that network
docker run -d --name app_container --network web_network my-node-app:latest
# Step 3: Run Caddy on the same network
docker run -d \
--name caddy \
--network web_network \
-p 80:80 -p 443:443 -p 443:443/udp \
-v /var/www/Caddyfile:/etc/caddy/Caddyfile:ro \
-v caddy_data:/data \
caddy:2.8.4
Managing the Caddy Lifecycle in Docker #
You can perform Caddy runtime maintenance directly using Docker CLI commands.
1. Reload Configuration Without Downtime #
Just like in a bare-metal installation, you don’t need to stop the container (docker restart) just to apply a new Caddyfile. You can trigger a graceful reload inside the container:
# Method 1: Use Caddy's official reload subcommand (Highly Recommended)
docker exec -w /etc/caddy caddy caddy reload
# Method 2: Send the SIGHUP signal to the Caddy process in the container
docker kill --signal=SIGHUP caddy
Before reloading, you can also validate the configuration file first:
# Validate the configuration inside the container
docker exec -w /etc/caddy caddy caddy validate
# Safe workflow: validate in the container, and if successful reload
docker exec -w /etc/caddy caddy caddy validate && docker exec -w /etc/caddy caddy caddy reload
2. Check Caddy Container Logs #
# Monitor Caddy container runtime logs in real time
docker logs -f caddy
# Show the last 50 log lines with full timestamps
docker logs -t --tail 50 caddy
3. Enter the Container Shell #
If you need to deeply investigate Caddy’s internal filesystem environment:
# Open an interactive shell (sh) terminal in the Caddy container
docker exec -it caddy sh
# Inside the container, you can inspect the active modules:
# caddy list-modules
Caddy with Custom Plugins in Docker #
If you need additional modules not included in the official binary — for example, the Cloudflare DNS provider module to support wildcard SSL via DNS-01 challenge — you must compile a custom Caddy binary.
The most efficient and clean way is to use a Multi-Stage Dockerfile. This approach uses the caddy:builder image for the compilation process, but in the final stage only copies the compiled binary into a clean, lightweight standard caddy image.
Create a file named Dockerfile:
# Stage 1: Compile the binary using the official builder image
FROM caddy:2.8.4-builder AS builder
# Run the compilation with xcaddy, specifying the plugins you need
# You can add multiple --with flags to include more than one plugin
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/mholt/caddy-ratelimit
# Stage 2: Create the clean, lightweight final image
FROM caddy:2.8.4
# Replace the built-in binary with your custom compiled binary
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
You can build that custom image with:
# Build the custom Docker image tagged 'caddy-custom:2.8.4'
docker build -t caddy-custom:2.8.4 .
# Verify that the Cloudflare plugin is installed in the new binary
docker run --rm caddy-custom:2.8.4 caddy list-modules | grep cloudflare
# Output: dns.providers.cloudflare
Now you can run containers using your new image caddy-custom:2.8.4.
Let’s Encrypt Rate Limits — The Risk of Losing Certificates #
You need to understand the impact of Let’s Encrypt’s rate limits so you don’t damage your domain’s reputation in production. Let’s Encrypt limits duplicate certificate issuance to 5 certificates per domain per week.
Here’s a dangerous example scenario that often hurts new Docker users:
# ANTI-PATTERN: Accidentally deleting containers and volumes
docker stop caddy
docker rm caddy
docker volume rm caddy_data # CERTIFICATE DATA DELETED COMPLETELY!
# After deleting the data, we recreate the container:
docker run -d --name caddy -v caddy_data:/data ... caddy:2.8.4
# Caddy detects the /data folder is empty and requests new certificates from Let's Encrypt.
# If you repeat the workflow above 5 times in a row within one week,
# your domain gets blocked by Let's Encrypt for hitting the duplication limit.
# As a result, on the 6th container creation, HTTPS fails and your site goes offline.
# CORRECT: Safe container maintenance workflow
docker stop caddy
docker rm caddy
# NEVER delete the 'caddy_data' volume.
# When you recreate the container, keep mapping the same volume:
docker run -d --name caddy -v caddy_data:/data ... caddy:2.8.4
# Caddy detects valid SSL certificates still exist in the host /data folder,
# and reuses them directly without contacting Let's Encrypt. Safe!
Troubleshooting Docker Cases #
1. Error Message: “dial tcp: lookup app_container: no such host” #
Caddy can’t find your backend server using the container name defined in the Caddyfile. Solution:
- Make sure both containers are running.
- Make sure both containers are on the same Docker network. You can verify active networks with:
docker network inspect web_network. - Make sure there’s no typo in the container name in the Caddyfile.
2. TLS Certificates Never Issue #
Caddy can’t secure the HTTPS connection and the logs show repeated ACME negotiation attempts. Solution:
- Check whether the domain actually points to your server’s host IP.
- Verify that host ports 80 and 443 are reachable from outside (firewall open).
- Use
docker logs caddy 2>&1 | grep -i acmeto see the specific error message from Let’s Encrypt/ZeroSSL.
3. File Permission Issues on Static Volumes #
Caddy returns an HTTP 403 Forbidden response for static documents mounted from the host.
Solution:
This happens because the default user inside the Caddy container (usually the caddy user with UID 1000, or root depending on the image) doesn’t have read permission on your host files.
Run the following command on the Linux host to make sure read access is available to all users:
sudo chmod -R o+r /path/to/host/static/directory
When to Switch to Alternatives / Not Use This #
Keep using the Standard Docker deployment if:
✓ You want to strictly isolate the web server process from the host filesystem.
✓ You're used to deploying single containers with independent lifecycles.
✓ Your backend applications also run inside Docker containers.
✓ You need easy replication of the web server environment on your local machine.
Consider other methods if:
✗ You manage complex architectures with dozens of interdependent services (Use Docker Compose).
✗ You want OS package manager integration and routine updates via apt-get (Use APT).
✗ Your server has very limited memory specs that can't handle the Docker Daemon overhead.
Summary #
- Use Specific Tags — Always use a specific version (e.g.,
caddy:2.8.4) on production servers to ensure runtime stability.- Persistent Volume Required — Always map the container’s
/datadirectory to a host named volume (caddy_data:/data) to prevent TLS certificate loss.- Avoid Localhost — Use the container name or Docker service name as the reverse proxy upstream address in the Caddyfile, not
localhostor127.0.0.1.- HTTP/3 Protocol — Open port
443/udpin the container port mapping (-p 443:443/udp) so Caddy can serve HTTP/3.- Graceful Reload — Use
docker exec caddy caddy reloadto reload configuration without restarting the container.- Multi-Stage Build — Leverage the
caddy:builderimage to assemble custom Caddy binaries safely while still producing a lightweight final image.