docs: create Netgrimoire/Network/Security/Caddy
This commit is contained in:
parent
fc05032a7c
commit
7a2129a851
1 changed files with 522 additions and 0 deletions
522
Netgrimoire/Network/Security/Caddy.md
Normal file
522
Netgrimoire/Network/Security/Caddy.md
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
---
|
||||
title: Caddy Reverse Proxy
|
||||
description: Curreent and future config
|
||||
published: true
|
||||
date: 2026-02-23T22:09:16.106Z
|
||||
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=${CROWDSEC_API_KEY}
|
||||
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: ${CROWDSEC_API_KEY} # 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=<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.
|
||||
|
||||
```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 <crowdsec_container> 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 <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=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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue