---
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=<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`.
