TL;DR — Quick Summary

Complete guide to Caddy reverse proxy with automatic HTTPS. Configure SSL with zero config, load balancing, health checks, and production multi-site hosting.

Caddy is a Go-based web server that makes automatic HTTPS the default — not an afterthought. Unlike Nginx or Apache where TLS means installing certbot, writing renewal scripts, and debugging cron jobs, Caddy negotiates certificates from Let’s Encrypt or ZeroSSL the moment you add a domain to your config. This guide covers everything from installation to production multi-site reverse proxy setups.

Prerequisites

  • A Linux server (Ubuntu 22.04/24.04, Debian, RHEL, or Docker).
  • A domain name with DNS A records pointing to your server’s public IP.
  • Ports 80 and 443 open in your firewall — Caddy needs both for ACME HTTP-01 challenge.
  • Backend services running on localhost ports (Node.js, Python, PHP, etc.).

Caddy Architecture

Caddy is a single static binary written in Go with no runtime dependencies. Key architectural decisions:

Automatic HTTPS via ACME. Caddy implements the ACME protocol natively. When you configure a domain, Caddy:

  1. Detects the domain requires a certificate.
  2. Runs an HTTP-01 or TLS-ALPN-01 ACME challenge.
  3. Stores the certificate in ~/.local/share/caddy/ (or /var/lib/caddy/ for system installs).
  4. Schedules renewal 30 days before expiry — automatically, in the background.
  5. Staples OCSP responses to TLS handshakes for faster revocation checks.

Certificate storage. Certificates live in ~/.local/share/caddy/certificates/ organized by ACME server hostname. Caddy supports multiple ACME providers simultaneously — Let’s Encrypt and ZeroSSL by default, with automatic failover.

Admin API. Caddy exposes a REST API at localhost:2019 for live config changes — no restart needed. The /config/ endpoint accepts the full JSON config, /load replaces the entire config atomically, and /reverse_proxy/upstreams shows live upstream health.


Installation

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

Caddy starts immediately as a systemd service. Your Caddyfile is at /etc/caddy/Caddyfile.

Docker

docker run -d \
  --name caddy \
  -p 80:80 -p 443:443 -p 443:443/udp \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
  -v caddy_data:/data \
  -v caddy_config:/config \
  caddy:latest

The /data volume persists certificates across container restarts.

xcaddy — Custom Builds with Plugins

Caddy’s plugin ecosystem extends it with rate limiting, DNS challenges, and more. Build a custom binary:

go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-ratelimit

This produces a caddy binary with Cloudflare DNS challenge support (for wildcard certificates) and rate limiting.


Caddyfile Syntax

The Caddyfile is Caddy’s human-friendly configuration format. Core concepts:

Site blocks define which hostname Caddy serves:

example.com {
    reverse_proxy localhost:3000
}

Global options apply server-wide settings:

{
    email admin@example.com
    acme_ca https://acme-v02.api.letsencrypt.org/directory
}

Snippets let you reuse config blocks:

(common_headers) {
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        -Server
    }
}

example.com {
    import common_headers
    reverse_proxy localhost:3000
}

Matchers apply directives to specific request paths:

