Configure Caddy 2 with Docker containers and automatic SSL certificates

Intermediate 45 min Apr 27, 2026 89 views
Ubuntu 24.04 Debian 12 AlmaLinux 9 Rocky Linux 9

Set up Caddy 2 as a reverse proxy using Docker with automatic Let's Encrypt SSL certificates. Deploy containerized web applications behind Caddy with zero-downtime SSL management and built-in load balancing.

Prerequisites

  • Root or sudo access
  • Docker and Docker Compose installed
  • Domain names pointing to server
  • Ports 80 and 443 open

What this solves

Caddy 2 automatically handles SSL certificate provisioning, renewal, and HTTPS redirects without manual configuration. Running Caddy in Docker containers provides isolated environments, easy scaling, and simplified deployment management for multiple web applications behind a single reverse proxy.

Step-by-step installation

Install Docker and Docker Compose

Install Docker Engine and Docker Compose to manage containerized applications.

sudo apt update
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
sudo systemctl enable --now docker
sudo dnf update -y
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
sudo systemctl enable --now docker

Create project directory structure

Organize Caddy configuration files and Docker compose setup in a dedicated directory.

mkdir -p ~/caddy-proxy/{config,data,logs}
cd ~/caddy-proxy

Configure Caddy with Caddyfile

Create the main Caddyfile that defines reverse proxy rules and automatic SSL configuration.

{
    email admin@example.com
    log {
        output file /var/log/caddy/access.log
        format json
    }
}

Main website

example.com { reverse_proxy web_app:3000 log { output file /var/log/caddy/example.log format json } encode gzip header { # Security headers Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" } }

API subdomain

api.example.com { reverse_proxy api_service:8080 log { output file /var/log/caddy/api.log format json } encode gzip }

Static files subdomain

static.example.com { reverse_proxy static_server:80 log { output file /var/log/caddy/static.log format json } encode gzip header { Cache-Control "public, max-age=86400" } }

Create Docker Compose configuration

Define the complete Docker stack with Caddy proxy and example web applications.

version: '3.8'

services:
  caddy:
    image: caddy:2.7-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./config/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data:/data
      - ./logs:/var/log/caddy
    networks:
      - caddy_network
    environment:
      - CADDY_INGRESS_NETWORKS=caddy_network
    healthcheck:
      test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
      interval: 30s
      timeout: 10s
      retries: 3
  
  # Example Node.js web application
  web_app:
    image: node:18-alpine
    container_name: web_app
    restart: unless-stopped
    working_dir: /app
    volumes:
      - ./apps/web:/app
    command: ["npm", "start"]
    networks:
      - caddy_network
    environment:
      - NODE_ENV=production
      - PORT=3000
    expose:
      - "3000"
    depends_on:
      - caddy
  
  # Example API service
  api_service:
    image: nginx:alpine
    container_name: api_service
    restart: unless-stopped
    volumes:
      - ./apps/api/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./apps/api/html:/usr/share/nginx/html:ro
    networks:
      - caddy_network
    expose:
      - "8080"
    depends_on:
      - caddy
  
  # Example static file server
  static_server:
    image: nginx:alpine
    container_name: static_server
    restart: unless-stopped
    volumes:
      - ./apps/static:/usr/share/nginx/html:ro
    networks:
      - caddy_network
    expose:
      - "80"
    depends_on:
      - caddy

networks:
  caddy_network:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.20.0.0/16

volumes:
  caddy_data:
    driver: local
  caddy_config:
    driver: local

Create example web applications

Set up sample applications to demonstrate reverse proxy functionality.

mkdir -p apps/{web,api,static}

Create simple Node.js app

cat > apps/web/package.json << 'EOF' { "name": "example-web-app", "version": "1.0.0", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.18.0" } } EOF

Configure Node.js application server

Create a simple Express.js server for testing the reverse proxy setup.

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    message: 'Hello from Web App!',
    timestamp: new Date().toISOString(),
    headers: req.headers,
    container: 'web_app'
  });
});

app.get('/health', (req, res) => {
  res.json({ status: 'healthy', uptime: process.uptime() });
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(Web app running on port ${PORT});
});

Configure API service nginx

