Deploying containerized web applications with Docker is straightforward, but exposing them securely to the public internet is where many system administrators and developers hit a wall. In traditional deployments, securing services requires orchestrating Nginx or HAProxy alongside Certbot, configuring cron jobs for ACME renewals, writing verbose configuration files, and managing SSL certificate validation hooks.

Enter Caddy—an enterprise-grade, memory-safe web server written in Go that has revolutionized reverse proxy administration. Unlike legacy servers, Caddy handles automatic TLS/SSL certificate issuance and continuous renewal via Let’s Encrypt and ZeroSSL completely out of the box, requiring zero external helper scripts or complex certificate configurations.
In this hands-on tutorial, you will learn step-by-step how to deploy Caddy as a centralized reverse proxy for your Docker services using Docker Compose. We will cover shared bridge networks, domain routing, HTTP-to-HTTPS redirection, WebSocket support, and production-hardened headers.
Why Choose Caddy Over Nginx for Docker Environments?
While Nginx remains a battle-tested industry standard, Caddy offers significant advantages for modern container stacks and self-hosted environments:
- Automatic HTTPS by Default: The moment you provide a domain name in your Caddyfile, Caddy automatically provisions valid SSL certificates via Let’s Encrypt or ZeroSSL and enforces HTTP-to-HTTPS redirection.
- Concise Caddyfile Syntax: What takes 40 lines of boilerplate directives in Nginx (proxy headers, SSL protocols, ciphers, timeouts) is achieved in 5 clean lines in Caddy.
- Built-in WebSocket Support: Modern web interfaces like Open-WebUI and real-time streaming tools require WebSockets. Caddy proxies WebSockets automatically without manual
UpgradeandConnectionheader gymnastics. - Memory Safety: Being written in Go, Caddy is immune to memory corruption vulnerabilities (such as buffer overflows) that occasionally affect C-based servers.
- Zero-Downtime Reloads: Caddy’s native configuration API allows dynamic updates without dropping existing TCP connections.
Technical Prerequisites
Before proceeding, ensure your environment meets the following requirements:
- Server: A Linux server (Ubuntu 22.04 / 24.04 LTS or Debian 12 / 13) with a public IPv4 address.
- Docker & Compose: Docker Engine 24.0+ with Docker Compose v2.
- Domain Name: A registered domain or subdomain (e.g.,
chat.yourdomain.com,api.yourdomain.com) with DNS A records pointing directly to your server’s public IP address. - Firewall Configuration: Inbound ports 80 (HTTP) and 443 (HTTPS) must be open on your host firewall (UFW) and cloud security group. Let’s Encrypt requires port 80 for the ACME HTTP-01 challenge.
Step 1: Designing the Docker Network Architecture
The biggest architectural mistake when running reverse proxies in Docker is exposing individual application ports (such as 3000, 8080, or 11434) to the host interface. This leaves your backend services vulnerable to direct IP scans that bypass the proxy’s SSL and authentication layers.
The secure, standard pattern defined in the Docker Official Networking Documentation is to establish a shared external bridge network:
- Public Gateway: Only Caddy binds to host ports
80and443. - Private Backend: Your upstream application containers (such as Ollama, Open-WebUI, or Nextcloud) expose no host ports. Instead, they attach to the shared bridge network where Caddy reaches them internally via their container names.
Create the external network with the following command:
docker network create caddy_network
Step 2: Project Setup and Directory Structure
Organize your reverse proxy configuration in a dedicated directory:
mkdir -p ~/stacks/caddy && cd ~/stacks/caddy
We will create two persistent directories on the host: caddy_data (which stores your issued Let’s Encrypt certificates and private keys) and caddy_config (which caches compiled runtime configurations). Storing certificates on a persistent volume is mandatory to prevent hitting Let’s Encrypt rate limits upon container restarts.
Step 3: Creating the Caddy Docker Compose Stack
Create a file named docker-compose.yml:
services:
caddy:
image: caddy:2-alpine
container_name: caddy-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # Enables HTTP/3 (QUIC) support
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy_data:/data
- ./caddy_config:/config
networks:
- caddy_network
networks:
caddy_network:
external: true
Configuration Highlights
caddy:2-alpine: A minimal, lightweight container footprint (under 50 MB) based on Alpine Linux.443:443/udp: Exposes UDP port 443 to enable native HTTP/3 (QUIC), providing significantly faster connection handshakes on modern browsers and mobile devices../caddy_data:/data: Preserves TLS certificates and account keys. Never delete this folder in production.
Step 4: Crafting the Production Caddyfile
Create the Caddyfile in the same directory. The syntax below adheres strictly to the Caddy Server Official Caddyfile Documentation:
# Global Options
{
email admin@yourdomain.com # Used for Let's Encrypt expiry notifications
admin off # Disables the internal REST admin API for security
}
# Example 1: Exposing Open-WebUI with Full HTTPS
chat.yourdomain.com {
reverse_proxy open-webui:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Security Headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
# Example 2: Exposing a Backend API with CORS and Gzip Compression
api.yourdomain.com {
encode gzip zstd
reverse_proxy api-service:5000
}
Step 5: Connecting Backend Services to the Caddy Network
To expose your existing Docker Compose services through Caddy, simply attach them to the shared caddy_network without exposing any public host ports.
For example, if you are running our GPU-accelerated Ollama stack or Open-WebUI, configure your application’s docker-compose.yml like this:
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
# NOTICE: No host port binding (3000:8080) needed here!
volumes:
- ./openwebui_data:/app/backend/data
networks:
- caddy_network
- internal_network
networks:
caddy_network:
external: true
internal_network:
driver: bridge
Because caddy-proxy and open-webui share caddy_network, Caddy resolves the hostname open-webui internally to port 8080 without exposing the raw port to the public internet.
Step 6: Launching Caddy and Verifying SSL Certificates
Launch the Caddy reverse proxy container:
docker compose up -d
Follow Caddy’s real-time logs to witness the automatic SSL provisioning:
docker compose logs -f caddy
You will see log entries detailing the ACME transaction:
{"level":"info","msg":"obtaining certificate","identifier":"chat.yourdomain.com"}
{"level":"info","msg":"serving initial intermediate certificates"}
{"level":"info","msg":"certificate obtained successfully","identifier":"chat.yourdomain.com"}
Open your browser and navigate to https://chat.yourdomain.com. Notice that:
- The green padlock / secure connection icon is present.
- The certificate was automatically signed by Let’s Encrypt (valid for 90 days, auto-renewed at 30 days remaining).
- Typing
http://chat.yourdomain.comautomatically redirects to HTTPS with a 308 permanent redirect.
Live Configuration Reloads Without Downtime
Whenever you add a new subdomain or application to your Caddyfile, you do not need to restart the container. Reload the configuration gracefully with zero dropped connections:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Troubleshooting Common Caddy Issues
1. ACME Challenge Fails (No Certificate Issued)
- Symptom: Logs report
acme: error presenting token: context deadline exceeded. - Fix: Ensure your domain’s DNS A record points to the exact public IP of your server. Verify with
dig +short chat.yourdomain.com. Double-check that your server’s firewall allows inbound traffic on port 80.
2. 502 Bad Gateway
- Symptom: Caddy returns a clean 502 error page when visiting your domain.
- Fix: Caddy cannot reach the upstream container. Verify that both Caddy and your application container are attached to
caddy_networkby runningdocker network inspect caddy_network. Also verify the container name and target port match the Caddyfile.
3. Let’s Encrypt Rate Limits
- Symptom: Logs indicate
too many certificates already issued for exact set of domains. - Fix: Always ensure
./caddy_datais mounted as a persistent volume so existing certificates are preserved across reboots. During local development, test against Let’s Encrypt’s staging environment by addingacme_ca https://acme-staging-v02.api.letsencrypt.org/directoryto your global Caddyfile block.
Conclusion & Next Steps
By leveraging Caddy alongside Docker Compose, you have eliminated the manual friction of managing SSL certificates, cron renewal scripts, and convoluted proxy rules. Your containerized services are now shielded behind an isolated Docker network, accessible solely through high-performance, encrypted HTTPS endpoints.
Now that your services are publicly exposed, your next priority is data reliability. In our next tutorial, we will explore how to automate encrypted, deduplicated Docker volume backups using Restic and S3 storage.
Hi, I’m Mark, the author of Clever IT Solutions: Mastering Technology for Success. I am passionate about empowering individuals to navigate the ever-changing world of information technology. With years of experience in the industry, I have honed my skills and knowledge to share with you. At Clever IT Solutions, we are dedicated to teaching you how to tackle any IT challenge, helping you stay ahead in today’s digital world. From troubleshooting common issues to mastering complex technologies, I am here to guide you every step of the way. Join me on this journey as we unlock the secrets to IT success.