example.com {
    handle /api/* {
        reverse_proxy localhost:8080
    }
    handle /* {
        reverse_proxy localhost:3000
    }
}

Reverse Proxy Configuration

Basic Reverse Proxy

example.com {
    reverse_proxy localhost:3000
}

That’s it. Caddy handles HTTP→HTTPS redirect, certificate issuance, renewal, and OCSP stapling automatically.

Load Balancing

example.com {
    reverse_proxy app1:3000 app2:3000 app3:3000 {
        lb_policy round_robin
    }
}

Available lb_policy values:

PolicyDescription
round_robinDistributes requests evenly across upstreams
least_connRoutes to the upstream with fewest active connections
firstAlways tries upstreams in order; uses first available
randomPicks a random upstream each request
ip_hashHashes client IP for sticky sessions
cookieUses a cookie for session persistence
headerHashes a request header value
uri_hashHashes the request URI

Active and Passive Health Checks

example.com {
    reverse_proxy app1:3000 app2:3000 {
        lb_policy least_conn

        # Active health checks — Caddy polls upstreams
        health_uri /health
        health_interval 10s
        health_timeout 5s
        health_status 200

        # Passive health checks — mark unhealthy on failures
        fail_duration 30s
        max_fails 3
        unhealthy_status 500 502 503
        unhealthy_latency 5s
    }
}

Active checks poll /health every 10 seconds. Passive checks automatically remove an upstream after 3 consecutive failures and re-add it after 30 seconds.

Header Manipulation

example.com {
    reverse_proxy localhost:3000 {
        header_up Host {upstream_hostport}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
        header_down -Server
        header_down -X-Powered-By
    }
}

header_up modifies request headers sent to the upstream. header_down modifies response headers sent to the client. Prefix a header name with - to remove it.


Automatic HTTPS Deep Dive

How Caddy Obtains Certificates

  1. A request arrives for example.com.
  2. Caddy checks its certificate store — no cert found.
  3. Caddy triggers an ACME HTTP-01 challenge: creates a temporary file at /.well-known/acme-challenge/<token>.
  4. The ACME server (Let’s Encrypt) fetches the token over HTTP.
  5. Caddy receives the signed certificate and stores it.
  6. All future connections use HTTPS; HTTP requests redirect to HTTPS automatically.
  7. Caddy renews the certificate 30 days before expiry in the background.

On-Demand TLS

For dynamic domains (e.g., a SaaS platform where users add custom domains), Caddy supports on-demand TLS — it obtains certificates the first time a domain is requested:

{
    on_demand_tls {
        ask http://localhost:8080/check-domain
        interval 2m
        burst 5
    }
}

:443 {
    tls {
        on_demand
    }
    reverse_proxy localhost:3000
}

The ask endpoint is your app’s API — Caddy calls it before issuing a cert, letting you allow or deny domains.

Internal CA for Local Development

For local development with real HTTPS (no browser warnings), use Caddy’s internal CA:

localhost {
    tls internal
    reverse_proxy localhost:3000
}

Run caddy trust to install Caddy’s local CA into your system/browser trust store.

Custom ACME Endpoint (Smallstep, BuyPass, etc.)

{
    acme_ca https://ca.example.com/acme/acme/directory
    acme_ca_root /path/to/root-ca.pem
}

This works with Smallstep CA, BuyPass Go SSL, or any RFC 8555 compliant ACME server.


Common Caddyfile Patterns

SPA with Client-Side Routing

app.example.com {
    root * /var/www/app
    try_files {path} /index.html
    file_server
}

PHP with PHP-FPM

php.example.com {
    root * /var/www/php-app
    php_fastcgi unix//run/php/php8.3-fpm.sock
    file_server
}

Static File Server with Compression

files.example.com {
    root * /var/www/files
    encode gzip zstd
    file_server browse
}

Basic Authentication

private.example.com {
    basicauth {
        alice JDJhJDE0JHBMbUhBRzVJQ...   # bcrypt hash from caddy hash-password
    }
    reverse_proxy localhost:8080
}

JSON Config API

Caddy’s admin API at localhost:2019 enables live configuration without restarts:

# View current config
curl http://localhost:2019/config/

# Replace entire config
curl -X POST http://localhost:2019/load \
  -H "Content-Type: application/json" \
  -d @caddy.json

# Check upstream health
curl http://localhost:2019/reverse_proxy/upstreams

# Add a route without reloading
curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes \
  -H "Content-Type: application/json" \
  -d '{"match":[{"host":["newsite.com"]}],...}'

Logging and Metrics

Structured JSON Logging

example.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size 100mb
            roll_keep 10
            roll_keep_for 720h
        }
        format json
        level INFO
    }
    reverse_proxy localhost:3000
}

Prometheus Metrics

{
    servers {
        metrics
    }
}

Caddy exposes Prometheus metrics at http://localhost:2019/metrics. Scrape with Prometheus and visualize in Grafana.


Caddy vs Nginx vs Traefik vs HAProxy

FeatureCaddyNginxTraefikHAProxy
Automatic HTTPSBuilt-in, defaultManual (certbot)Built-inManual
Config syntaxSimple Caddyfile or JSONComplex nginx.confYAML/TOML labelsComplex
Runtime reloadYes (API + signal)Yes (signal)YesYes (socket)
Load balancing8 policiesRound-robin, IP hashMultipleExtensive
Admin APIREST JSONNoREST JSONStats socket
HTTP/3 (QUIC)YesYes (v1.25+)YesNo
Plugin ecosystemxcaddyDynamic modulesProvider pluginsNo
Resource usageLow (Go)Very low (C)Medium (Go)Very low (C)
Best forAuto-HTTPS, simplicityHigh-perf staticDocker/K8sTCP load balancing

Production Caddyfile: Multi-Site Hosting

{
    email ops@example.com
    admin localhost:2019
}

(security_headers) {
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
        -Server
        -X-Powered-By
    }
}

(proxy_headers) {
    header_up Host {upstream_hostport}
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
}

# Main app — load balanced
app.example.com {
    import security_headers

    encode gzip zstd

    log {
        output file /var/log/caddy/app.log
        format json
    }

    reverse_proxy app1:3000 app2:3000 {
        import proxy_headers
        lb_policy least_conn
        health_uri /health
        health_interval 15s
        fail_duration 30s
    }
}

# API backend
api.example.com {
    import security_headers

    @cors_preflight method OPTIONS
    handle @cors_preflight {
        header Access-Control-Allow-Origin "https://app.example.com"
        header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
        header Access-Control-Max-Age 3600
        respond "" 204
    }

    reverse_proxy localhost:8080 {
        import proxy_headers
    }
}

# Static marketing site
www.example.com {
    import security_headers
    encode gzip zstd
    root * /var/www/marketing
    try_files {path} /index.html
    file_server
}

# Redirect bare domain
example.com {
    redir https://www.example.com{uri} 301
}

Gotchas and Edge Cases

  • Port 80 must be reachable. Caddy uses HTTP-01 ACME challenge. If port 80 is firewalled, use the DNS-01 challenge with a DNS plugin via xcaddy.
  • Rate limits on Let’s Encrypt. You can request 50 certificates per domain per week. Use the Let’s Encrypt staging CA (acme_ca https://acme-staging-v02.api.letsencrypt.org/directory) during testing.
  • header_up Host breaks some upstreams. Some backends require Host: localhost not the external hostname. Test without header_up Host first.
  • On-demand TLS needs an ask endpoint. Without the ask URL, anyone can trigger certificate issuance for any domain — a Let’s Encrypt rate limit attack vector.
  • Admin API is localhost-only by default. Never expose port 2019 publicly — it allows live config replacement with no authentication.
  • Caddy reloads vs restarts. systemctl reload caddy (graceful, zero-downtime) vs systemctl restart caddy (kills all connections). Always use reload in production.

Summary

  • Automatic HTTPS — Caddy obtains and renews Let’s Encrypt/ZeroSSL certificates with zero configuration.
  • Simple syntax — A five-line Caddyfile replaces hundreds of lines of Nginx config.
  • 8 load balancing policies — including sticky sessions via cookie and IP hash.
  • Active and passive health checks — automatically remove and re-add failed upstreams.
  • On-demand TLS — issue certificates dynamically for user-provided domains.
  • REST admin API — live config changes at localhost:2019 without restarts.
  • Internal CA — real HTTPS for local development without browser warnings.