Zum Inhalt

Automated TLS Ingress: Caddy v2 Go Architecture, On-Demand ACME, Internal PKI & xcaddy

Executive Summary: Traditional web servers require external ACME cron jobs, manual OCSP stapling configuration, and fragile reload scripts to maintain HTTPS availability. This architectural reference details the production deployment of Caddy v2—a memory-safe Go web server and reverse proxy that enables Automatic HTTPS by default (HTTP/1.1, HTTP/2, and HTTP/3 QUIC), features an embedded Smallstep PKI (tls internal) for instant trusted LAN certificates, compiles custom DNS-01 plugins via xcaddy, and exposes a transactional JSON configuration API on localhost:2019.

  • Zero-Config Automatic HTTPS


    Automate Let's Encrypt and ZeroSSL multi-CA fallback, OCSP stapling, TLS 1.3 cipher negotiation, and HTTP/3 (QUIC) listeners out of the box.

  • Embedded Internal PKI (tls internal)


    Operate a self-contained Root and Intermediate Certificate Authority powered by Smallstep to issue short-lived certificates for .lan domains.

  • Custom xcaddy Go Builds


    Compile static Go binaries embedding Cloudflare/RFC2136 DNS-01 providers, CrowdSec bouncers, and Coraza WAF modules in a single container stage.

  • Caddyfile Adapter & JSON API


    Author concise human-readable Caddyfile snippets (snippets) that compile deterministically into Caddy's atomic JSON runtime state.


1. Architecture: Caddyfile Adapter, CertMagic & Embedded PKI

Under the hood, Caddy's native configuration language is a strongly typed JSON document served via an admin socket (localhost:2019). When you write a Caddyfile, Caddy's configuration adapter compiles it into native JSON and loads it atomically into the running process without dropping active TCP or QUIC streams. Certificate lifecycle management is powered by CertMagic, which coordinates distributed storage locks and automatic multi-issuer fallback (Let's Encrypt → ZeroSSL).

%%{init: {'flowchart': {'wrappingWidth': 600, 'subGraphTitleMargin': {'top': 15, 'bottom': 15}}}}%%
flowchart TB
    subgraph ConfigPlane["Configuration & Build Pipeline"]
        Caddyfile["Declarative Caddyfile<br/>Snippets + Matchers"]
        Adapter["JSON Config Adapter<br/>Atomic Hot-Reload (:2019)"]
    end

    subgraph CaddyRuntime["Caddy v2 Engine (Go + CertMagic)"]
        Listeners["HTTP/1.1, HTTP/2 & HTTP/3<br/>TCP/443 + UDP/443"]
        CertMagic["CertMagic ACME Engine<br/>Let's Encrypt + ZeroSSL"]
        StepPKI["Smallstep Internal CA<br/>tls internal (.lan Zones)"]
    end

    subgraph Upstreams["Upstream Service Pool"]
        ActiveHC["Active / Passive Health<br/>lb_policy least_conn"]
        Backends["Container Backends<br/>FastCGI / Reverse Proxy"]
    end

    Caddyfile -->|"caddy adapt / reload"| Adapter
    Adapter --> Listeners
    Listeners <--> CertMagic
    Listeners <--> StepPKI
    Listeners --> ActiveHC
    ActiveHC --> Backends

2. Building a Custom Caddy Binary with xcaddy (DNS-01 Wildcards)

The official caddy:2-alpine image includes standard HTTP-01 and TLS-ALPN-01 ACME solvers. To issue wildcard certificates (*.nejat-hakan.de) or authenticate internal servers via DNS-01 without exposing port 80/443 publicly, build a custom static binary using the official builder stage:

/opt/caddy/Dockerfile
FROM caddy:2.8-builder-alpine AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/caddy-dns/rfc2136 \
    --with github.com/WeidiDeng/caddy-cloudflare-ip

FROM caddy:2.8-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
/opt/caddy/compose.yaml
services:
  caddy:
    build: .
    container_name: caddy-ingress
    restart: unless-stopped
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    environment:
      TZ: "Europe/Berlin"
      CF_API_TOKEN: "${CF_API_TOKEN}"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data:/data:Z
      - ./config:/config:Z
    cap_add:
      - NET_ADMIN

Persist /data Across Container Recreations

Unlike Nginx where /data is optional, Caddy stores its ACME account keys, issued X.509 certificates, OCSP staples, and internal Root CA private keys inside /data/caddy. Failing to mount /data as a persistent volume will cause Caddy to hit Let's Encrypt rate limits (5 duplicate certificates per week) upon container restarts.


3. Production Caddyfile: Snippets, Wildcards & ForwardAuth

Caddy supports reusable named blocks (snippet_name) imported via import. Below is a complete production Caddyfile demonstrating global HTTP/3 configuration, reusable security headers, Authelia/Pocket-ID forward_auth, and load-balanced health checks:

/opt/caddy/Caddyfile
{
    email hostmaster@nejat-hakan.de
    admin localhost:2019
    servers {
        protocols h1 h2 h3
        trusted_proxies static private_ranges
    }
}

# --- Reusable Hardened Security Headers Snippet ---
(security_headers) {
    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), usb=()"
        -Server
    }
    encode zstd gzip
}

# --- Reusable SSO ForwardAuth Gate (Authelia / Authentik) ---
(sso_gate) {
    forward_auth authelia:9091 {
        uri /api/authz/forward-auth
        copy_headers Remote-User Remote-Groups Remote-Emails Remote-Name
    }
}

# --- 1. Wildcard Public Domain Block ---
*.nejat-hakan.de {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
        resolvers 1.1.1.1 9.9.9.9
    }

    import security_headers

    @vault host vault.nejat-hakan.de
    handle @vault {
        reverse_proxy vaultwarden:80 {
            header_up X-Real-IP {remote_host}
        }
    }

    @git host git.nejat-hakan.de
    handle @git {
        request_body {
            max_size 2GB
        }
        reverse_proxy forgejo:3000
    }

    @grafana host metrics.nejat-hakan.de
    handle @grafana {
        import sso_gate
        reverse_proxy grafana:3000 {
            lb_policy least_conn
            health_uri /api/health
            health_interval 15s
            health_timeout 5s
        }
    }

    # Default Fallback for Unconfigured Subdomains
    handle {
        abort
    }
}

# --- 2. Internal LAN Services via Embedded Smallstep PKI ---
proxmox.lan.nejat-hakan.de {
    tls internal
    import security_headers

    reverse_proxy https://192.168.10.20:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}

4. Trusting Caddy's Internal Root CA on Client Workstations

When using tls internal for air-gapped or split-horizon .lan domains, Caddy generates a long-lived Root Certificate Authority inside /data/caddy/pki/authorities/local/root.crt and signs short-lived leaf certificates via an intermediate CA. Extract and install the Root CA into your workstation trust store once:

Extracting & Trusting Caddy's Local Root CA on Linux
# 1. Copy the generated Root CA certificate from the container volume
sudo cp /opt/caddy/data/caddy/pki/authorities/local/root.crt \
  /usr/local/share/ca-certificates/caddy-internal-root-ca.crt

# 2. Update the system-wide CA bundle (/etc/ssl/certs)
sudo update-ca-certificates

# 3. Also inject into NSS database (Chrome / Firefox) if running on a desktop
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Caddy Internal CA" \
  -i /usr/local/share/ca-certificates/caddy-internal-root-ca.crt

5. Verification, Formatting & Zero-Downtime Reloads

Always format and validate your Caddyfile before triggering an atomic hot-reload via the admin socket:

Formatting, Validating & Reloading Caddy Without Downtime
# 1. Auto-format Caddyfile indentation in place
docker exec caddy-ingress caddy fmt --overwrite /etc/caddy/Caddyfile

# 2. Validate syntax and inspect compiled JSON output
docker exec caddy-ingress caddy validate --config /etc/caddy/Caddyfile

# 3. Perform atomic zero-downtime configuration reload via localhost:2019 API
docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile