Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions .github/workflows/build-caddy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ jobs:
echo "Resolving cache-handler HEAD commit..."
CACHE_HANDLER_REF="$(
retry git ls-remote --heads https://github.com/caddyserver/cache-handler.git \
| awk '/refs\/heads\/master/ {print $1}'
| awk '/refs\/heads\/main/ {print $1}'
)"

echo "Resolving transform-encoder HEAD commit..."
TRANSFORM_ENCODER_REF="$(
retry git ls-remote --heads https://github.com/caddyserver/transform-encoder.git \
| awk '/refs\/heads\/master/ {print $1}'
| awk '/refs\/heads\/main/ {print $1}'
)"

echo "Resolving caddy-security version..."
Expand All @@ -193,11 +193,14 @@ jobs:
echo "Resolved fingerprint: ${BUILD_FINGERPRINT}"

# ── 3c: Compare with published image fingerprint ──
# Authenticate to GHCR with the automatically-provided GITHUB_TOKEN
# before running docker manifest inspect. This ensures the inspect
# succeeds even on the first run (before Step 5 logins), and avoids
# a race condition where the inspect could fail if the image is not
# publicly accessible.
# Pre-authenticate to GHCR using the auto-provided GITHUB_TOKEN
# before running docker manifest inspect. This must happen here
# (Step 3) rather than relying solely on the later docker/login-action
# in Step 5, because Step 5 is conditional on should_build == 'true'.
# Without this pre-auth, manifest inspect would fail on the very first
# run (no published image) or when the package visibility is private,
# causing the fingerprint comparison to silently default to
# 'no-published-image' and trigger unnecessary rebuilds.
echo "${{ secrets.GITHUB_TOKEN }}" \
| docker login ghcr.io -u "${{ github.actor }}" --password-stdin \
2>/dev/null || true
Expand All @@ -221,14 +224,22 @@ jobs:
CHANGE_SUMMARY="No published image found — performing initial build."
elif [[ "${BUILD_FINGERPRINT}" != "${PUBLISHED_FINGERPRINT}" ]]; then
SHOULD_BUILD="true"
# Human-readable diff
# Human-readable diff — compare only the parts that are present
# in both fingerprints; guard against unequal part counts (e.g.,
# if a new plugin is added to the fingerprint schema).
Comment on lines +227 to +229
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says the diff will "compare only the parts that are present in both fingerprints", but the implementation compares up to MAX_IDX and uses MISSING for absent entries (i.e., it compares the union and explicitly reports missing parts). Consider updating the comment to match the current behavior to avoid confusion during future audits.

Suggested change
# Human-readable diff — compare only the parts that are present
# in both fingerprints; guard against unequal part counts (e.g.,
# if a new plugin is added to the fingerprint schema).
# Human-readable diff — compare all indexed parts from both
# fingerprints (up to MAX_IDX), marking missing entries as
# MISSING; this guards against unequal part counts (e.g., when
# the fingerprint schema changes or a new plugin is added).

Copilot uses AI. Check for mistakes.
CHANGE_SUMMARY="Component changes detected:"$'\n'
IFS='|' read -ra NEW_PARTS <<< "${BUILD_FINGERPRINT}"
IFS='|' read -ra OLD_PARTS <<< "${PUBLISHED_FINGERPRINT}"
for i in "${!NEW_PARTS[@]}"; do
NEW="${NEW_PARTS[$i]}"
NEW_COUNT="${#NEW_PARTS[@]}"
OLD_COUNT="${#OLD_PARTS[@]}"
if [[ "${NEW_COUNT}" != "${OLD_COUNT}" ]]; then
CHANGE_SUMMARY+=" - Fingerprint schema changed: new has ${NEW_COUNT} parts, published has ${OLD_COUNT} parts"$'\n'
fi
MAX_IDX=$(( NEW_COUNT > OLD_COUNT ? NEW_COUNT : OLD_COUNT ))
for (( i=0; i<MAX_IDX; i++ )); do
NEW="${NEW_PARTS[$i]:-MISSING}"
OLD="${OLD_PARTS[$i]:-MISSING}"
if [[ "$NEW" != "$OLD" ]]; then
if [[ "${NEW}" != "${OLD}" ]]; then
CHANGE_SUMMARY+=" - CHANGED: ${NEW} (was: ${OLD})"$'\n'
fi
done
Expand Down Expand Up @@ -413,6 +424,14 @@ jobs:

