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 build runs here, producing a caddy binary 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 caddyfile

Order matters:

  1. Download GeoIP database (if missing or older than 3 days)
  2. Start crond as a background process (for scheduled GeoIP updates)
  3. exec replaces 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
fi

I 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/2

Every 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/Containerfile

Trigger 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_dispatch lets 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

References