App Deployment·7 min read·

Modern Web Stack with Docker Compose and Caddy Ingress

Set up a clean, production-ready multi-container web stack with Docker Compose and Caddy, featuring automated Let's Encrypt TLS and isolated networks.

NB

Netbay Cloud Team

Netbay Engineering

On this page

Multi-container application stacks composed of a web API, database, and background worker frequently encounter configuration drift when certificates and proxy routes are managed manually. While Nginx has traditionally served as the default reverse proxy, configuring certbot renewals, ACME HTTP challenge routing, and OCSP stapling adds operational friction.

Caddy simplifies container ingress. By running Caddy in a Docker Compose stack alongside your application and database services, TLS certificates are issued and renewed automatically with Let's Encrypt and ZeroSSL. Combined with Docker bridge networks, you can completely isolate internal database ports from the host's public network interface.

Designing the Multi-Tier Compose Architecture

Our production architecture consists of three distinct tiers:

  1. **Public Ingress (Caddy)**: Binds to host ports 80 and 443, handles TLS termination, and routes requests.
  2. **Application Tier**: Runs your Node.js, Python, or Go web container. It communicates only on the internal Docker network.
  3. **Data Tier (PostgreSQL & Valkey)**: Stores application state on High-Speed SSD persistent volumes. It has no exposed host ports.

Security at the network level is achieved by defining two distinct bridge networks:

  • webnet: Connects Caddy to the application service.
  • backend: Marked with internal: true, connecting the application service to PostgreSQL and Valkey.

Because the database containers attach exclusively to the backend network, they are physically unreachable from the public internet, even if database root passwords were weak or misconfigured.

Create your project directory:

bash
mkdir -p /srv/webstack && cd /srv/webstack
mkdir -p caddy_data caddy_config

Writing docker-compose.yml

Create docker-compose.yml defining services, internal networks, and volume persistence:

yaml
# /srv/webstack/docker-compose.yml
services:
  caddy:
    image: caddy:2.8-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp" # HTTP/3 QUIC support
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy_data:/data
      - ./caddy_config:/config
    networks:
      - webnet
    depends_on:
      - app

  app:
    image: myapp:production
    restart: unless-stopped
    environment:
      - PORT=3000
      - DATABASE_URL=postgres://appuser:appsecret@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    networks:
      - webnet
      - backend
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=appsecret
      - POSTGRES_DB=appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backend

  cache:
    image: valkey/valkey:8-alpine
    restart: unless-stopped
    command: ["valkey-server", "--appendonly", "yes"]
    volumes:
      - cache_data:/data
    networks:
      - backend

networks:
  webnet:
    driver: bridge
  backend:
    internal: true

volumes:
  db_data:
  cache_data:

Notice that the backend network specifies internal: true. This prevents any container attached exclusively to the backend network from initiating outbound internet connections or exposing ports to the public interface.

Expand
Docker Compose and Caddy
Containerized Ingress and Network Isolation

Configuring the Caddyfile

Create the Caddyfile with security headers, automatic HTTPS, and gzip/zstd compression:

caddy
# /srv/webstack/Caddyfile
app.example.com {
    # Automatic TLS with 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"
    }

    # Reverse proxy to the application container name and internal port
    reverse_proxy app:3000 {
        health_uri /health
        health_interval 5s
        health_timeout 2s
        health_status 200
    }

    encode zstd gzip
    log {
        output stdout
        format console
    }
}
Docker Compose & Caddy Network Isolation Topology Caddy Ingress Container - Host 80/443 (HTTP/3) - Auto Let's Encrypt - Shared network: webnet - Proxies to app:3000 Zero manual SSL certs App Container (app) - Dual-homed network: 1. webnet (receives req) 2. backend (talks to DB) - No public IP bind - High-Speed SSD access Data Tier (db & cache) - PostgreSQL 16 + Valkey 8 - Network: backend (internal) - Zero internet access - Persistent Named Volumes Protected from scanners Internal networks isolate databases while Caddy terminates TLS at the edge
Docker Containers and Modern Reverse Proxy Architecture
YouTube

Launching and Validating the Stack

Launch the stack using Docker Compose:

bash
# Start all containers in detached mode
sudo docker compose up -d

# Verify container health and logs
sudo docker compose ps
sudo docker compose logs -f caddy

Test the public HTTPS endpoint and confirm security headers:

bash
curl -I https://app.example.com

This stack delivers automated certificates, container network isolation, and reliable zero-touch maintenance.

Deploy your Docker Compose applications on Netbay Linux VPS in under 60 seconds with High-Speed SSD in Lucknow DC01 — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance