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, andHTTP/3 QUIC), features an embedded Smallstep PKI (tls internal) for instant trusted LAN certificates, compiles custom DNS-01 plugins viaxcaddy, and exposes a transactional JSON configuration API onlocalhost: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
.landomains. -
Custom
xcaddyGo 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
Caddyfilesnippets(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:
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
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:
{
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:
# 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:
# 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