How to Securely Expose Docker Services with Caddy and Automatic Let’s Encrypt SSL

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.

DevOps engineer configuring Caddy reverse proxy with automatic SSL in Docker Compose
How to Securely Expose Docker Services with Caddy and Automatic Let's Encrypt SSL 3

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 Upgrade and Connection header 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:

  1. Server: A Linux server (Ubuntu 22.04 / 24.04 LTS or Debian 12 / 13) with a public IPv4 address.
  2. Docker & Compose: Docker Engine 24.0+ with Docker Compose v2.
  3. 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.
  4. 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 80 and 443.
  • 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:

  1. The green padlock / secure connection icon is present.
  2. The certificate was automatically signed by Let’s Encrypt (valid for 90 days, auto-renewed at 30 days remaining).
  3. Typing http://chat.yourdomain.com automatically 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_network by running docker 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_data is mounted as a persistent volume so existing certificates are preserved across reboots. During local development, test against Let’s Encrypt’s staging environment by adding acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to 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.