An opinionated Caddy image for Docker Compose homelab edge stacks. It is built for Docker label-driven Caddy config, Cloudflare DNS-01 wildcard certificates, Cloudflare client IP handling, CrowdSec/appsec enforcement, optional Cloudflare Access JWT checks, and a hardened non-root distroless runtime.
This repository assumes working familiarity with Docker Compose, DNS, reverse proxies, and the Docker socket security tradeoff. It is written for operators who want a practical edge-stack pattern rather than a beginner Caddy tutorial.
For Docker Compose, start from the canonical example in this repository, create .env using the variables shown in Compose Pattern, then start the stack:
git clone https://github.com/sholdee/caddy-proxy-cloudflare.git
cd caddy-proxy-cloudflare
${EDITOR:-vi} .env
docker compose up -dFor a host binary install or update on Linux:
curl -fsSL https://cpcf.shold.io | bashThe image is built from the repository Dockerfile with:
- Caddy
github.com/lucaslorentz/caddy-docker-proxy/v2github.com/caddy-dns/cloudflaregithub.com/WeidiDeng/caddy-cloudflare-ipgithub.com/mholt/caddy-l4github.com/hslatman/caddy-crowdsec-bouncer/httpgithub.com/hslatman/caddy-crowdsec-bouncer/appsecgithub.com/hslatman/caddy-crowdsec-bouncer/layer4github.com/ggicci/caddy-jwtgithub.com/zhangjiayin/caddy-geoip2
The final image runs as nonroot:nonroot on a pinned distroless base image and includes a small healthcheck binary.
GHCR is the primary registry:
ghcr.io/sholdee/caddy-proxy-cloudflare
Docker Hub is published as a compatibility fallback:
docker.io/sholdee/caddy-proxy-cloudflare
Recommended reference styles:
# Production: pin a release tag and digest.
ghcr.io/sholdee/caddy-proxy-cloudflare:vYYYY.MDD.HMMSS@sha256:<digest>
# Normal updates: use the release tag.
ghcr.io/sholdee/caddy-proxy-cloudflare:vYYYY.MDD.HMMSS
# Quick tests only.
ghcr.io/sholdee/caddy-proxy-cloudflare:latest
Docker is the primary deployment target, but releases also include Linux amd64 and arm64 Caddy binaries for host installs.
Quick install/update:
curl -fsSL https://cpcf.shold.io | bashNon-interactive install with flags:
curl -fsSL https://cpcf.shold.io | bash -s -- --yes --install-service --write-default-caddyfile --startReview-first install:
curl -fsSL https://cpcf.shold.io -o install-caddy-proxy-cloudflare.sh
chmod +x install-caddy-proxy-cloudflare.sh
./install-caddy-proxy-cloudflare.shThe script detects the host architecture, verifies the binary checksum, optionally verifies the checksum Sigstore bundle with cosign, summarizes the planned changes, backs up the existing caddy binary when present, and restarts an active caddy.service.
Useful options:
./install-caddy-proxy-cloudflare.sh --version vYYYY.MDD.HMMSS
./install-caddy-proxy-cloudflare.sh --yes
./install-caddy-proxy-cloudflare.sh --require-cosign
./install-caddy-proxy-cloudflare.sh list-backups
./install-caddy-proxy-cloudflare.sh restoreNightly unattended update check after placing the script somewhere stable:
17 3 * * * /usr/local/bin/install-caddy-proxy-cloudflare.sh --yes --if-outdated --wait-idle >> /var/log/caddy-proxy-cloudflare-update.log 2>&1--if-outdated compares the installed binary checksum with the selected release asset before downloading the binary. --wait-idle watches established TCP connections on ports 80,443 and updates once none have sent or received data for the quiet window, which defaults to 2m. If recently active connections remain for the timeout window, the run exits successfully and defers to the next schedule.
For systemd hosts without an existing Caddy service, --install-service creates a Caddyfile-based caddy.service following Caddy's Linux service guidance. It does not overwrite an existing service unless --force-service is also set.
For a plain Caddyfile starting point, examples/Caddyfile.compose-equivalent mirrors the main features from the canonical Compose labels in direct Caddyfile syntax.
The canonical example is docker-compose.yml. It uses a small edge stack:
caddy: the Docker-label Caddy runtimecaddy-config: a no-op label carrier for global Caddy configcrowdsec: optional CrowdSec local API and appsec servicewhoami: a tiny demo upstreamdocker-socket-proxy: a narrow Docker API proxy for Caddy and CrowdSec
The caddy-config container is intentional. It lets caddy-docker-proxy watch label changes and hot-reload generated Caddy config without recreating the actual Caddy runtime container. In practice, this keeps the edge proxy stable while still making label-driven config edits cheap.
Keeping reverse-proxy configuration in Compose labels also makes the edge config GitOps-friendly: route changes can move through the same reviewed Compose workflow as the services they expose.
The example does not mount the raw Docker socket into caddy or crowdsec. Those containers use DOCKER_HOST=tcp://docker-socket-proxy:2375, and only docker-socket-proxy mounts /var/run/docker.sock.
Create a local .env for the example:
DOMAIN=example.com
EMAIL_ADDR=admin@example.com
CF_TOKEN=replace-with-cloudflare-api-token
CROWDSEC_API_KEY=replace-with-shared-bouncer-key
DOCKER_GID=123
TZ=America/ChicagoSet DOCKER_GID to the group ID that owns /var/run/docker.sock on the host:
getent group dockerThe socket group is only added to docker-socket-proxy; Caddy and CrowdSec do not receive the raw socket mount.
The example exposes HTTP, HTTPS, and HTTP/3:
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"The example uses wollomatic/socket-proxy as a blast-radius reduction layer between the edge stack and Docker. It keeps the raw Docker socket out of Caddy and CrowdSec while still allowing the Docker reads they need for label discovery, event watching, network discovery, and Docker log acquisition.
The proxy is attached only to the internal edge network and has no host port mapping. Its Docker API surface is intentionally narrow:
command:
- "-listenip=0.0.0.0"
- "-allowfrom=caddy,crowdsec"
- "-allowHEAD=^(/v[0-9.]+)?/_ping$"
- "-allowGET=^(/v[0-9.]+)?/(info|version|containers/json|containers/[^/]+/json|containers/[^/]+/logs|networks/[^/]+|events)(\\?.*)?$"This allows required read paths such as container list/inspect/logs, Docker events, Docker info/version, and network reads. It blocks mutating Docker API calls and leaves broad sections such as images, volumes, exec, services, tasks, swarm, secrets, build, and auth disabled.
Treat socket proxy image updates as manual-review changes. The repository Renovate config detects the image but excludes it from automerge because restarting or changing the proxy can interrupt Docker API access for running Caddy and CrowdSec containers.
The example uses Cloudflare DNS-01 validation:
labels:
caddy.acme_dns: "cloudflare ${CF_TOKEN}"Use a scoped Cloudflare API token that can edit DNS records for the zone. The Cloudflare DNS module documents the token requirements in libdns/cloudflare.
The canonical compose file uses a static Cloudflare proxy CIDR list:
caddy.servers.trusted_proxies: "static 173.245.48.0/20 ..."
caddy.servers.client_ip_headers: "Cf-Connecting-Ip"Static CIDRs make the edge behavior predictable. The bundled Cloudflare IP module also supports dynamic Cloudflare proxy discovery with caddy.servers.trusted_proxies: "cloudflare" if you prefer runtime refreshes.
CrowdSec is first-class in the example, but optional. If you do not use CrowdSec, remove:
- the
crowdsecservice caddy.crowdsec.*labels fromcaddy-configcrowdsecandappsecroute labels from upstream services- the
acquis.yamlmount
The example keeps the CrowdSec streaming bouncer enabled and sets:
environment:
- CADDY_DOCKER_EVENT_THROTTLE_INTERVAL=3sThat throttle prevents rapid Docker event bursts from causing repeated graceful reloads. In this stack, it is the practical workaround for reload bursts interacting poorly with the streaming bouncer/admin API path. If your environment still sees reload timeouts, increase the throttle or test the bouncer's polling mode.
acquis.yaml is the minimal CrowdSec Docker acquisition file used by the example. It tells CrowdSec to read Caddy logs through docker-socket-proxy and classify them as Caddy logs.
The image includes caddy-l4 and the CrowdSec Layer 4 matcher for advanced TCP/UDP edge routing, such as SNI-based TCP proxying or non-HTTP services that should still be checked against CrowdSec decisions.
The canonical Compose example stays HTTP-focused and does not expose Layer 4 listeners by default.
The image includes caddy-jwt, so a route can require Cloudflare Access JWTs. Keep this out of the global default unless every service behind that route should require Access.
The snippet below applies JWT auth only when the client IP is outside the listed internal LAN CIDRs. That pattern is useful for split-DNS deployments where local clients can reach the service directly while remote clients must pass through Cloudflare Access.
labels:
caddy: "app.${DOMAIN}"
caddy.@remote.not: "remote_ip 192.168.0.0/16 10.0.0.0/8"
caddy.route.1_jwtauth: "@remote"
caddy.route.1_jwtauth.jwk_url: "https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs"
caddy.route.1_jwtauth.from_header: "Cf-Access-Jwt-Assertion"
caddy.route.1_jwtauth.from_cookies: "CF_Authorization"
caddy.route.1_jwtauth.issuer_whitelist: "https://<team-name>.cloudflareaccess.com"
caddy.route.1_jwtauth.audience_whitelist: "<cloudflare-access-audience-id>"
caddy.route.2_reverse_proxy: "{{upstreams 8080}}"Caddy Docker Proxy labels can define named snippets and import them into routes. This is useful for repeated headers or upstream defaults.
labels:
caddy_0: "(upstream_defaults)"
caddy_0.header: "-Server"
caddy_0.encode: "zstd gzip"
caddy: "app.${DOMAIN}"
caddy.import: "upstream_defaults"
caddy.reverse_proxy: "{{upstreams 8080}}"Validate and start the example:
docker compose config
docker compose up -dCheck Caddy and CrowdSec:
docker logs caddy --tail=100
docker exec crowdsec cscli metrics
curl -I https://whoami.example.comIf Caddy is not issuing certificates, check the Cloudflare token scope, DNS zone, and caddy.acme_dns label first.
Resolve the digest for a release tag:
docker buildx imagetools inspect ghcr.io/sholdee/caddy-proxy-cloudflare:vYYYY.MDD.HMMSSCopy the top-level manifest digest into Compose:
image: ghcr.io/sholdee/caddy-proxy-cloudflare:vYYYY.MDD.HMMSS@sha256:<digest>If you run Renovate against your Compose repository, it can also maintain digest-pinned image references.
Release images are signed with keyless cosign. Production deployments should prefer digest-pinned references and can verify a published digest:
cosign verify ghcr.io/sholdee/caddy-proxy-cloudflare@sha256:<digest> \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/sholdee/caddy-proxy-cloudflare/.github/workflows/main.yml@refs/heads/mainThe release workflow also verifies the distroless runtime base image before publishing.
This repository is intentionally narrow:
- It is not a general Caddy module marketplace.
- It is not a replacement for learning Caddy, Cloudflare, Docker, or CrowdSec.
- It is not a complete homelab security model.
- It does not make Docker metadata harmless. Caddy and CrowdSec no longer receive the raw socket, but the socket proxy still exposes selected Docker read APIs. Keep labels, logs, and container metadata free of secrets, and run this pattern only on hosts where that tradeoff is acceptable.