# 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](https://github.com/caddyserver/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:

```dockerfile
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`:

```bash
#!/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

```bash
#!/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](https://github.com/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:

```yaml
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

- [xcaddy GitHub](https://github.com/caddyserver/xcaddy)
- [Caddy Module Marketplace](https://caddyserver.com/download)
- [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb)


---

> Author: Nite  
> URL: https://www.nite07.com/en/posts/custom-caddy-build/  

