Custom Caddy images with curated module sets, published to GHCR with a public Docker Hub mirror and rebuilt automatically when upstream components change.
This repository provides ready-to-use Caddy container images for self-hosted and infrastructure setups that need more than the official default image. Each image variant is built with a defined module set, tracked against upstream changes, and published with reproducible tags plus traceable metadata.
Registries:
- GHCR (canonical):
ghcr.io/smoochy/caddy-cloudflare-modules - Docker Hub (public mirror):
smoochy84/caddy-cloudflare-modules
If this project saves you time or helps your setup, you can support ongoing maintenance via Coindrop, Ko-fi, or Buy Me a Coffee.
- Background
- Available Images
- Cloudflare Image
- When Builds Run
- Dockerfile
- GitHub Actions Workflow
- Workflow Verification
- Job Summary
- Image Metadata
- Image Tags
- Install
- Usage
- Transparency
- Adding More Addons
- Adding a New Image Variant
- Security
- Maintainers
- Contributing
- License
The official Caddy image is intentionally minimal. This repository provides maintained custom image variants for setups that rely on additional modules and want a reproducible way to stay current with upstream changes.
| Image | Dockerfile | Workflow | Description |
|---|---|---|---|
caddy-cloudflare-modules |
Dockerfile-cloudflare |
build_cloudflare-modules.yaml |
Cloudflare DNS and IP modules |
Additional variants can be added at any time. See Adding a New Image Variant.
| Addon | Purpose |
|---|---|
caddy-dns/cloudflare |
DNS-01 ACME challenge provider for Cloudflare |
WeidiDeng/caddy-cloudflare-ip |
Provides the real client IP when behind Cloudflare proxy |
fvbommel/caddy-combine-ip-ranges |
Combines multiple IP range sources for trusted proxies |
lucaslorentz/caddy-docker-proxy |
Automatic Caddy configuration via Docker labels |
- Builds the custom Docker image
caddy-cloudflare-modules - Publishes the image to:
- GHCR (canonical):
ghcr.io/smoochy/caddy-cloudflare-modules:latestghcr.io/smoochy/caddy-cloudflare-modules:caddy-<x.y.z>ghcr.io/smoochy/caddy-cloudflare-modules:caddy-v<x.y.z>
- Docker Hub (public mirror):
smoochy84/caddy-cloudflare-modules:latestsmoochy84/caddy-cloudflare-modules:caddy-<x.y.z>smoochy84/caddy-cloudflare-modules:caddy-v<x.y.z>
- GHCR (canonical):
- Tracks upstream updates and rebuilds only when needed
The workflow is triggered in three ways:
-
Push to
main, but only when one of these files changes:Dockerfile*.dockerignore.github/workflows/build_*.yaml
This prevents rebuilds for documentation-only changes such as
README.md. -
Scheduled run:
- Runs a daily check at 03:00 UTC for upstream changes such as the Caddy base digest and addon versions
- Builds only if something changed
-
Manual run with
workflow_dispatch:- Optional
force=trueinput to rebuild even if nothing changed
- Optional
The Dockerfile:
- Uses a two-stage build
- Uses
caddy:builderandxcaddyto compile Caddy with addons - Uses
caddy:latestas the final base image - Supports multi-arch builds for:
linux/amd64linux/arm64
- Declares addons via
--witharguments
Commented-out --with lines are ignored by both the build and the workflow.
build_*.yaml does the following:
- Logs into GHCR and sets up Buildx plus QEMU.
- Parses the Dockerfile to discover active addons from
--withlines. - Fetches upstream versions using authenticated GitHub API calls.
- Reads metadata from the currently published image when it exists.
- Compares the Caddy base digest and addon versions.
- Builds once, pushes to GHCR, and mirrors the published tags to Docker Hub when Docker Hub secrets are configured.
- Publishes only when:
- a push-triggered run happens
- an upstream change is detected
- a manual run is forced
This section states what is checked when a change to build_cloudflare-modules.yaml runs, and what is not checked before merge.
A pull request build runs the workflow with push: false, so the image is built but never published to GHCR or Docker Hub. This exercises the Dockerfile and the addon install steps without any write to a registry.
On a pull request, when do_build is true, the Assert produced tag set step reads the tag list docker/metadata-action produced and fails the job unless that set matches exactly what is expected for the caddy_tag value the decide step emitted. The comparison is by set membership plus a count, so it does not depend on order or formatting. This step never runs on push, schedule, or workflow_dispatch, so it can never fail a publishing build. When do_build is false, both the meta step and this assertion step are skipped.
A change to this workflow is expected to pass these static checks locally before review:
- Parse the whole workflow file with a YAML parser and confirm it succeeds.
- Extract the body of every
run:block you changed to a temporary file and runshellcheckon it. - Copy the shell logic you changed to a temporary file, replace the workflow expressions with shell variables, and execute it against every value the producing step can emit. For the tag logic that means both shapes of
caddy_tag: a three-part semver such as2.11.4, and the literal stringlatest.
These local checks are a fast pre-review pass, not the only gate: the Lint workflows GitHub Actions workflow runs actionlint in CI on any pull request that touches .github/workflows/**, and again on every push to main. actionlint covers unknown context properties, invalid expressions, and undefined step outputs across the whole workflow file, and it also runs shellcheck over every run: block, so the shellcheck pass a contributor ran locally is checked again automatically before merge.
The workflow also runs on the schedule described in "When Builds Run", and on a push to main that touches Dockerfile-cloudflare, .dockerignore, or the workflow file itself. Neither run publishes on its own. The decide step still has to judge a build necessary: a push forces one only when it changed Dockerfile-cloudflare or .dockerignore, a scheduled run never forces one, and either kind of run still builds and publishes when it detects an upstream Caddy or addon change. Only once decide outputs do_build: true on a push, schedule, or workflow_dispatch event does the real multi-arch push to GHCR happen, and the crane copy mirror to Docker Hub runs on top of that only when Docker Hub secrets are configured.
What is not verified before a merge: a real multi-arch push, a real crane copy to Docker Hub, and a real write to either registry. A pull request never publishes, so those three paths can only happen after the merge, and only on a later push or scheduled run where decide judges a build necessary.
act was considered for local verification of this workflow and rejected. The decide step calls crane against caddy:latest and the GitHub API for release metadata, so a local act run is not hermetic. act also cannot reproduce the multi-arch docker/build-push-action push or the crane copy mirror, which is exactly the part no static check covers, so it would not close that gap. What a local act run would actually prove is that the YAML parses and the shell branches execute, and shellcheck plus the pull request tag assertion already prove that more cheaply and in the place that gates a merge. Against that, act costs a Docker-in-Docker setup in a repository that has no other local toolchain.
Every workflow run writes a summary that includes:
- The reason the build ran
- Which upstream component changed
- The Caddy base digest and version
- All addon versions with direct links
- Changelogs from upstream release notes where available
- The published image tags
- Whether the Docker Hub mirror was updated or skipped
Each published image includes OCI labels used for traceability and change detection, for example:
org.opencontainers.image.base.tagorg.opencontainers.image.base.digestorg.opencontainers.image.base.versionorg.opencontainers.image.cloudflare.versionorg.opencontainers.image.addon.N.nameorg.opencontainers.image.addon.N.version
This image is published with:
latest: always points to the newest buildcaddy-<x.y.z>: matches the Caddy base version used at build time and is useful for reproducible deployments pinned to a specific Caddy releasecaddy-v<x.y.z>: the same version, matching the upstream Caddy release tag name (for examplev2.11.4), so pinning by it is synchronous with upstream
Pull the published image from GHCR (canonical) or Docker Hub (public mirror):
docker pull ghcr.io/smoochy/caddy-cloudflare-modules:latestdocker pull smoochy84/caddy-cloudflare-modules:latestFor reproducible deployments, pin both a version tag and digest, using either version tag shape:
ghcr.io/smoochy/caddy-cloudflare-modules:caddy-<x.y.z>@sha256:<digest>
ghcr.io/smoochy/caddy-cloudflare-modules:caddy-v<x.y.z>@sha256:<digest>
The digest of every published image is visible in the GitHub Actions Job Summary and on the GHCR package page. The same version tags are mirrored to Docker Hub at smoochy84/caddy-cloudflare-modules.
Example docker-compose.yml:
services:
caddy:
image: ghcr.io/smoochy/caddy-cloudflare-modules:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
volumes:
caddy_data:
caddy_config:Example Caddyfile:
{
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
respond "Hello from Caddy with Cloudflare addons!"
}The code, documentation, and related project materials in this repository were created and refined with AI assistance. All generated output was reviewed and adapted before publication.
To include another Caddy module, add a --with line to Dockerfile-cloudflare:
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/WeidiDeng/caddy-cloudflare-ip \
--with github.com/fvbommel/caddy-combine-ip-ranges \
--with github.com/lucaslorentz/caddy-docker-proxy/v2 \
--with github.com/your-org/your-caddy-addonThe workflow automatically picks up new --with lines and:
- Fetches the latest version
- Includes it in the Job Summary and OCI labels
- Tracks it for future upstream change detection
Each image variant is self-contained and consists of exactly two files:
| File | Purpose |
|---|---|
Dockerfile-<name> |
Defines the modules compiled into the image |
.github/workflows/build_<name>.yaml |
Builds, tags, and publishes the image |
To add a new variant:
- Copy
Dockerfile-cloudflaretoDockerfile-<name>and adjust the--withlines. - Copy
.github/workflows/build_cloudflare-modules.yamlto.github/workflows/build_<name>.yaml. - Update the hardcoded image name in the new workflow.
- Update the
file:reference fromDockerfile-cloudflaretoDockerfile-<name>. - Add the new image to the table above.
- No secrets are baked into the image
- GitHub Actions uses the built-in
GITHUB_TOKENfor GHCR authentication and GitHub API calls - Optional Docker Hub publishing uses
DOCKERHUB_USERNAMEandDOCKERHUB_TOKENrepository secrets - Only release metadata is queried from upstream projects
- smoochy
Issues and pull requests are welcome. Keep image, workflow, and documentation changes aligned so the published image behavior stays obvious from the README.