Building Custom Caddy With Xcaddy: Plugin Selection to CI/CD Auto-Publish
Why You Need a Custom Caddy
The official Caddy binary and Docker images include only the most basic modules. Once you need any of the following:
- Cloudflare DNS challenge for wildcard certificates
- Rate limiting
- MaxMind GeoIP geolocation
- WebDAV file management
- Layer4 TCP/UDP forwarding
- Response content replacement
- Nginx config compatibility
- CGI support
You need to build a custom version with xcaddy.
What is xcaddy
xcaddy is the official Caddy build tool. It’s essentially a wrapper around the Go build process - Caddy’s module system is based on Go imports, and xcaddy handles downloading the specified Caddy source, injecting custom modules (via --with flags), and compiling the complete binary.
Plugin Selection
My custom Containerfile:
FROM docker.io/library/caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddyserver/nginx-adapter \
--with github.com/caddy-dns/cloudflare \
--with github.com/caddyserver/replace-response \
--with github.com/mholt/caddy-webdav \
--with github.com/mholt/caddy-ratelimit \
--with github.com/WeidiDeng/caddy-cloudflare-ip \
--with github.com/porech/caddy-maxmind-geolocation \
--with github.com/mholt/caddy-l4 \
--with github.com/aksdb/caddy-cgi/v2
FROM docker.io/library/caddy:latest
RUN apk update && apk add --no-cache git git-daemon cgit python3 py3-pygments py3-markdown py3-docutils groff curl dcron
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY update_geodb.sh /usr/local/bin/update_geodb.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& chmod +x /usr/local/bin/update_geodb.sh \
&& echo "0 0 */3 * * /usr/local/bin/update_geodb.sh > /proc/1/fd/1 2>/proc/1/fd/2" | crontab -
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]What each plugin does:
| Plugin | Purpose |
|---|---|
caddy-dns/cloudflare |
DNS-01 challenge via Cloudflare API for wildcard TLS certificates |
caddy-ratelimit |
Request rate limiting to prevent abuse |
caddy-maxmind-geolocation |
GeoIP-based access control using MaxMind databases |
caddy-l4 |
Layer4 TCP/UDP proxy for non-HTTP traffic |
caddy-webdav |
WebDAV file management on a subpath |
caddy-cloudflare-ip |
Fetch Cloudflare’s real IP ranges for trusted_proxies |
caddy-replace-response |
Modify response content (string replacement) |
nginx-adapter |
Nginx config migration compatibility |
caddy-cgi |
Run CGI scripts in Caddy |
Why Multi-Stage Build
The two stages have separate responsibilities:
- Builder stage: Based on
caddy:2-builder, which includes the full Go toolchain and Caddy source dependencies.xcaddy buildruns here, producing acaddybinary with all plugins. - Runtime stage: Based on official
caddy:latest(Alpine), keeping the image small. Extra packages (git,cgit,python3,groff,curl,dcron) are installed for running cgit (Git web interface) and scheduled GeoIP database updates.
The benefit: the builder image is 1GB+, but the final runtime image is only ~80MB and contains no build artifacts or source code.
What the Entrypoint Does
The startup flow is controlled by entrypoint.sh:
#!/bin/sh
# Run GeoLite2 database update first to ensure file availability before Caddy starts
/usr/local/bin/update_geodb.sh
# Start cron daemon
crond -b -L /var/log/cron.log
# Execute the original Caddy ENTRYPOINT
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfileOrder matters:
- Download GeoIP database (if missing or older than 3 days)
- Start crond as a background process (for scheduled GeoIP updates)
execreplaces the shell process with the Caddy main process, ensuring signals are delivered correctly
The exec is critical - without it, Caddy runs as a child process, and SIGTERM may not reach it when the container shuts down.
GeoIP Database Auto-Update
#!/bin/sh
GEODB_DIR="/config/geodb"
GEODB_FILE="$GEODB_DIR/GeoLite2-Country.mmdb"
GEODB_URL="https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-Country.mmdb"
INTERVAL_SECONDS=259200 # 3 * 24 * 60 * 60
mkdir -p "$GEODB_DIR"
if [ ! -f "$GEODB_FILE" ]; then
echo "GeoLite2-Country.mmdb not found, downloading..."
curl -sSL "$GEODB_URL" -o "$GEODB_FILE"
else
FILE_MOD_TIME=$(stat -c %Y "$GEODB_FILE")
CURRENT_TIME=$(date +%s)
AGE=$((CURRENT_TIME - FILE_MOD_TIME))
if [ "$AGE" -ge "$INTERVAL_SECONDS" ]; then
echo "GeoLite2-Country.mmdb downloading new version..."
curl -sSL "$GEODB_URL" -o "$GEODB_FILE"
else
echo "GeoLite2-Country.mmdb skip download."
fi
fiI use the P3TERX/GeoLite.mmdb mirror. MaxMind’s official free tier requires account registration and a License Key, while P3TERX syncs periodically and auto-publishes releases, eliminating that step.
Scheduled updates run via crond inside the container (configured in the Dockerfile):
0 0 */3 * * /usr/local/bin/update_geodb.sh > /proc/1/fd/1 2>/proc/1/fd/2Every 3 days at midnight, it checks and re-downloads if the file is older than 3 days.
GitHub Actions Auto-Build
My workflow configuration:
name: docker-caddy
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: your-username/caddy:latest
platforms: linux/amd64
context: ./caddy
file: ./caddy/ContainerfileTrigger methods:
- Scheduled: Auto-builds every Monday at midnight. Since Caddy and its plugins are continuously updated, weekly builds ensure the latest security fixes.
- Manual:
workflow_dispatchlets you trigger a build from the GitHub Actions page - useful when you need to publish a new plugin version immediately.
secrets.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN must be configured in the repository’s Settings → Secrets and variables → Actions.
My Image
If you don’t want the hassle of building an image, you can directly use my docker.io/nite07/caddy:latest.
Repo Link: https://git.nite07.com/nite/custom-containerfile