Set up nginx configuration for the API service container.

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    
    log_format json_combined escape=json
    '{
        "time_local":"$time_local",
        "remote_addr":"$remote_addr",
        "remote_user":"$remote_user",
        "request":"$request",
        "status": "$status",
        "body_bytes_sent":"$body_bytes_sent",
        "request_time":"$request_time",
        "http_referrer":"$http_referer",
        "http_user_agent":"$http_user_agent"
    }';
    
    access_log /var/log/nginx/access.log json_combined;
    error_log  /var/log/nginx/error.log warn;
    
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types application/json application/javascript text/css text/xml;
    
    server {
        listen 8080;
        server_name localhost;
        
        location / {
            root /usr/share/nginx/html;
            index index.html index.htm;
        }
        
        location /api/status {
            return 200 '{"service":"api","status":"running","timestamp":"$time_iso8601"}';
            add_header Content-Type application/json;
        }
        
        location /health {
            access_log off;
            return 200 "healthy\n";
            add_header Content-Type text/plain;
        }
    }
}

Create API service content

Add sample API responses and static content for testing.

# API service HTML content
cat > apps/api/html/index.html << 'EOF'



    API Service
    


    

API Service

This is the API service running behind Caddy proxy.

EOF

Static files content

cat > apps/static/index.html << 'EOF' Static Files

Static File Server

Serving static content through Caddy proxy with caching headers.

EOF

Install Node.js dependencies in container

Create a temporary container to install npm dependencies for the web application.

cd apps/web
docker run --rm -v "$(pwd)":/app -w /app node:18-alpine npm install
cd ../../

Configure firewall rules

Open HTTP and HTTPS ports for Caddy to receive traffic.

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --reload

Start the Docker stack

Launch all containers with Docker Compose in detached mode.

docker-compose up -d

Check container status

docker-compose ps

View logs

docker-compose logs -f caddy

Configure log rotation

Set up logrotate to manage Caddy log files and prevent disk space issues.