# ──────────────────────────────────────────────────────────
# STEP 7 — Smoke test and module validation
# Module names verified against each upstream's registered caddy
# module identifier (as output by `caddy list-modules`):
# docker_proxy → lucaslorentz/caddy-docker-proxy
# dns.providers.cloudflare → caddy-dns/cloudflare
# http.ip_sources.cloudflare → WeidiDeng/caddy-cloudflare-ip
# http.handlers.cache → caddyserver/cache-handler
# caddy.logging.encoders.transform → caddyserver/transform-encoder
# security → greenpau/caddy-security
# ──────────────────────────────────────────────────────────
- name: Smoke Test and Module Validation
if: steps.decide.outputs.should_build == 'true'
Expand Down Expand Up @@ -570,12 +589,14 @@ jobs:
MINOR="$(echo "${VERSION}" | cut -d. -f1-2)"
DIGEST="${{ steps.push.outputs.digest }}"

# Push semver major/minor tags to GHCR only. DockerHub does not
# enforce tag immutability by default (it is an opt-in paid feature),
# but we intentionally omit these mutable floating tags from DockerHub
# to avoid silently updating images that users may have pinned to "2"
# or "2.11" expecting stability. GHCR users are expected to understand
# the floating-tag contract.
# Semver major/minor (floating) tags are pushed to GHCR only.
# DockerHub tag immutability is an opt-in Enterprise/Teams feature
# and is NOT enabled on the atnplex org. We intentionally omit
# floating tags from DockerHub to avoid silently overwriting a tag
# that users may have pinned expecting it to be stable (e.g.,
# image: atnplex/caddy:2 in a compose file). GHCR users pulling
# floating tags are expected to understand the contract. For fully
# reproducible deployments, always pin to a digest.
GHCR_IMAGE="${GHCR_REGISTRY}/${{ github.repository_owner }}/${IMAGE_NAME}"
docker buildx imagetools create \
--tag "${GHCR_IMAGE}:${MAJOR}" \
Expand All @@ -599,6 +620,10 @@ jobs:

# ──────────────────────────────────────────────────────────
# STEP 11 — Update README badges (always runs)
# NOTE: date -d 'next monday' is a GNU coreutils extension.
# This step always runs on ubuntu-latest which ships GNU coreutils.
# If the runner OS is ever changed (e.g., to macos-latest), replace
# `date -d 'next monday'` with a Python/gdate equivalent.
# ──────────────────────────────────────────────────────────
- name: Update README Badges
if: always()
Expand Down Expand Up @@ -785,4 +810,3 @@ jobs:
docker rmi "caddy-test:${{ github.run_id }}" 2>/dev/null || true
docker buildx prune --filter "until=1h" --force 2>/dev/null || true
rm -f scout_output.md modules.txt

31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,21 @@ labels:

## Tag Strategy

| Tag | Description |
|:---|:---|
| `latest` | Always points to the most recent successful build |
| `2` · `2.11` · `2.11.2` | Semantic version pins at major / minor / patch |
| `2.11.2-2026.03.28` | Version + build date for fully reproducible deployments |
| `sha-abc1234` | Git SHA of the Dockerfile at build time |

