Skip to content

Security Stack: Caddy + CrowdSec

The Security Stack is the protective layer that sits between the Internet and every service in the infrastructure. It combines two components:

ComponentRolePort
CaddyReverse proxy with automatic TLS and HTTP/380, 443, 443/udp
CrowdSecBehavioural WAF and collaborative threat intelligence8080 (LAPI), 7422 (AppSec)
TrivyCVE scanner for Docker images, called via docker execno exposed port

ports 80, 443, 443/udp

Internal services · webproxy

N8N

Grafana

Odoo

ntfy

Blog

CrowdSec engine

Parse Caddy access logs

Detect attacks · scenarios

Apply decisions · ban, captcha

Share threat intel · CAPI

Caddy · security-stack

TLS termination · Let's Encrypt, auto-renew

HTTP/3 QUIC support

Security headers · HSTS, CSP, X-Frame-Options

CrowdSec bouncer module

Internet


CriterionCaddyNginxTraefik
Automatic TLSNative (Let’s Encrypt)External CertbotNative
HTTP/3 QUICNativeExperimental moduleNo
ConfigurationSimple CaddyfileVerbose nginx.confYAML/labels
ReloadZero-downtimeGraceful reloadZero-downtime
CrowdSecOfficial bouncer moduleLua moduleThird-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
FunctionBenefit
Collaborative threat intelligenceMalicious IPs detected by other CrowdSec instances are shared
Detection scenariosReady-made rules for HTTP, SSH, well-known CVEs
Real-time bouncerBanned IPs are blocked before reaching the backend
Automatic whitelistsLegitimate bots (Googlebot, etc.) are not blocked

{
# 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
}
Fenêtre de terminal
# List active collections
docker exec crowdsec cscli collections list
CollectionRole
crowdsecurity/caddyCaddy log parser
crowdsecurity/base-http-scenariosCommon HTTP attack detection
crowdsecurity/http-cveKnown CVE exploits
crowdsecurity/appsec-virtual-patchingVirtual patching
crowdsecurity/linuxLinux system scenarios
crowdsecurity/sshdSSH brute-force detection
crowdsecurity/whitelist-good-actorsWhitelist for legitimate bots
  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
WebhookCalled byBlocked from Internet
/webhook/notify/*DIUNYes
/webhook/prometheus/*AlertmanagerYes
/webhook/claude/*CLI OllamaYes
/webhook/claude-simple-chat, /webhook/claude-approve, /webhook/claude-rejectN8NYes
/webhook/odoo/*Odoo internalYes
/webhook/docker-controllerTelegram OrchestratorYes
/webhook/notification-hubN8N (Execute Workflow)Yes

Internal services call these webhooks via http://n8n:5678 (Docker network), not the public domain.

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.

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:

    header Link "</llms.txt>; rel=\"llms-txt\", </llms-full.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:

    @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):

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:"

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.

LayerRoleEnforcement
CrowdSec + Caddy bouncerHTTP/HTTPSBlock via Caddy (403)
CrowdSec SSH detectionBrute-force SSHDetection only (no host bouncer)
fail2banSSH enforcementBan via nftables (inet f2b-table)
UFWBaseline firewallPorts 22, 80, 443 only
Fenêtre de terminal
# 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

LimitImpactMitigation
No Caddy HASingle point of failureMonitoring + auto restart
CrowdSec untested under loadBehaviour during DDoS unknownUpstream rate limiting possible
Possible false positivesLegitimate IPs may be blockedPlan for manual whitelisting

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
MetricSourceAttention threshold
Banned IPs/daycscli decisions list> 100 = review scenarios
HTTP alertscscli alerts listSudden spike = potential attack
403 responsesCaddy logsAbnormal rate = possible false positives

  • Glossary — WAF, bouncer, TLS, reverse proxy