Caddy Reverse Proxy¶
Caddy handles TLS termination, automatic Let's Encrypt certificate provisioning, and reverse-proxy routing for all VM-hosted services (Keycloak ACC, Keycloak PROD, Operaton).
Repository structure¶
The Caddyfile is version-controlled but must be copied to the VM and loaded by the Caddy container. It is not committed with secrets.
Caddyfile¶
acc.keycloak.open-regels.nl {
reverse_proxy keycloak-acc:8080
}
keycloak.open-regels.nl {
reverse_proxy keycloak-prod:8080
}
operaton.open-regels.nl {
reverse_proxy operaton:8080
}
Caddy automatically provisions and renews Let's Encrypt certificates for all listed domains. HTTP requests are automatically redirected to HTTPS.
Retrieving the Caddyfile from a running VM¶
If the Caddyfile on the VM has diverged from the version in the repository, retrieve the live version and commit it:
ssh user@open-regels.nl "docker exec caddy cat /etc/caddy/Caddyfile" \
> deployment/vm/caddy/Caddyfile
Review the diff before committing — the VM copy is authoritative for any manual changes made outside of the normal deploy flow.
Deploying Caddy¶
Copy the Caddyfile to the VM:
Create ~/caddy/docker-compose.yml on the VM:
version: '3.8'
services:
caddy:
image: caddy:2-alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
restart: always
networks:
- npm-network
volumes:
caddy-data:
caddy-config:
networks:
npm-network:
external: true
Start Caddy:
Caddy is ready when you see: certificate obtained successfully
Adding a new domain¶
To expose a new VM service through Caddy:
- Add a DNS A record pointing the new subdomain to the VM's public IP
- Add a new block to the
Caddyfile: - Copy the updated
Caddyfileto the VM and reload:
Docker network¶
All VM containers communicate over a shared Docker network named npm-network. Caddy resolves Keycloak and Operaton by their container names (keycloak-acc, keycloak-prod, operaton) within this network. Ensure all containers specify networks: npm-network in their docker-compose.yml.
Security hardening¶
Security headers¶
Add a reusable import snippet to apply security headers to all proxied services:
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
acc.keycloak.open-regels.nl {
import security_headers
reverse_proxy keycloak-acc:8080
}
keycloak.open-regels.nl {
import security_headers
reverse_proxy keycloak-prod:8080
}
operaton.open-regels.nl {
import security_headers
reverse_proxy operaton:8080
}
Rate limiting¶
To protect Keycloak login from brute-force attempts at the proxy level:
acc.keycloak.open-regels.nl {
import security_headers
rate_limit {
zone acc_keycloak {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy keycloak-acc:8080
}
Note: Keycloak's built-in brute-force protection (5 failed attempts → lockout) is configured in the realm settings and operates independently of Caddy rate limiting.
Verifying SSL certificates¶
# Check certificate expiry for each domain
echo | openssl s_client -servername acc.keycloak.open-regels.nl \
-connect acc.keycloak.open-regels.nl:443 2>/dev/null | openssl x509 -noout -dates
echo | openssl s_client -servername keycloak.open-regels.nl \
-connect keycloak.open-regels.nl:443 2>/dev/null | openssl x509 -noout -dates
echo | openssl s_client -servername operaton.open-regels.nl \
-connect operaton.open-regels.nl:443 2>/dev/null | openssl x509 -noout -dates
Caddy stores certificates in the caddy-data volume and renews them automatically ~30 days before expiry. No manual intervention is required unless DNS or the VM IP changes.
Metrics and monitoring¶
Caddy exposes a metrics/admin endpoint on port 2019 (localhost only, not exposed to internet):
To follow access logs:
docker logs -f caddy
# Filter for a specific domain
docker logs caddy 2>&1 | grep "acc.keycloak.open-regels.nl"
Troubleshooting¶
Service returns 502 Bad Gateway — Caddy can reach the domain but can't reach the backend container. Check:
- Container is running:
docker ps - Container is on
npm-network:docker network inspect npm-network | grep <container-name> - Container name in Caddyfile matches exactly:
docker ps --format '{{.Names}}'
Certificate not provisioning — Check that:
- DNS A record for the domain points to the VM's public IP:
dig acc.keycloak.open-regels.nl - Ports 80 and 443 are open on the VM:
sudo ufw status - Caddy logs for ACME errors:
docker logs caddy | grep -i "acme\|certificate\|error"
Validate Caddyfile syntax before reloading: