Netgrimoire/Keystone-Grimoire/Docker/Caddy.md
2026-04-12 09:53:51 -05:00

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

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

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=2000 setting 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 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.