An open API service indexing awesome lists of open source software.

https://github.com/oltdaniel/caddy-custom

Publish your custom caddy automatically as binary, container, apk, deb, ...
https://github.com/oltdaniel/caddy-custom

Last synced: about 1 month ago
JSON representation

Publish your custom caddy automatically as binary, container, apk, deb, ...

Awesome Lists containing this project

README

          

# Custom Caddy build & release

Builds [Caddy](https://caddyserver.com) 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](https://forgejo.org) instance
or GitHub. Driven by a single config file: [build.yaml](build.yaml).

## AI Disclaimer

This repository was bootstrapped and largely written with [Claude
Code](https://claude.com/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

```sh
./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](.forgejo/workflows) and
[.github/workflows](.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) | `..` | Same commit produces the same version, so the already-published check skips rebuild. |
| `""` | `` | No suffix. |
| `"X"` (anything else) | `.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_-1_amd64.deb`, `-r1`
in the `.apk`) is hardcoded to `1`. All uniqueness lives in the version
itself.

> If `auto` runs outside a git repo or against a shallow clone, the
> commit count falls back to `0` and rebuilds will collide. CI workflows
> clone with `fetch-depth: 0` to 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` 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](#running-releasesh-locally).

| Backend | Image URL |
|-|-|
| forgejo | `//` |
| github | `ghcr.io///` if set, else `ghcr.io//` |

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_subpackage` to anything else (e.g.
`server`) to publish under `ghcr.io///...`.

### 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](build.yaml) (forgejo host
`codeberg.org`, owner `oltdaniel`, package name `caddy-custom`).
Substitute `` 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):
```sh
curl -fsSL -o caddy.tar.gz \
https://codeberg.org/api/packages/oltdaniel/generic/caddy-custom//caddy__linux_amd64.tar.gz
tar -xzf caddy.tar.gz caddy
sudo install -m 755 caddy /usr/local/bin/
```

GitHub (release asset):
```sh
curl -fsSL -o caddy.tar.gz \
https://github.com/oltdaniel/caddy-custom/releases/download/v/caddy__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 ``. There
is no package manager — the binary is just overwritten.

### Debian (`.deb`)

Forgejo (debian registry, integrates with `apt`):
```sh
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):
```sh
curl -fsSL -o caddy.deb \
https://github.com/oltdaniel/caddy-custom/releases/download/v/caddy-custom_-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`):
```sh
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):
```sh
curl -fsSL -o caddy.apk \
https://github.com/oltdaniel/caddy-custom/releases/download/v/caddy-custom_-r1_x86_64.apk
sudo apk add --allow-untrusted caddy.apk
```

Upgrade: re-run the same commands with a newer ``. 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:
> ```sh
> rc-update add caddy default
> rc-service caddy start
> ```
> Alpine community's [`caddy-openrc`](https://pkgs.alpinelinux.org/package/edge/community/x86_64/caddy-openrc)
> contains the same init script packaged standalone (no hard dependency on
> the upstream `caddy` binary). If you prefer to have it owned by the
> upstream package, install with `sudo 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:
```sh
docker pull codeberg.org/oltdaniel/caddy-custom:latest
```

GitHub `ghcr.io`:
```sh
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 `:` instead of
`:latest` if you want reproducible deployments.

## CI flow

Both workflows follow the same two-job shape:

1. **`check`** — runs `./release.sh check`, which queries the provider
for the current `pkg_version` and outputs `published=true|false`.
Only needs `bash`, `curl`, `yq`.
2. **`build-and-publish`** — gated on `published != 'true'`. Runs
`./build.sh all` then `./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`](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: pr` additionally 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: `@` for projects
without release tags). Bare module paths are rejected at build time.
Example:

```yaml
plugins:
- github.com/caddy-dns/cloudflare@v0.2.4
- github.com/some-plugin/name@abc123def456
```

**Build tools (yq, nfpm, xcaddy).** All three pinned by version +
per-architecture SHA256 in [lib/tools.sh](lib/tools.sh). Downloads are
verified against the hash before use. [`check-updates.sh`](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](https://github.com/caddyserver/dist) and
[alpinelinux/aports](https://github.com/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](build.yaml). The most common
knobs:

- `caddy.version` — upstream Caddy version. Either `"latest"` (resolves
to the current `caddyserver/caddy` release 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 resulting `pkg_version` has to be deb/apk/Docker-safe.
- `xcaddy.plugins` — `--with` modules. Versions and replacements
supported per [xcaddy](https://github.com/caddyserver/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.sh` with no targets); a format that's `false` is skipped
end-to-end.
- `release.provider` — `forgejo` or `github`.
- `auto_update.mode` — `pr` (rolling PR) or `auto` (commit to `main`).

## Local development

```sh
./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_URL` and
`FORGEJO_REPOSITORY`][forgejo-env], plus the `GITHUB_*` aliases for
GitHub Actions compatibility. The forgejo backend prefers the
`FORGEJO_*` names and falls back to `GITHUB_*`.
- GitHub Actions sets `GITHUB_REPOSITORY`. (The host is always
`api.github.com`, so no server-URL var is needed.)

[forgejo-env]: https://forgejo.org/docs/next/user/actions/reference/#env-1

Outside CI those env vars are unset, so `release.sh` will refuse to run
unless you export them yourself:

```sh
# 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/](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` (for `envsubst`)
- Go toolchain (xcaddy invokes it to compile the Caddy binary)
- Docker + buildx (only for the `docker` target)

`yq`, `nfpm`, and `xcaddy` are bootstrapped automatically into `./tools/`
from their GitHub release tarballs, verified against the SHA256 pins in
[lib/tools.sh](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](LICENSE).

Files under [dist/](dist/) are synced verbatim from upstream and remain
under their original licenses:

| Path | Upstream | License |
|-|-|-|
| `dist/caddy-dist/` | [caddyserver/dist](https://github.com/caddyserver/dist) | Apache-2.0 |
| `dist/aports/` | [alpinelinux/aports](https://github.com/alpinelinux/aports) (`community/caddy`) | MIT (per the aports repository) |

Caddy itself is built from
[caddyserver/caddy](https://github.com/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.