For production deployments requiring immutability, use the digest from the
[Releases](https://github.com/atnplex/caddy/releases) page.
| Tag | Registry | Description |
|:---|:---|:---|
| `latest` | GHCR · DockerHub | Always points to the most recent successful build |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor documentation inconsistency: latest tag classification.

The note on lines 127-130 states "DockerHub receives only immutable tags" to prevent silent overwrites, yet line 121 shows latest is pushed to both registries. The latest tag is by definition a floating/mutable tag that gets overwritten on every build.

Consider clarifying that DockerHub receives latest (which users understand is mutable) plus immutable version-pinned tags, but intentionally omits the semver floating tags (2, 2.11) which users might mistakenly assume are stable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 121, Update the README to resolve the inconsistency about
the `latest` tag: change the table row and the explanatory note that currently
states "DockerHub receives only immutable tags" so it explicitly says DockerHub
is intentionally pushed both the mutable `latest` tag (floating) and immutable,
version-pinned tags, while omitting semver-floating tags like `2` and `2.11`;
locate and edit the `latest` table entry and the sentence containing "DockerHub
receives only immutable tags" to state this behavior clearly and mention
`latest`, `2`, and `2.11` by name.

| `2` · `2.11` | GHCR only | Floating semver major / minor tags — updated on every build |
| `2.11.2` | GHCR · DockerHub | Patch-level version pin |
| `2.11.2-2026.03.28` | GHCR · DockerHub | Version + build date for fully reproducible deployments |
| `sha-abc1234` | GHCR · DockerHub | Git SHA of the Dockerfile at build time |

> **Note:** Floating major/minor tags (`2`, `2.11`) are only pushed to GHCR.
> DockerHub receives only immutable tags (patch version, date-stamped, and SHA)
Comment on lines +123 to +128
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table says the patch-level semver tag (e.g. 2.11.2) is pushed to DockerHub, but the workflow currently only pushes latest, ${version}-${date}, and sha-${short_sha} to DockerHub (no plain ${version} tag). Either add the ${version} DockerHub tag in the workflow, or update this row to reflect the actual published tags.

Suggested change
| `2.11.2` | GHCR · DockerHub | Patch-level version pin |
| `2.11.2-2026.03.28` | GHCR · DockerHub | Version + build date for fully reproducible deployments |
| `sha-abc1234` | GHCR · DockerHub | Git SHA of the Dockerfile at build time |
> **Note:** Floating major/minor tags (`2`, `2.11`) are only pushed to GHCR.
> DockerHub receives only immutable tags (patch version, date-stamped, and SHA)
| `2.11.2` | GHCR only | Patch-level version pin |
| `2.11.2-2026.03.28` | GHCR · DockerHub | Version + build date for fully reproducible deployments |
| `sha-abc1234` | GHCR · DockerHub | Git SHA of the Dockerfile at build time |
> **Note:** Floating major/minor tags (`2`, `2.11`) and patch-level tags (`2.11.2`) are only pushed to GHCR.
> DockerHub receives only immutable tags (date-stamped and SHA-based)

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +128
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note says DockerHub receives immutable tags including the patch version, but the workflow tag list doesn’t currently publish the plain ${version}/patch tag to DockerHub. Please adjust the note (and/or the workflow) so DockerHub tag policy is described accurately.

Suggested change
| `2.11.2` | GHCR · DockerHub | Patch-level version pin |
| `2.11.2-2026.03.28` | GHCR · DockerHub | Version + build date for fully reproducible deployments |
| `sha-abc1234` | GHCR · DockerHub | Git SHA of the Dockerfile at build time |
> **Note:** Floating major/minor tags (`2`, `2.11`) are only pushed to GHCR.
> DockerHub receives only immutable tags (patch version, date-stamped, and SHA)
| `2.11.2` | GHCR only | Patch-level version pin (GHCR-only; not published to DockerHub) |
| `2.11.2-2026.03.28` | GHCR · DockerHub | Version + build date for fully reproducible deployments |
| `sha-abc1234` | GHCR · DockerHub | Git SHA of the Dockerfile at build time |
> **Note:** Floating major/minor tags (`2`, `2.11`) and patch tags (for example, `2.11.2`)
> are only pushed to GHCR. DockerHub receives only immutable tags (date-stamped and SHA)

Copilot uses AI. Check for mistakes.
> to prevent silent overwrites for users who may have pinned these tags in
> compose files expecting stability.
Comment on lines +128 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This note contradicts the Tag Strategy table, which shows the latest tag being pushed to both GHCR and DockerHub. To maintain consistency, the note should clarify that latest is an exception to the immutability policy on DockerHub.

Suggested change
> DockerHub receives only immutable tags (patch version, date-stamped, and SHA)
> to prevent silent overwrites for users who may have pinned these tags in
> compose files expecting stability.
> DockerHub receives only immutable tags (patch version, date-stamped, and SHA)
> and the latest floating tag. Major/minor floating tags (2, 2.11) are
> excluded from DockerHub to prevent silent overwrites for users.

>
> For production deployments requiring full immutability, pin to a digest from
> the [Releases](https://github.com/atnplex/caddy/releases) page.

---

Expand Down Expand Up @@ -212,6 +218,13 @@ using your personal account credentials.
In **Settings → Branches**, protect the `main` branch and allow the
Renovate bot to bypass protection rules for automerge to work.

### 5. Repository About Section (GitHub UI — one-time)
Set the repository **About** section via **Settings → General → Description**
(or the gear icon on the repo homepage):
- **Description:** `Hardened multi-arch custom Caddy image with docker-proxy, Cloudflare DNS/IP, cache-handler, transform-encoder, and caddy-security modules`
- **Website:** `https://hub.docker.com/r/atnplex/caddy`
- **Topics:** `caddy`, `docker`, `reverse-proxy`, `cloudflare`, `homelab`, `xcaddy`, `github-actions`

---

## Releases
Expand Down
30 changes: 21 additions & 9 deletions docker/caddy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# ─────────────────────────────────────────────────────────────────
# Stage 1: Builder
# ─────────────────────────────────────────────────────────────────
# IMPORTANT: The tag here (CADDY_VERSION-builder) must match the
# CADDY_VERSION ARG in Stage 2. When upgrading Caddy, update both
# the ARG default below AND the digest here together.
# The SHA digest is version-specific; Renovate keeps it up to date.
# IMPORTANT: The ARG default below AND the tag in the FROM line must
# always be kept in sync. When upgrading Caddy, update BOTH the ARG
# default AND the image tag (and its SHA digest) together in one commit.
# The FROM tag cannot reference the ARG directly because ARGs declared
# before the first FROM are not in scope inside the build stage.
Comment on lines +7 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment incorrectly states that the FROM tag cannot reference the ARG directly. In Docker, ARGs declared before the first FROM are specifically designed to be used in FROM instructions, as demonstrated on line 12. The need for manual synchronization is due to the hardcoded SHA digest being tied to a specific version, not a scoping limitation of the tag itself.

# The FROM tag references the ARG below; both must be updated with the SHA.
# Note that ARGs before the first FROM are not in scope inside build stages.

# Renovate keeps the SHA digest up to date automatically; the tag and
# ARG default must be updated manually (or via a Renovate customManager).
Comment on lines +4 to +10
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stage 1 header comment is internally inconsistent with the Dockerfile behavior: the FROM line does reference ${CADDY_VERSION}, and global ARGs declared before the first FROM are in scope for FROM substitution. If the real constraint is the pinned digest (and/or needing to re-declare ARG after FROM to use it in later instructions), please adjust the wording so it accurately describes Docker’s ARG scoping and why the digest/tag must be updated together.

Suggested change
# IMPORTANT: The ARG default below AND the tag in the FROM line must
# always be kept in sync. When upgrading Caddy, update BOTH the ARG
# default AND the image tag (and its SHA digest) together in one commit.
# The FROM tag cannot reference the ARG directly because ARGs declared
# before the first FROM are not in scope inside the build stage.
# Renovate keeps the SHA digest up to date automatically; the tag and
# ARG default must be updated manually (or via a Renovate customManager).
# IMPORTANT: The ARG default below, the tag in the FROM line, and the
# pinned SHA digest must always describe the same Caddy version. The
# global ARG CADDY_VERSION is in scope for the FROM instruction and is
# used in `caddy:${CADDY_VERSION}-builder@sha256:...`.
# When upgrading Caddy, update ALL of the following together in one commit:
# 1) the CADDY_VERSION ARG default,
# 2) the tag portion of the FROM image reference, and
# 3) the SHA256 digest.
# Renovate keeps the SHA digest up to date automatically; the tag and ARG
# default must be updated manually (or via a Renovate customManager).

Copilot uses AI. Check for mistakes.
ARG CADDY_VERSION=2.11.2
FROM caddy:${CADDY_VERSION}-builder@sha256:84dfc3479309c690643ada9279b3e0b4352ce56b0ec8fd802c668f42b546e98f AS builder

Expand Down Expand Up @@ -72,8 +75,12 @@ ENV TZ=America/Los_Angeles \
XDG_DATA_HOME=/data

# Create non-root caddy user/group and add to docker group for socket access.
# We check for an existing docker group (e.g., inherited from the base image)
# before creating one, to avoid silently using a mismatched GID.
# getent is used to check whether a docker group already exists in the base
# image (e.g., Alpine may ship one with a different GID than the host). If it
# already exists we skip creation to avoid a mismatched GID silently adding the
# caddy user to the wrong group. If your host docker.sock GID differs from the
# group created here, pass --group-add $(stat -c '%g' /var/run/docker.sock)
# in your docker run / compose file (as shown in the README Quick Start).
# Provision persistent data and config directories with correct ownership.
RUN addgroup -S caddy && \
adduser -S -G caddy caddy && \
Expand Down Expand Up @@ -101,9 +108,14 @@ COPY --from=builder /usr/share/zoneinfo/America/Los_Angeles \
RUN rm -rf /tmp/* /var/tmp/* && \
find / -xdev -type d -perm -0002 ! -path "/tmp*" -exec chmod 755 {} +

# Healthcheck uses caddy environ (lightweight internal check).
# wget is not present in this minimal Alpine image.
# Port 2019 (admin API) is loopback-only and not exposed externally.
# Healthcheck uses `caddy environ` rather than an HTTP probe against the
# admin API (port 2019) because:
# 1. The admin API is loopback-only and disabled in some deployments.
# 2. `caddy environ` exits 0 iff the binary is functional without
# requiring any network listener to be up.
# 3. The command is lightweight and produces no side effects.
# If you expose the admin API and prefer an HTTP liveness probe, replace
# with: CMD wget -qO- http://localhost:2019/ || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["/usr/bin/caddy", "environ"]

Expand Down
Loading