--- title: Caddy Reverse Proxy description: Curreent and future config published: true date: 2026-02-25T01:50:20.558Z tags: editor: markdown dateCreated: 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 ```yaml 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`) ```yaml 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`. ```caddyfile # ───────────────────────────────────────────────────────────────────────────── # 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`: ```yaml labels: - caddy=homepage.netgrimoire.com - caddy.import=authentik - caddy.reverse_proxy={{upstreams 3000}} ``` For services that need no auth: ```yaml 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 ```yaml 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 current - `caddy-crowdsec-bouncer` — inline HTTP blocking based on CrowdSec decisions - `caddy-geoip` — GeoIP filtering at the application layer - `caddy-ratelimit` — per-endpoint rate limiting - `caddy-security` — additional auth/security middleware ### Updated Compose ```yaml 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`) ```yaml filenames: - /var/log/caddy/access.log labels: type: caddy ``` ### Environment File (`.env`) ```env CROWDSEC_API_KEY= ``` 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. ```caddyfile # ───────────────────────────────────────────────────────────────────────────── # 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:** ```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:** ```yaml 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.com` - `docker.netgrimoire.com` (Portainer) - `fish.pncharris.com` - `www.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.net` - `namer.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`: ```bash openssl rand -hex 32 ``` **Step 4 — Update the image and add the CrowdSec service to the compose file**, then redeploy: ```bash docker stack deploy -c docker-compose.yml caddy ``` **Step 5 — Verify CrowdSec is reading Caddy logs:** ```bash docker exec cscli metrics ``` Look for the `Acquisition Metrics` table showing hits from `/var/log/caddy/access.log`. **Step 6 — Test a ban manually:** ```bash docker exec 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 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=2000` setting is **required** with the CrowdSec module to prevent non-deterministic domain matching behavior during container label reloads (see [issue #61](https://github.com/hslatman/caddy-crowdsec-bouncer/issues/61)). - Jellyfin is commented out in the Caddyfile — likely served via a different path or disabled temporarily. - The `web` upstream referenced by `fish.pncharris.com` and `www.wasted-bandwidth.net` resolves to a container named `web` on the `netgrimoire` network. - 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`.