19 KiB
| title | description | published | date | tags | editor | dateCreated |
|---|---|---|---|---|---|---|
| Caddy Reverse Proxy | Curreent and future config | true | 2026-02-25T01:50:20.558Z | markdown | 2026-02-23T22:09:16.106Z |
Caddy Reverse Proxy
Host: znas (Docker Swarm node)
Internal IP: 192.168.5.10
Data Path: /export/Docker/caddy/
Networks: netgrimoire (service network), vpn
Ports: 80 (mapped to host 8900), 443
Overview
Caddy serves as the primary reverse proxy for all public and internal web services. It uses the caddy-docker-proxy pattern, which allows services to register themselves with Caddy by adding Docker labels to their compose files — no manual Caddyfile edits required per service.
Configuration is hybrid: some services are defined entirely via Docker labels, others are defined statically in the Caddyfile, and most use both (labels for routing, Caddyfile for shared snippets). The caddy-docker-proxy container merges both sources at runtime.
Current State
Image
image: lucaslorentz/caddy-docker-proxy:ci-alpine
This image provides the Docker Proxy module only. It has no CrowdSec, GeoIP, or rate limiting built in.
Docker Compose (/export/Docker/caddy/docker-compose.yml)
configs:
caddy-basic-content:
file: ./Caddyfile
labels:
caddy:
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
ports:
- 8900:80
- 443:443
environment:
- CADDY_INGRESS_NETWORKS=netgrimoire
networks:
- netgrimoire
- vpn
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /export/Docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- /export/Docker/caddy:/data
#- /export/Docker/caddy/logs:/var/log/caddy # Placeholder for CrowdSec log mount
deploy:
placement:
constraints:
- node.hostname == znas
networks:
netgrimoire:
external: true
vpn:
external: true
Caddyfile (/export/Docker/caddy/Caddyfile)
The Caddyfile defines shared authentication snippets and static site blocks. These snippets are available to all services — including label-defined ones — via import.
# ─────────────────────────────────────────────────────────────────────────────
# AUTH SNIPPETS
# ─────────────────────────────────────────────────────────────────────────────
(authentik) {
route /outpost.goauthentik.io/* {
reverse_proxy http://authentik:9000
}
forward_auth http://authentik:9000 {
uri /outpost.goauthentik.io/auth/caddy
header_up X-Forwarded-URI {http.request.uri}
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email \
X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt \
X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider \
X-Authentik-Meta-App X-Authentik-Meta-Version
}
}
(authelia) {
forward_auth http://authelia:9091 {
uri /api/verify?rd=https://login.wasted-bandwidth.net/
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
# ─────────────────────────────────────────────────────────────────────────────
# MAIL SNIPPETS
# ─────────────────────────────────────────────────────────────────────────────
(email-proxy) {
redir https://mail.netgrimoire.com/sogo 301
}
(mailcow-proxy) {
reverse_proxy nginx-mailcow:80
}
# ─────────────────────────────────────────────────────────────────────────────
# STATIC SITE BLOCKS — NETGRIMOIRE.COM
# ─────────────────────────────────────────────────────────────────────────────
cloud.netgrimoire.com {
reverse_proxy http://nextcloud-aio-apache:11000
}
log.netgrimoire.com {
reverse_proxy http://graylog:9000
}
win.netgrimoire.com {
reverse_proxy http://192.168.5.10:8006
}
docker.netgrimoire.com {
reverse_proxy http://portainer:9000
}
immich.netgrimoire.com {
reverse_proxy http://192.168.5.10:2283
}
npm.netgrimoire.com {
reverse_proxy http://librenms:8000
}
#jellyfin.netgrimoire.com {
# reverse_proxy http://jellyfin:8096
#}
# ─────────────────────────────────────────────────────────────────────────────
# AUTHENTICATED — NETGRIMOIRE.COM
# ─────────────────────────────────────────────────────────────────────────────
dozzle.netgrimoire.com {
import authentik
reverse_proxy http://192.168.4.72:8043
}
dns.netgrimoire.com {
import authentik
reverse_proxy http://192.168.5.7:5380
}
webtop.netgrimoire.com {
import authentik
reverse_proxy http://webtop:3000
}
jackett.netgrimoire.com {
import authentik
reverse_proxy http://gluetun:9117
}
transmission.netgrimoire.com {
import authentik
reverse_proxy http://gluetun:9091
}
scrutiny.netgrimoire.com {
import authentik
reverse_proxy http://192.168.5.10:8081
}
# ─────────────────────────────────────────────────────────────────────────────
# AUTHENTICATED — WASTED-BANDWIDTH.NET (Authelia)
# ─────────────────────────────────────────────────────────────────────────────
stash.wasted-bandwidth.net {
import authelia
reverse_proxy http://192.168.5.10:9999
}
namer.wasted-bandwidth.net {
import authelia
reverse_proxy http://192.168.5.10:6980
}
# ─────────────────────────────────────────────────────────────────────────────
# PUBLIC — PNCHARRIS.COM / WASTED-BANDWIDTH.NET
# ─────────────────────────────────────────────────────────────────────────────
fish.pncharris.com {
reverse_proxy http://web
}
www.wasted-bandwidth.net {
reverse_proxy http://web
}
# ─────────────────────────────────────────────────────────────────────────────
# MAILCOW — MULTI-DOMAIN
# ─────────────────────────────────────────────────────────────────────────────
mail.netgrimoire.com, autodiscover.netgrimoire.com, autoconfig.netgrimoire.com, \
mail.wasted-bandwidth.net, autodiscover.wasted-bandwidth.net, autoconfig.wasted-bandwidth.net, \
mail.gnarlypandaproductions.com, autodiscover.gnarlypandaproductions.com, autoconfig.gnarlypandaproductions.com, \
mail.pncfishandmore.com, autodiscover.pncfishandmore.com, autoconfig.pncfishandmore.com, \
mail.pncharrisenterprises.com, autodiscover.pncharrisenterprises.com, autoconfig.pncharrisenterprises.com, \
mail.pncharris.com, autodiscover.pncharris.com, autoconfig.pncharris.com, \
mail.florosafd.org, autodiscover.florosafd.org, autoconfig.florosafd.org {
import mailcow-proxy
}
Docker Label Pattern (label-defined services)
Services not in the Caddyfile are registered via labels on their own containers. The snippet defined in the Caddyfile is available to them via caddy.import:
labels:
- caddy=homepage.netgrimoire.com
- caddy.import=authentik
- caddy.reverse_proxy={{upstreams 3000}}
For services that need no auth:
labels:
- caddy=myservice.netgrimoire.com
- caddy.reverse_proxy={{upstreams 8080}}
Authentication Layers
Two identity proxies are in use, each serving different domains/use cases:
| Provider | Domain Pattern | Snippet |
|---|---|---|
| Authentik | *.netgrimoire.com internal tools |
import authentik |
| Authelia | *.wasted-bandwidth.net |
import authelia |
Services without an auth import are either public (e.g. fish.pncharris.com) or carry their own authentication (e.g. Nextcloud, Graylog, Portainer).
Current Security Posture
CrowdSec protection exists only at the OPNsense firewall level — IP reputation blocking before traffic reaches Caddy. CrowdSec does not currently inspect HTTP traffic at the application layer. This means:
- Known-bad IPs are blocked at the perimeter
- Application-layer attacks (SQLi in URLs, malicious paths, bad user agents, brute force on specific endpoints) are not blocked at the Caddy level
- Services behind Authentik/Authelia have an additional protection layer; unauthenticated public services do not
Future State: CrowdSec + GeoIP + Rate Limiting
Target Image
image: ghcr.io/serfriz/caddy-crowdsec-geoip-ratelimit-security-dockerproxy:latest
This is a drop-in replacement for lucaslorentz/caddy-docker-proxy. All existing Docker labels and Caddyfile site blocks continue to work unchanged. The image is automatically rebuilt monthly when Caddy releases updates — no custom image maintenance required.
Included modules:
caddy-docker-proxy— same label-based config as currentcaddy-crowdsec-bouncer— inline HTTP blocking based on CrowdSec decisionscaddy-geoip— GeoIP filtering at the application layercaddy-ratelimit— per-endpoint rate limitingcaddy-security— additional auth/security middleware
Updated Compose
configs:
caddy-basic-content:
file: ./Caddyfile
labels:
caddy:
services:
caddy:
image: ghcr.io/serfriz/caddy-crowdsec-geoip-ratelimit-security-dockerproxy:latest
ports:
- 8900:80
- 443:443
environment:
- CADDY_INGRESS_NETWORKS=netgrimoire
- CADDY_DOCKER_EVENT_THROTTLE_INTERVAL=2000 # Prevents non-deterministic reload with CrowdSec module
- CROWDSEC_API_KEY=BYSLg/wKOa7wlHYzChJpBVJA06Ukc7G6fKJCvBwjyZg
networks:
- netgrimoire
- vpn
- crowdsec_net
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /export/Docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- /export/Docker/caddy:/data
- caddy-logs:/var/log/caddy
deploy:
placement:
constraints:
- node.hostname == znas
crowdsec:
image: crowdsecurity/crowdsec
restart: unless-stopped
environment:
COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-cve crowdsecurity/whitelist-good-actors"
BOUNCER_KEY_CADDY: BYSLg/wKOa7wlHYzChJpBVJA06Ukc7G6fKJCvBwjyZg # Pre-registers the Caddy bouncer automatically
volumes:
- crowdsec-db:/var/lib/crowdsec/data
- ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml
- caddy-logs:/var/log/caddy:ro
networks:
- crowdsec_net
deploy:
placement:
constraints:
- node.hostname == znas
volumes:
caddy-logs:
crowdsec-db:
networks:
netgrimoire:
external: true
vpn:
external: true
crowdsec_net:
driver: overlay # Swarm overlay network
CrowdSec Log Acquisition (./crowdsec/acquis.yaml)
filenames:
- /var/log/caddy/access.log
labels:
type: caddy
Environment File (.env)
CROWDSEC_API_KEY=<generate-with-cscli-or-set-before-first-boot>
The BOUNCER_KEY_CADDY env var in the CrowdSec container pre-registers the bouncer key at startup. Set the same value in .env as CROWDSEC_API_KEY and both sides will be in sync on first boot — no need to run cscli bouncers add manually.
Updated Caddyfile Additions
Add a global block at the top of the Caddyfile and a new crowdsec snippet. All other existing content remains unchanged.
# ─────────────────────────────────────────────────────────────────────────────
# GLOBAL BLOCK — add this at the very top before any snippets
# ─────────────────────────────────────────────────────────────────────────────
{
crowdsec {
api_url http://crowdsec:8080
api_key {$CROWDSEC_API_KEY}
}
log {
output file /var/log/caddy/access.log {
roll_size 50mb
roll_keep 5
}
format json
}
}
# ─────────────────────────────────────────────────────────────────────────────
# CROWDSEC SNIPPET — add alongside existing auth snippets
# ─────────────────────────────────────────────────────────────────────────────
(crowdsec) {
route {
crowdsec
}
}
Applying CrowdSec to Existing Services
Once the snippet exists, add import crowdsec to site blocks and container labels. This is a gradual rollout — services without it remain fully functional, just without Caddy-level CrowdSec inspection (they still have OPNsense perimeter protection).
In the Caddyfile:
# Before
cloud.netgrimoire.com {
reverse_proxy http://nextcloud-aio-apache:11000
}
# After
cloud.netgrimoire.com {
import crowdsec
reverse_proxy http://nextcloud-aio-apache:11000
}
# With auth
dozzle.netgrimoire.com {
import crowdsec
import authentik
reverse_proxy http://192.168.4.72:8043
}
In Docker labels:
labels:
- caddy=homepage.netgrimoire.com
- caddy.import=crowdsec
- caddy.import=authentik
- caddy.reverse_proxy={{upstreams 3000}}
CrowdSec Rollout Priority
Roll out import crowdsec in this order based on risk exposure:
High priority — do first (public, no auth):
cloud.netgrimoire.com(Nextcloud)immich.netgrimoire.comdocker.netgrimoire.com(Portainer)fish.pncharris.comwww.wasted-bandwidth.net
Medium priority — high value behind auth:
log.netgrimoire.com(Graylog)win.netgrimoire.com(Proxmox)- All
dozzle,dns,webtop,jackett,transmission,scrutiny
Lower priority — already protected by Authelia/Authentik:
stash.wasted-bandwidth.netnamer.wasted-bandwidth.net- All label-defined services behind auth
Skip:
- Mailcow block — handled by nginx-mailcow, different threat model
Behavior if CrowdSec Container Goes Down
The bouncer is designed to fail open by default. If crowdsec is unreachable, Caddy continues serving traffic normally — enforcement is temporarily suspended but the site stays up. This is the safe default for a homelab. To change this behavior, set enable_hard_fails true in the global crowdsec block (will cause 500 errors if CrowdSec is down — not recommended for homelab).
Bootstrap Steps
When ready to migrate to the new image:
Step 1 — Add the CrowdSec global block and snippet to the Caddyfile before changing the image. This ensures the Caddyfile is valid for the new image on startup.
Step 2 — Create ./crowdsec/acquis.yaml with the content above.
Step 3 — Create .env with a strong random value for CROWDSEC_API_KEY:
openssl rand -hex 32
Step 4 — Update the image and add the CrowdSec service to the compose file, then redeploy:
docker stack deploy -c docker-compose.yml caddy
Step 5 — Verify CrowdSec is reading Caddy logs:
docker exec <crowdsec_container> cscli metrics
Look for the Acquisition Metrics table showing hits from /var/log/caddy/access.log.
Step 6 — Test a ban manually:
docker exec <crowdsec_container> cscli decisions add --ip 1.2.3.4 --duration 5m
# Verify the IP gets a 403 from Caddy
curl -I https://yoursite.com --resolve yoursite.com:443:1.2.3.4
docker exec <crowdsec_container> cscli decisions delete --ip 1.2.3.4
Step 7 — Gradually add import crowdsec to site blocks and labels per the priority order above.
File Layout
/export/Docker/caddy/
├── Caddyfile # Shared snippets and static site blocks
├── docker-compose.yml # Caddy + CrowdSec services
├── .env # CROWDSEC_API_KEY (future)
├── data/ # Caddy data volume (TLS certs, etc.)
├── logs/ # caddy-logs volume mount point (future)
└── crowdsec/
└── acquis.yaml # Tells CrowdSec where to read Caddy logs (future)
Known Issues / Notes
- Port 80 is mapped to host port 8900 — this is intentional for Swarm. OPNsense NAT handles the external 80→8900 translation.
- The
CADDY_DOCKER_EVENT_THROTTLE_INTERVAL=2000setting is required with the CrowdSec module to prevent non-deterministic domain matching behavior during container label reloads (see issue #61). - Jellyfin is commented out in the Caddyfile — likely served via a different path or disabled temporarily.
- The
webupstream referenced byfish.pncharris.comandwww.wasted-bandwidth.netresolves to a container namedwebon thenetgrimoirenetwork. - Authelia redirect URL is
https://login.wasted-bandwidth.net/— update if this changes. - The serfriz image is rebuilt on the 1st of each month for module updates, and on every new Caddy release. Force a module update by recreating the container:
docker service update --force caddy_caddy.