--- title: 'Security Stack: Caddy + CrowdSec' url: https://blog.guigpap.com/en/infrastructure/security-stack/ url_md: https://blog.guigpap.com/en/infrastructure/security-stack.md category: infrastructure date: '2026-01-20' maturite: production techno: - caddy - crowdsec - docker application: - infrastructure - operations --- # Security Stack: Caddy + CrowdSec > Reverse proxy with automatic TLS and a collaborative WAF protecting the infrastructure ## 1. What? — Definition and context The **Security Stack** is the protective layer that sits between the Internet and every service in the infrastructure. It combines two components: | Component | Role | Port | |-----------|------|------| | **Caddy** | Reverse proxy with automatic TLS and HTTP/3 | 80, 443, 443/udp | | **CrowdSec** | Behavioural WAF and collaborative threat intelligence | 8080 (LAPI), 7422 (AppSec) | | **Trivy** | CVE scanner for Docker images, called via `docker exec` | no exposed port | > **Note - Reverse proxy** > > A **reverse proxy** is a server that receives all external traffic and dispatches it to internal services. It provides a single secure entry point with TLS and exposes several services on the same server. ### Architecture diagram ```mermaid flowchart TD Internet([Internet]) subgraph Caddy["Caddy · security-stack"] direction TB C1["TLS termination · Let's Encrypt, auto-renew"] C2["HTTP/3 QUIC support"] C3["Security headers · HSTS, CSP, X-Frame-Options"] C4["CrowdSec bouncer module"] end subgraph CS["CrowdSec engine"] direction TB S1["Parse Caddy access logs"] S2["Detect attacks · scenarios"] S3["Apply decisions · ban, captcha"] S4["Share threat intel · CAPI"] end subgraph Internal["Internal services · webproxy"] direction LR N["N8N"] G["Grafana"] O["Odoo"] NT["ntfy"] B["Blog"] end Internet -->|"ports 80, 443, 443/udp"| Caddy Caddy --> CS CS --> Internal ``` --- ## 2. Why? — Stakes and motivations ### Why Caddy rather than Nginx or Traefik? | Criterion | Caddy | Nginx | Traefik | |-----------|-------|-------|---------| | **Automatic TLS** | Native (Let's Encrypt) | External Certbot | Native | | **HTTP/3 QUIC** | Native | Experimental module | No | | **Configuration** | Simple Caddyfile | Verbose nginx.conf | YAML/labels | | **Reload** | Zero-downtime | Graceful reload | Zero-downtime | | **CrowdSec** | Official bouncer module | Lua module | Third-party plugin | **Decision drivers:** 1. **Simplicity** — Caddyfile syntax is short and readable 2. **Zero-config TLS** — Let's Encrypt certificates handled automatically, no Certbot cron 3. **Native CrowdSec integration** — Official bouncer module, no workaround ### What does CrowdSec bring? > **Tip - Behavioural WAF** > > A **WAF** (Web Application Firewall) inspects HTTP requests to detect and block attacks: SQL injection, XSS, vulnerability scans, brute force... CrowdSec is "behavioural": it learns from attack patterns. | Function | Benefit | |----------|---------| | **Collaborative threat intelligence** | Malicious IPs detected by other CrowdSec instances are shared | | **Detection scenarios** | Ready-made rules for HTTP, SSH, well-known CVEs | | **Real-time bouncer** | Banned IPs are blocked before reaching the backend | | **Automatic whitelists** | Legitimate bots (Googlebot, etc.) are not blocked | --- ## 3. How? — Technical implementation ### Caddyfile configuration ```caddyfile { # Global options email admin@guigpap.com # CrowdSec module crowdsec { api_url http://crowdsec:8080 api_key {env.CROWDSEC_API_KEY} ticker_interval 15s } } # Redirect www → apex www.guigpap.com { redir https://guigpap.com{uri} permanent } # Main site guigpap.com { crowdsec header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" } reverse_proxy blog:4321 } # N8N with protected webhooks n8n.guigpap.com { crowdsec # Block internal webhooks from the public Internet @blocked_webhooks { path /webhook/notify/* path /webhook/prometheus/* path /webhook/claude/* path /webhook/claude-simple-chat path /webhook/docker-controller path /webhook/odoo/* } respond @blocked_webhooks 403 reverse_proxy n8n:5678 } # Other services grafana.guigpap.com { crowdsec reverse_proxy grafana:3000 } odoo.guigpap.com { crowdsec reverse_proxy odoo:8069 } ``` ### Installed CrowdSec collections ```bash # List active collections docker exec crowdsec cscli collections list ``` | Collection | Role | |------------|------| | `crowdsecurity/caddy` | Caddy log parser | | `crowdsecurity/base-http-scenarios` | Common HTTP attack detection | | `crowdsecurity/http-cve` | Known CVE exploits | | `crowdsecurity/appsec-virtual-patching` | Virtual patching | | `crowdsecurity/linux` | Linux system scenarios | | `crowdsecurity/sshd` | SSH brute-force detection | | `crowdsecurity/whitelist-good-actors` | Whitelist for legitimate bots | ### How the bouncer works 1. Caddy receives a request 2. The bouncer module queries the CrowdSec API (local cache) 3. If the IP appears in the decision list → 403 Forbidden 4. Otherwise → request forwarded to the backend > **Tip - Latency** > > The bouncer caches decisions locally. Querying the CrowdSec API only adds a few milliseconds, and only for new IPs. ### Internal webhook protection | Webhook | Called by | Blocked from Internet | |---------|-----------|-----------------------| | `/webhook/notify/*` | DIUN | Yes | | `/webhook/prometheus/*` | Alertmanager | Yes | | `/webhook/claude/*` | CLI Ollama | Yes | | `/webhook/claude-simple-chat`, `/webhook/claude-approve`, `/webhook/claude-reject` | N8N | Yes | | `/webhook/odoo/*` | Odoo internal | Yes | | `/webhook/docker-controller` | Telegram Orchestrator | Yes | | `/webhook/notification-hub` | N8N (Execute Workflow) | Yes | Internal services call these webhooks via `http://n8n:5678` (Docker network), not the public domain. ### Rate limiting and AppSec WAF The webhook routes on `n8n.guigpap.com` are rate-limited to **30 requests/minute per IP** through the `caddy-ratelimit` module. This complements the auth (Bearer / Header / HMAC) by preventing brute-force on the exposed endpoints. CrowdSec AppSec listens in parallel on port 7422 and replays the `crowdsecurity/appsec-virtual-patching` + `appsec-generic-rules` rules before requests reach the backends. ### AI crawlers and `llms.txt` The blog (`blog.guigpap.com`) deliberately serves two artefacts for AI crawlers (ChatGPT, Gemini, Perplexity, etc.): 1. **`/llms.txt`** and **`/llms-full.txt`** — machine-readable index of the blog articles, advertised via the `Link` header: ```caddyfile header Link "; rel=\"llms-txt\", ; rel=\"llms-full-txt\"" header X-Llms-Txt "/llms.txt" ``` 2. **Markdown mirrors** — every article has a `.md` twin that returns the source version. Caddy forces `Content-Type: text/plain` instead of `text/markdown` because mainstream crawler parsers accept plain text but often ignore `text/markdown`: ```caddyfile @markdown_mirror path_regexp md_ext \.md$ header @markdown_mirror { Content-Type "text/plain; charset=utf-8" defer } ``` The blog's CSP allows `'wasm-unsafe-eval'` (for Pagefind, the static search engine) and `data:` URIs in `font-src` (for the embedded fontsource subsets): ```caddyfile header Content-Security-Policy "default-src 'self'; \ script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; \ style-src 'self' 'unsafe-inline'; \ img-src 'self' data:; \ font-src 'self' data:" ``` ### CrowdSec: Debian image + SSH detection The CrowdSec engine uses the `latest-debian` image (not Alpine) because the Debian 13 VPS exposes its SSH logs only via journald — there is no `/var/log/auth.log`. The Debian image ships `journalctl`; the Alpine image does not. The container mounts `/var/log/journal`, `/run/log/journal` and `/etc/machine-id` from the host. | Layer | Role | Enforcement | |-------|------|-------------| | CrowdSec + Caddy bouncer | HTTP/HTTPS | Block via Caddy (403) | | CrowdSec SSH detection | Brute-force SSH | Detection only (no host bouncer) | | **fail2ban** | SSH enforcement | Ban via nftables (`inet f2b-table`) | | UFW | Baseline firewall | Ports 22, 80, 443 only | > **Caution - SSH not blocked by CrowdSec** > > CrowdSec detects SSH brute-force scenarios (incl. CVE-2024-6387) but cannot block at the network layer — there is no host firewall bouncer. **fail2ban** is the only service that actually blocks SSH attackers on this VPS. ### Operational commands ```bash # CrowdSec management docker exec crowdsec cscli decisions list # Banned IPs docker exec crowdsec cscli decisions delete --ip 1.2.3.4 # Unban docker exec crowdsec cscli alerts list # Recent alerts docker exec crowdsec cscli metrics # Statistics # Caddy management docker exec caddy caddy validate --config /etc/caddy/Caddyfile docker exec caddy caddy reload --config /etc/caddy/Caddyfile docker logs caddy --tail 100 # Trivy: scan an image docker exec trivy trivy image --format json \ --severity CRITICAL,HIGH --ignore-unfixed postgres:17 ``` --- ## 4. What if? — Outlook and limits ### Current operational feedback > **Note - Recent deployment** > > CrowdSec was installed recently on this infrastructure. There is not yet enough hindsight to quantify blocked attacks or surface false-positive patterns. ### Current limits | Limit | Impact | Mitigation | |-------|--------|------------| | **No Caddy HA** | Single point of failure | Monitoring + auto restart | | **CrowdSec untested under load** | Behaviour during DDoS unknown | Upstream rate limiting possible | | **Possible false positives** | Legitimate IPs may be blocked | Plan for manual whitelisting | ### Evolution scenarios **If false positives appear**: - Create whitelists per IP or User-Agent - Tune scenario thresholds (`cscli scenarios inspect`) - Disable overly aggressive scenarios **If load grows**: - Enable Caddy streaming mode for large files - Add a cache (Varnish) in front of some services - Consider a CDN (Cloudflare) for static content **If a DDoS attack happens**: - CrowdSec detects but is not enough on its own - Activate Cloudflare's Under Attack mode - Contact the host for upstream mitigation ### Metrics to watch | Metric | Source | Attention threshold | |--------|--------|---------------------| | Banned IPs/day | `cscli decisions list` | > 100 = review scenarios | | HTTP alerts | `cscli alerts list` | Sudden spike = potential attack | | 403 responses | Caddy logs | Abnormal rate = possible false positives | --- ## Related pages ### Infrastructure - [VPS Architecture](/en/infrastructure/architecture-vps/) — Overview - [Monitoring Stack](/en/infrastructure/monitoring-stack/) — Caddy metrics ### Reference - [Glossary](/en/reference/glossary/) — WAF, bouncer, TLS, reverse proxy ## Metadonnees agent - Cet article est issu du blog GuiGPaP Lab. - Contexte global du blog: https://blog.guigpap.com/llms.txt - Contact auteur: https://odoo.guigpap.com/mon-cv - Licence: CC-BY-SA 4.0