I got tired of setting up SSL for every side project, so I made a 60-second Docker deploy kit
Automating TLS Termination in Containerized Workloads with Caddy
Current Situation Analysis
Deploying containerized applications to bare-metal or cloud VPS instances introduces a recurring operational bottleneck: TLS termination and reverse proxy configuration. The traditional stack relies on Nginx for routing paired with Certbot for certificate lifecycle management. While both tools are mature, their combination demands manual intervention at multiple stages. Developers must configure virtual hosts, generate Diffie-Hellman parameters, install Certbot, request certificates, validate DNS challenges, and schedule cron jobs for automatic renewal. When certificates fail to renew or redirect loops occur, debugging consumes significant engineering time.
This friction is frequently underestimated because infrastructure provisioning is treated as a one-time setup task. In practice, certificate expiration, DNS propagation delays, and container networking misconfigurations create recurring operational overhead. Industry benchmarks indicate that manual Nginx/Certbot deployments typically require 90β120 minutes per environment, including troubleshooting and validation. For teams managing multiple staging or production instances, this compounds into lost development velocity and increased risk of expired certificates causing service outages.
The root cause is architectural mismatch. Modern containerized workloads demand declarative, self-healing infrastructure. Manual certificate management violates the principle of immutable deployments. When TLS termination is decoupled from the application but still requires manual lifecycle management, it becomes a fragile dependency. Automated ACME-compatible reverse proxies eliminate this gap by embedding certificate provisioning directly into the routing layer.
WOW Moment: Key Findings
Replacing manual certificate orchestration with an ACME-native reverse proxy fundamentally changes deployment economics. The following comparison illustrates the operational shift:
| Approach | Setup Duration | Certificate Management | Configuration Complexity |
|---|---|---|---|
| Nginx + Certbot | 60β120 min | Manual cron + validation hooks | 15β30 lines + external scripts |
| Caddy (ACME-native) | < 2 min | Automatic background renewal | 3β5 lines |
This finding matters because it decouples infrastructure plumbing from application delivery. Developers no longer need to understand ACME challenge types, certificate chain validation, or cron scheduling. The reverse proxy becomes a self-sustaining component that handles TLS termination, automatic HTTP-to-HTTPS redirection, and certificate renewal without external dependencies. For solo developers, small teams, or rapid prototyping workflows, this reduces deployment friction to near-zero while maintaining production-grade security standards.
Core Solution
The architecture replaces manual certificate management with a declarative Docker Compose stack. Caddy operates as the edge reverse proxy, handling TLS termination and routing traffic to the backend application container. Both services share an isolated Docker network, ensuring the application remains inaccessible from the host while Caddy manages external exposure.
Architecture Decisions & Rationale
- Caddy over Nginx/Traefik: Caddy's native ACME support eliminates external certificate tools. It automatically requests, validates, and renews Let's Encrypt certificates. The configuration syntax is declarative and requires zero boilerplate for standard HTTPS setups.
- Docker Compose Orchestration: Compose provides a single source of truth for service dependencies, networking, and volume mounts. It ensures reproducible deployments across environments without requiring Kubernetes or external orchestrators.
- Network Isolation: The application container does not expose ports to the host. Only Caddy binds to ports 80 and 443. This enforces a security boundary and prevents direct backend access.
- Persistent Certificate Storage: Caddy's
/dataand/configdirectories are mounted as named volumes. This preserves certificates across container restarts and prevents redundant ACME requests.
Implementation
The following stack demonstrates the production-ready configuration. All naming conventions and structural patterns differ from common tutorials to emphasize explicit configuration over implicit defaults.
environment.env
TARGET_DOMAIN=api.example.io
BACKEND_IMAGE=registry.example.io/backend-service:v2.4.1
BACKEND_INTERNAL_PORT=8080
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
gateway.caddy
{$TARGET_DOMAIN} {
reverse_proxy backend-service:{$BACKEND_INTERNAL_PORT} {
health_uri /healthz
health_interval 10s
}
log {
output file /var/log/caddy/access.log
format json
}
}
deploy-stack.yml
version: "3.9"
services:
edge-proxy:
image: caddy:2.8-alpine
container_name: caddy-edge
restart: unless-stopped
ports:
- "${CADDY_HTTP_PORT}:${CADDY_HTTP_PORT}"
- "${CADDY_HTTPS_PORT}:${CADDY_HTTPS_PORT}"
volumes:
- ./gateway.caddy:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- caddy_logs:/var/log/caddy
environment:
- TARGET_DOMAIN=${TARGET_DOMAIN}
- BACKEND_INTERNAL_PORT=${BACKEND_INTERNAL_PORT}
networks:
- app-tier
depends_on:
backend-service:
condition: service_healthy
backend-service:
image: ${BACKEND_IMAGE}
container_name: app-backend
restart: unless-stopped
expose:
- "${BACKEND_INTERNAL_PORT}"
networks:
- app-tier
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:${BACKEND_INTERNAL_PORT}/healthz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
volumes:
caddy_data:
caddy_config:
caddy_logs:
networks:
app-tier:
driver: bridge
Why This Structure Works
- Explicit Health Checks: The
depends_oncondition ensures Caddy only starts routing after the backend passes its health probe. This prevents 502 Bad Gateway errors during cold starts. - Named Volumes:
caddy_dataandcaddy_configpersist ACME state across restarts. Without them, Caddy would request new certificates on every deployment, triggering Let's Encrypt rate limits. - Internal Port Exposure:
exposemakes the backend port available only to other containers on theapp-tiernetwork. Host port mapping is intentionally omitted to enforce proxy-only access. - Structured Logging: JSON-formatted access logs enable seamless ingestion into log aggregation pipelines without parsing overhead.
Pitfall Guide
1. DNS Propagation Lag During ACME Validation
Explanation: Let's Encrypt validates domain ownership via DNS A records. If the record hasn't propagated globally before Caddy starts, ACME challenges fail and certificates are not issued.
Fix: Verify propagation using dig +short yourdomain.com or nslookup. Use Caddy's staging CA during testing: acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in the Caddyfile to avoid production rate limits.
2. Container Name Resolution Failure
Explanation: Using localhost or 127.0.0.1 in the reverse proxy directive routes traffic to the Caddy container itself, not the backend. Docker networks require service name resolution.
Fix: Always reference the backend using its Compose service name (backend-service). Ensure both services share the same custom network.
3. Volume Permission Conflicts
Explanation: Caddy runs as a non-root user (caddy). If host-mounted directories lack correct ownership, Caddy fails to write certificates or logs.
Fix: Use named volumes instead of bind mounts for /data and /config. If bind mounts are required, pre-create directories with chown -R 1000:1000 before starting the stack.
4. Let's Encrypt Rate Limit Exhaustion
Explanation: Repeated failed validations or unnecessary certificate requests trigger staging or production rate limits. This blocks new certificate issuance for hours or days.
Fix: Implement exponential backoff for failed startups. Use caddy reload instead of docker restart when modifying configurations. Monitor caddy list-certs to verify existing certificates before triggering new requests.
5. Missing Upstream Health Validation
Explanation: Caddy routes traffic to unhealthy backend containers, resulting in intermittent 502/503 errors without automatic failover.
Fix: Configure health_uri, health_interval, and health_timeout in the reverse_proxy block. Pair this with Docker-level health checks to ensure only ready containers receive traffic.
6. Overexposing Backend Ports
Explanation: Mapping the backend port to the host (ports: - "8080:8080") bypasses the reverse proxy, exposing the application directly to the internet without TLS or rate limiting.
Fix: Remove host port mappings from the backend service. Use expose for internal routing only. Verify with ss -tlnp or netstat that only ports 80/443 are publicly accessible.
7. Ignoring Caddy's Default HTTP-to-HTTPS Redirect
Explanation: Caddy automatically redirects port 80 to 443. If firewall rules block port 80, ACME HTTP-01 challenges fail, and certificate issuance stalls.
Fix: Ensure ufw allow 80/tcp and ufw allow 443/tcp are active. If operating behind a cloud load balancer, configure the listener to forward both ports to the Caddy instance.
Production Bundle
Action Checklist
- Verify DNS A record propagation before initiating deployment
- Use Let's Encrypt staging CA during initial configuration testing
- Mount Caddy
/dataand/configas named volumes to persist certificates - Configure Docker health checks for both Caddy and backend services
- Restrict backend exposure to internal Docker network only
- Enable JSON access logging for downstream observability pipelines
- Set
restart: unless-stoppedto ensure automatic recovery after host reboots - Validate certificate issuance with
docker exec caddy-edge caddy list-certs
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Solo developer / side project | Caddy + Docker Compose | Zero-config TLS, minimal maintenance, fast iteration | $0 additional (uses free Let's Encrypt certs) |
| Enterprise microservices | Traefik or Nginx Ingress | Advanced routing, middleware chains, service mesh integration | Higher operational overhead, potential licensing for enterprise features |
| High-throughput static assets | Nginx + CloudFront/S3 | Optimized caching, edge distribution, lower compute costs | CDN fees scale with bandwidth, but reduce origin load |
| Multi-tenant SaaS platform | Caddy with dynamic config or Nginx + Lua | Dynamic upstream resolution, tenant isolation, custom headers | Moderate DevOps investment for config management |
Configuration Template
Copy the following files into a dedicated deployment directory. Replace placeholder values with your environment specifics.
environment.env
TARGET_DOMAIN=app.yourdomain.com
BACKEND_IMAGE=yourregistry.io/yourapp:stable
BACKEND_INTERNAL_PORT=3000
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
gateway.caddy
{$TARGET_DOMAIN} {
reverse_proxy backend-service:{$BACKEND_INTERNAL_PORT} {
health_uri /status
health_interval 15s
lb_policy round_robin
}
encode gzip
log {
output file /var/log/caddy/traffic.log
format json
}
}
deploy-stack.yml
version: "3.9"
services:
edge-proxy:
image: caddy:2.8-alpine
container_name: caddy-gateway
restart: unless-stopped
ports:
- "${CADDY_HTTP_PORT}:${CADDY_HTTP_PORT}"
- "${CADDY_HTTPS_PORT}:${CADDY_HTTPS_PORT}"
volumes:
- ./gateway.caddy:/etc/caddy/Caddyfile:ro
- caddy_persist:/data
- caddy_config:/config
- caddy_audit:/var/log/caddy
environment:
- TARGET_DOMAIN=${TARGET_DOMAIN}
- BACKEND_INTERNAL_PORT=${BACKEND_INTERNAL_PORT}
networks:
- secure-tier
depends_on:
app-backend:
condition: service_healthy
app-backend:
image: ${BACKEND_IMAGE}
container_name: app-backend
restart: unless-stopped
expose:
- "${BACKEND_INTERNAL_PORT}"
networks:
- secure-tier
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_INTERNAL_PORT}/status"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
volumes:
caddy_persist:
caddy_config:
caddy_audit:
networks:
secure-tier:
driver: bridge
Quick Start Guide
- Provision Infrastructure: Spin up a fresh Ubuntu 22.04 LTS VPS. Install Docker and Docker Compose using the official installation scripts.
- Configure DNS: Create an A record pointing your domain to the VPS public IP. Wait for propagation (verify with
dig). - Deploy Stack: Upload the three configuration files to the VPS. Run
docker compose --env-file environment.env -f deploy-stack.yml up -d. - Validate: Check certificate issuance with
docker logs caddy-gateway. Visithttps://yourdomain.comto confirm TLS termination and backend routing. - Monitor: Set up log rotation for
/var/log/caddy/traffic.logand configure alerts for container restarts or health check failures.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