~/caddy-proxy/logs/*.log {
    daily
    missingok
    rotate 30
    compress
    delaycompress
    notifempty
    create 0644 root root
    postrotate
        docker exec caddy caddy reload --config /etc/caddy/Caddyfile
    endscript
}

Configure automatic SSL certificates

Update DNS records

Point your domains to the server IP address before SSL certificate issuance.

Note: Replace example.com with your actual domain names. DNS propagation can take up to 48 hours.
# Check DNS resolution
dig +short example.com
dig +short api.example.com
dig +short static.example.com

Monitor SSL certificate provisioning

Watch Caddy logs to verify automatic SSL certificate acquisition from Let's Encrypt.

# Monitor certificate provisioning
docker-compose logs -f caddy | grep -i cert

Check certificate status

docker exec caddy caddy list-certificates

Configure certificate renewal monitoring

Create a monitoring script to check certificate expiration and renewal status.

#!/bin/bash

Certificate monitoring script

LOG_FILE="/var/log/caddy-cert-check.log" DATE=$(date '+%Y-%m-%d %H:%M:%S') echo "[$DATE] Checking SSL certificates..." >> $LOG_FILE

Get certificate information

docker exec caddy caddy list-certificates --format json > /tmp/caddy-certs.json

Check for certificates expiring in 30 days

if command -v jq >/dev/null 2>&1; then EXPIRING_CERTS=$(jq -r '.[] | select(.not_after | . != null and (. | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) < (now + 2592000)) | .names[0]' /tmp/caddy-certs.json 2>/dev/null) if [ ! -z "$EXPIRING_CERTS" ]; then echo "[$DATE] WARNING: Certificates expiring soon: $EXPIRING_CERTS" >> $LOG_FILE # Add email notification here if needed else echo "[$DATE] All certificates are valid" >> $LOG_FILE fi else echo "[$DATE] jq not installed, skipping expiration check" >> $LOG_FILE fi rm -f /tmp/caddy-certs.json

Schedule certificate monitoring

Add a cron job to run certificate monitoring daily.

chmod +x ~/caddy-proxy/scripts/check-certs.sh

Add to crontab

(crontab -l 2>/dev/null; echo "0 6 * ~/caddy-proxy/scripts/check-certs.sh") | crontab -

Deploy web applications behind Caddy proxy

Add custom application container

Extend the Docker Compose configuration to include additional web applications.

version: '3.8'

services:
  # WordPress application
  wordpress:
    image: wordpress:6.4-apache
    container_name: wordpress_app
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: wordpress_db:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: secure_password_here
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wordpress_data:/var/www/html
    networks:
      - caddy_network
    expose:
      - "80"
    depends_on:
      - wordpress_db
  
  # WordPress database
  wordpress_db:
    image: mysql:8.0
    container_name: wordpress_db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: secure_password_here
      MYSQL_ROOT_PASSWORD: root_password_here
    volumes:
      - wordpress_db_data:/var/lib/mysql
    networks:
      - caddy_network
    command: --default-authentication-plugin=mysql_native_password
  
  # Monitoring dashboard
  grafana:
    image: grafana/grafana:10.2.0
    container_name: grafana
    restart: unless-stopped
    environment:
      - GF_SERVER_ROOT_URL=https://monitoring.example.com
      - GF_SECURITY_ADMIN_PASSWORD=admin_password_here
    volumes:
      - grafana_data:/var/lib/grafana
    networks:
      - caddy_network
    expose:
      - "3000"

volumes:
  wordpress_data:
  wordpress_db_data:
  grafana_data:

Update Caddyfile for new applications

Add reverse proxy configurations for the new containerized applications.

# Add these sections to the existing Caddyfile

WordPress blog

blog.example.com { reverse_proxy wordpress:80 log { output file /var/log/caddy/blog.log format json } encode gzip # WordPress specific headers header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" X-XSS-Protection "1; mode=block" } }

Monitoring dashboard

monitoring.example.com { reverse_proxy grafana:3000 log { output file /var/log/caddy/monitoring.log format json } encode gzip }

Deploy updated configuration

Apply the new configuration and restart containers with the additional applications.

# Restart with override configuration
docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d

Reload Caddy configuration

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Check all services

docker-compose ps

Monitor and troubleshoot Caddy containers

Set up container health monitoring

Create monitoring scripts to track container health and performance metrics.

#!/bin/bash

Container monitoring script

LOG_FILE="/var/log/caddy-monitor.log" DATE=$(date '+%Y-%m-%d %H:%M:%S') ALERT_EMAIL="admin@example.com" echo "[$DATE] Starting container health check..." >> $LOG_FILE

Check if Docker daemon is running

if ! docker info >/dev/null 2>&1; then echo "[$DATE] ERROR: Docker daemon is not running" >> $LOG_FILE exit 1 fi

Check critical containers

CRITICAL_CONTAINERS=("caddy" "web_app") for container in "${CRITICAL_CONTAINERS[@]}"; do if ! docker ps --format "table {{.Names}}" | grep -q "^$container$"; then echo "[$DATE] CRITICAL: Container $container is not running" >> $LOG_FILE # Attempt to restart echo "[$DATE] Attempting to restart $container..." >> $LOG_FILE docker-compose restart $container sleep 10 # Check if restart was successful if docker ps --format "table {{.Names}}" | grep -q "^$container$"; then echo "[$DATE] SUCCESS: Container $container restarted" >> $LOG_FILE else echo "[$DATE] FAILED: Container $container restart failed" >> $LOG_FILE fi else echo "[$DATE] OK: Container $container is running" >> $LOG_FILE fi done

Check container resource usage

docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" >> $LOG_FILE

Check SSL certificate status

docker exec caddy caddy list-certificates --format json 2>/dev/null | jq -r '.[] | "Certificate: " + (.names | join(", ")) + " expires: " + .not_after' >> $LOG_FILE 2>/dev/null echo "[$DATE] Container health check completed" >> $LOG_FILE

Configure log aggregation

Set up centralized logging to collect and analyze logs from all containers. This builds on the Loki and Promtail log aggregation setup for comprehensive monitoring.

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: caddy-logs
    static_configs:
      - targets:
          - localhost
        labels:
          job: caddy
          __path__: /var/log/caddy/*.log
    
    pipeline_stages:
      - json:
          expressions:
            level: level
            timestamp: ts
            message: msg
            method: request.method
            uri: request.uri
            status: resp_headers.status
      
      - timestamp:
          source: timestamp
          format: RFC3339Nano
      
      - labels:
          level:
          method:
          status:
  
  - job_name: docker-containers
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    
    relabel_configs:
      - source_labels: [__meta_docker_container_name]
        regex: '/(.*)'
        target_label: container
      
      - source_labels: [__meta_docker_container_log_stream]
        target_label: stream

Create backup and recovery procedures

Implement automated backup for Caddy configuration and SSL certificates.

#!/bin/bash

Caddy backup script

BACKUP_DIR="/backup/caddy" DATE=$(date '+%Y%m%d_%H%M%S') BACKUP_NAME="caddy_backup_$DATE.tar.gz" RETENTION_DAYS=30

Create backup directory

mkdir -p $BACKUP_DIR echo "Starting Caddy backup at $(date)"

Create backup archive

tar -czf "$BACKUP_DIR/$BACKUP_NAME" \ -C ~/caddy-proxy \ config/ \ data/ \ docker-compose.yml \ docker-compose.override.yml \ apps/ \ scripts/ \ --exclude='*.log' if [ $? -eq 0 ]; then echo "Backup created successfully: $BACKUP_NAME" # Verify backup integrity tar -tzf "$BACKUP_DIR/$BACKUP_NAME" >/dev/null if [ $? -eq 0 ]; then echo "Backup verification passed" else echo "ERROR: Backup verification failed" exit 1 fi else echo "ERROR: Backup creation failed" exit 1 fi

Clean up old backups

find $BACKUP_DIR -name "caddy_backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete echo "Backup completed at $(date)"

Schedule monitoring and backups

Add cron jobs for automated monitoring and backup procedures.

chmod +x ~/caddy-proxy/scripts/monitor-containers.sh
chmod +x ~/caddy-proxy/scripts/backup-caddy.sh

Schedule monitoring every 5 minutes and backups daily

(crontab -l 2>/dev/null; cat << 'EOF'

Caddy monitoring and maintenance

/5 * ~/caddy-proxy/scripts/monitor-containers.sh 0 2 * ~/caddy-proxy/scripts/backup-caddy.sh 0 6 * ~/caddy-proxy/scripts/check-certs.sh EOF ) | crontab -

Verify your setup

# Check all containers are running
docker-compose ps

Test HTTP to HTTPS redirect

curl -I http://example.com

Test SSL certificate

curl -I https://example.com

Check Caddy configuration

docker exec caddy caddy validate --config /etc/caddy/Caddyfile

View certificate information

docker exec caddy caddy list-certificates

Test reverse proxy functionality

curl -s https://example.com | jq . curl -s https://api.example.com/api/status | jq . curl -I https://static.example.com

Check container health

docker exec caddy sh -c 'caddy version && caddy list-certificates | head -5'

Monitor logs in real-time

docker-compose logs -f --tail=50

Common issues

Symptom Cause Fix
SSL certificate provisioning fails DNS not pointing to server or port 80/443 blocked Verify DNS with dig example.com and check firewall rules
502 Bad Gateway errors Backend container not reachable or wrong port Check container connectivity with docker exec caddy nc -zv backend_container port
Caddy container keeps restarting Invalid Caddyfile syntax Validate config: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
High memory usage Too many active connections or log accumulation Configure log rotation and tune connection limits in Caddyfile
Docker network issues Containers can't communicate on Docker network Recreate network: docker network rm caddy_network && docker-compose up -d
Permission denied errors in logs Incorrect file ownership in volumes Fix ownership: sudo chown -R $USER:$USER ~/caddy-proxy/
Never use chmod 777. It gives every user on the system full access to your files. Instead, fix ownership with chown and use minimal permissions like 755 for directories and 644 for files.

Next steps

Running this in production?

Want this handled for you? Setting up Caddy with containers is straightforward. Keeping it patched, monitored, backed up and tuned across environments is the harder part. See how we run infrastructure like this for European SaaS and e-commerce teams.

Automated install script

Run this to automate the entire setup

Need help?

Don't want to manage this yourself?

We handle managed cloud infrastructure for businesses that depend on uptime. From initial setup to ongoing operations.