- Shell 94.4%
- Dockerfile 2.8%
- Go Template 2.8%
Custom Caddy build & release
Builds Caddy with a curated plugin set, packages
it as binary tarballs, .deb, .apk, and multi-arch Docker images, then
publishes the result to either a Forgejo instance
or GitHub. Driven by a single config file: build.yaml.
AI Disclaimer
This repository was bootstrapped and largely written with Claude Code. Since my primary use case is automating custom Caddy packaging within my homelab, I'm comfortable with AI-assisted code generation and the resulting codebase size.
However, all AI-generated output undergoes human review and validation through automated test suites or manual testing before merging.
Quick start
./build.sh tools # download yq, nfpm, and xcaddy into ./tools/
./build.sh all # build every format enabled in build.yaml
./release.sh # publish to the configured provider
In CI, the workflows in .forgejo/workflows and
.github/workflows do exactly this on every push to
main.
How versioning works
The published package version is computed at build time from
caddy.version plus caddy.version_suffix:
version_suffix |
Resolves to | Notes |
|---|---|---|
"auto" (default) |
<caddy.version>.<YYYYMMDD>.<commit-count> |
Same commit produces the same version, so the already-published check skips rebuild. |
"" |
<caddy.version> |
No suffix. |
"X" (anything else) |
<caddy.version>.X |
Literal; digits/letters/dots only. |
So with caddy.version: 2.11.2, version_suffix: auto, and 42 commits
in the repo today, you get 2.11.2.20260508.42. Re-running the same
commit yields the same version, and the check job below short-circuits.
The nfpm release field (the -1 in caddy_<ver>-1_amd64.deb, -r1
in the .apk) is hardcoded to 1. All uniqueness lives in the version
itself.
If
autoruns outside a git repo or against a shallow clone, the commit count falls back to0and rebuilds will collide. CI workflows clone withfetch-depth: 0to avoid this.
Providers
The release backend is selected by release.provider in build.yaml,
with the RELEASE_PROVIDER env var taking precedence. Each CI workflow
sets the env explicitly so it stays self-contained.
Both providers create a Release tagged v<pkg_version> with binary,
deb, and apk attached as assets, so users get the same discovery
surface either way. The provider-specific extras differ:
| Format | forgejo | github |
|---|---|---|
| binary | generic registry + release asset | release asset |
| deb | debian registry + release asset | release asset |
| apk | alpine registry + release asset | release asset |
| docker | container registry | ghcr.io |
| Release page | yes | yes |
Re-uploading a same-named asset replaces the existing one on both providers, so re-running a workflow on the same commit is a no-op.
Container image naming
The host and owner are auto-detected from the CI runner env —
FORGEJO_SERVER_URL + FORGEJO_REPOSITORY for the forgejo backend
(with GITHUB_* aliases as a fallback for older runners), and
GITHUB_REPOSITORY for the github backend. Export manually for local
runs — see below.
| Backend | Image URL |
|---|---|
| forgejo | <forgejo-host>/<owner>/<docker.image> |
| github | ghcr.io/<owner-lowercased>/<repo>/<release.github.container_subpackage> if set, else ghcr.io/<owner-lowercased>/<docker.image> |
On GitHub, release.github.container_subpackage is optional and names
a sub-package under the repo. The repo prefix is added automatically:
release.github.container_subpackage |
Resulting URL |
|---|---|
unset (defaults to docker.image) |
ghcr.io/oltdaniel/caddy-custom |
server |
ghcr.io/oltdaniel/caddy-custom/server |
Both forms are auto-linked to the repo (visibility on the repo page,
permissions inheritance) — the default because docker.image matches
the repo name; the sub-package form because GitHub matches its leading
segment to a repo under the same owner. The choice is just:
- Default: a top-level package named after
docker.image. - Sub-package: set
container_subpackageto anything else (e.g.server) to publish underghcr.io/<owner>/<repo>/....
Token scopes
| Provider | Env var (read by scripts) | CI secret name | Required scopes |
|---|---|---|---|
| forgejo | FORGEJO_TOKEN |
RELEASE_TOKEN (Forgejo reserves the FORGEJO_TOKEN name) |
write:package (package + container registries) and write:repository (releases + asset uploads) |
| github | GITHUB_TOKEN |
GITHUB_TOKEN (auto-injected) |
contents: write (releases) and packages: write (ghcr) |
For just release.sh check, read scopes (read:package +
read:repository on Forgejo; default GITHUB_TOKEN on GitHub) are
enough — the check job runs without a write-scoped token if the
registry/repo is publicly readable.
On the GitHub workflow, GITHUB_TOKEN is auto-provided; the
permissions block at the top of the workflow declares the two scopes.
On the Forgejo workflow, the token is supplied via the RELEASE_TOKEN
secret — Forgejo reserves FORGEJO_TOKEN for its own auto-injected
runner token, so user-defined secrets can't use that name. Each
workflow step maps RELEASE_TOKEN onto the FORGEJO_TOKEN env var
that the scripts and API calls read.
Installing & upgrading
How to consume the published artifacts, by format. Examples use the
values currently in build.yaml (forgejo host
codeberg.org, owner oltdaniel, package name caddy-custom).
Substitute <pkg_version> with a real version (e.g. 2.11.0.20260512.42)
and swap amd64 / x86_64 for your architecture as needed.
Binary tarball
Forgejo (generic registry):
curl -fsSL -o caddy.tar.gz \
https://codeberg.org/api/packages/oltdaniel/generic/caddy-custom/<pkg_version>/caddy_<pkg_version>_linux_amd64.tar.gz
tar -xzf caddy.tar.gz caddy
sudo install -m 755 caddy /usr/local/bin/
GitHub (release asset):
curl -fsSL -o caddy.tar.gz \
https://github.com/oltdaniel/caddy-custom/releases/download/v<pkg_version>/caddy_<pkg_version>_linux_amd64.tar.gz
tar -xzf caddy.tar.gz caddy
sudo install -m 755 caddy /usr/local/bin/
Upgrade: re-run the same commands with a newer <pkg_version>. There
is no package manager — the binary is just overwritten.
Debian (.deb)
Forgejo (debian registry, integrates with apt):
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://codeberg.org/api/packages/oltdaniel/debian/repository.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/caddy-custom.gpg
echo "deb [signed-by=/etc/apt/keyrings/caddy-custom.gpg] https://codeberg.org/api/packages/oltdaniel/debian stable main" \
| sudo tee /etc/apt/sources.list.d/caddy-custom.list
sudo apt update
sudo apt install caddy-custom
Upgrade: sudo apt update && sudo apt upgrade caddy-custom.
GitHub (release asset, manual install):
curl -fsSL -o caddy.deb \
https://github.com/oltdaniel/caddy-custom/releases/download/v<pkg_version>/caddy-custom_<pkg_version>-1_amd64.deb
sudo apt install ./caddy.deb
Upgrade: download the new .deb and run sudo apt install ./caddy.deb
again — apt detects the higher version and upgrades in place.
Alpine (.apk)
Forgejo (alpine registry, integrates with apk):
curl -fsSL -o /etc/apk/keys/caddy-custom.rsa.pub \
https://codeberg.org/api/packages/oltdaniel/alpine/key
echo "https://codeberg.org/api/packages/oltdaniel/alpine/v3/caddy-custom" \
| sudo tee -a /etc/apk/repositories
sudo apk update
sudo apk add caddy-custom
Upgrade: sudo apk update && sudo apk upgrade caddy-custom.
GitHub (release asset, manual install):
curl -fsSL -o caddy.apk \
https://github.com/oltdaniel/caddy-custom/releases/download/v<pkg_version>/caddy-custom_<pkg_version>-r1_x86_64.apk
sudo apk add --allow-untrusted caddy.apk
Upgrade: re-run the same commands with a newer <pkg_version>. The
--allow-untrusted flag is required because the standalone .apk
isn't signed against any key in /etc/apk/keys/.
OpenRC service. The apk already ships
/etc/init.d/caddy, so enable and start it directly:rc-update add caddy default rc-service caddy startAlpine community's
caddy-openrccontains the same init script packaged standalone (no hard dependency on the upstreamcaddybinary). If you prefer to have it owned by the upstream package, install withsudo apk add --force-overwrite caddy-openrc— the file contents are byte-identical to ours, so apk only emits an overwrite warning.
Docker
Forgejo container registry:
docker pull codeberg.org/oltdaniel/caddy-custom:latest
GitHub ghcr.io:
docker pull ghcr.io/oltdaniel/caddy-custom:latest
Both registries publish a multi-arch manifest, so docker pull
auto-selects the right platform. Upgrade is another docker pull
(plus a container restart). Pin to :<pkg_version> instead of
:latest if you want reproducible deployments.
CI flow
Both workflows follow the same two-job shape:
check— runs./release.sh check, which queries the provider for the currentpkg_versionand outputspublished=true|false. Only needsbash,curl,yq.build-and-publish— gated onpublished != 'true'. Runs./build.sh allthen./release.sh. The whole job is skipped (no runner allocated) when the version already exists.
The result: pushing the same commit twice does no work the second time; pushing a new commit always builds because the commit count differs.
A separate auto-update workflow runs on a monthly schedule. It calls
check-updates.sh to detect new upstream releases
(Caddy, xcaddy, plugins, yq, nfpm) and new content in the tracked
dist/ sources, then either opens a rolling PR or commits directly to
main depending on auto_update.mode.
On GitHub,
auto_update.mode: pradditionally requires the repository setting Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests to be enabled. Without it, the workflow fails at PR-open time with a 403 even when the token scopes are correct. On Forgejo, the token scopes listed above (write:repository) are sufficient — no extra repo-level toggle.
Reproducibility and pinning
All external dependencies are pinned to achieve reproducible builds.
Caddy. Pinned by release tag. Set caddy.version to an explicit
semver (e.g., "2.11.2"), not "latest". Resolved via go.sum during
the xcaddy build, so integrity is cryptographically verified by Go.
Plugins. Each plugin in xcaddy.plugins must include a version
tag (preferred: @vX.Y.Z; fallback: @<commit-sha> for projects
without release tags). Bare module paths are rejected at build time.
Example:
plugins:- github.com/caddy-dns/cloudflare@v0.2.4- github.com/some-plugin/name@abc123def456Build tools (yq, nfpm, xcaddy). All three pinned by version +
per-architecture SHA256 in lib/tools.sh. Downloads are
verified against the hash before use. check-updates.sh
proposes new versions with their hashes together, keeping the pins in
sync. xcaddy's hash is computed locally from the downloaded tarball
(upstream publishes SHA-512 only); yq and nfpm use the SHA-256
checksums.txt from their releases. The Go toolchain is still required
at build time because xcaddy invokes go build to produce the Caddy
binary — it is no longer needed to install xcaddy itself.
dist/ content. Files imported from
caddyserver/dist and
alpinelinux/aports are pinned
by commit SHA in build.yaml under dist.*. The update checker fetches
each tracked file from upstream, diffs it against the local copy, and
on --apply rewrites the changed file and bumps the pin.
Container image. The base image (docker.base_image) is
intentionally tag-pinned but not digest-pinned. The Dockerfile runs
apk add from alpine's repos, which floats independently of the base
digest — a digest pin would be misleading. Container freshness comes
from the monthly rebuild, not from pinning. The Caddy binary inside
the container is pinned and SHA256-verified as described above.
Configuration surface
All configuration lives in build.yaml. The most common knobs:
caddy.version— upstream Caddy version. Either"latest"(resolves to the currentcaddyserver/caddyrelease tag at build time) or an explicit semver like"2.11.2". Pre-release tags ("2.12.0-beta1") are allowed; branches, commit SHAs, and"nightly"are rejected because the resultingpkg_versionhas to be deb/apk/Docker-safe.xcaddy.plugins—--withmodules. Versions and replacements supported per xcaddy's syntax.architectures[]— comment out rows you don't need; each row produces one binary, optionally one deb, one apk, and one Docker platform.formats.*— toggle binary / deb / apk / docker on or off. The same flag gates both the build (build.sh all) and the publish (release.shwith no targets); a format that'sfalseis skipped end-to-end.release.provider—forgejoorgithub.auto_update.mode—pr(rolling PR) orauto(commit tomain).
Local development
./build.sh tools # download yq/nfpm/xcaddy (SHA256-verified)
./build.sh binary # per-arch binaries into ./out/binaries/
./build.sh deb apk # nfpm packages into ./out/packages/
./build.sh docker # local Docker image
./build.sh clean # rm -rf ./out/
build.sh and release.sh are intentionally independent. There is no
combined entry point — CI workflows orchestrate the two-step flow
explicitly.
Running release.sh locally
In CI, the runner auto-populates the host + owner/repo env vars, so
release.sh knows where to publish without any config:
- Forgejo Actions (v7+ runner) sets
FORGEJO_SERVER_URLandFORGEJO_REPOSITORY, plus theGITHUB_*aliases for GitHub Actions compatibility. The forgejo backend prefers theFORGEJO_*names and falls back toGITHUB_*. - GitHub Actions sets
GITHUB_REPOSITORY. (The host is alwaysapi.github.com, so no server-URL var is needed.)
Outside CI those env vars are unset, so release.sh will refuse to run
unless you export them yourself:
# Forgejo (e.g. publishing to Codeberg)
FORGEJO_SERVER_URL=https://codeberg.org \
FORGEJO_REPOSITORY=oltdaniel/caddy-custom \
FORGEJO_TOKEN=... \
./release.sh check # is this pkg_version published?
FORGEJO_SERVER_URL=https://codeberg.org \
FORGEJO_REPOSITORY=oltdaniel/caddy-custom \
FORGEJO_TOKEN=... \
./release.sh binary deb # publish only those targets
# GitHub (no server-URL var — host is api.github.com)
GITHUB_REPOSITORY=oltdaniel/caddy-custom \
GITHUB_TOKEN=... RELEASE_PROVIDER=github \
./release.sh check
For just release.sh check against a public repo/registry, the token
can be omitted (read-only HTTP is unauthenticated). The host and
owner/repo vars are still required so the script knows what to query.
Tests
The scripts in tests/ exercise the produced artifacts on a host with the matching runtime available:
| Script | What it checks |
|---|---|
tests/binary.sh |
Tarball extracts, binary runs, caddy version includes the pinned plugins. |
tests/deb.sh |
.deb installs into a Debian container, service starts, removes cleanly. |
tests/apk.sh |
.apk installs into an Alpine container, OpenRC init script works. |
tests/docker.sh |
Image starts, default Caddyfile resolves, welcome page is served. |
These are run by hand or in CI before publishing — they are not part of
the default build.sh all target.
Requirements
bash,curl,git,gettext(forenvsubst)- Go toolchain (xcaddy invokes it to compile the Caddy binary)
- Docker + buildx (only for the
dockertarget)
yq, nfpm, and xcaddy are bootstrapped automatically into ./tools/
from their GitHub release tarballs, verified against the SHA256 pins in
lib/tools.sh.
Layout
build.sh entry point for building artifacts
release.sh entry point for publishing — dispatches on provider
check-updates.sh detects pin drift for Caddy, xcaddy, plugins, tools, dist files
build.yaml configuration
lib/
common.sh shared helpers (cfg/yq, pkg_version, tool bootstrap)
tools.sh pinned versions + SHA256 for yq, nfpm
build-binary.sh xcaddy invocation
build-packages.sh nfpm deb/apk packaging
build-docker.sh buildx multi-arch image
release-forgejo.sh forgejo backend (registries + releases)
release-github.sh github backend (releases + ghcr.io)
templates/ nfpm + Dockerfile templates (envsubst-rendered)
dist/ static payload synced from upstream Caddy/Alpine repos
tests/ artifact-level smoke tests
License
The build scripts, workflows, tests, templates, and configuration in this repository are licensed under the Apache License 2.0.
Files under dist/ are synced verbatim from upstream and remain under their original licenses:
| Path | Upstream | License |
|---|---|---|
dist/caddy-dist/ |
caddyserver/dist | Apache-2.0 |
dist/aports/ |
alpinelinux/aports (community/caddy) |
MIT (per the aports repository) |
Caddy itself is built from
caddyserver/caddy (Apache-2.0)
via xcaddy; the resulting binary is redistributed under that license.
Plugin licenses depend on each module in xcaddy.plugins — check the
respective project for terms.