Files
Gitea/image/caddy/Dockerfile
JMR-devandClaude Opus 5 c0382d5d31 Gitea on GCE: podman quadlets, Pulumi, Cloud Build
Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1,
serving gitea.jasonmross.dev.

Runtime is podman quadlets (systemd .container/.network/.volume units).
Both images are built on Debian 13: Gitea from a GPG-verified release
binary, and Caddy from an xcaddy build carrying the Google Cloud DNS
provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded.

Infrastructure is a Pulumi program in Go against a GCS state backend.
Cloud Build handles CI: a push trigger for images, one for infra, and a
weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen.

Notable design decisions, each documented where it lives:

- Quadlets track a floating :prod tag. AutoUpdate=registry compares
  digests for a tag, so a digest-pinned image silently disables
  auto-updates.
- Git transport and LFS bypass the WAF. With the bypass removed, a plain
  git push returns 403 -- packfiles trip CRS reliably.
- gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail
  acting on WAF verdicts exists. Banning on detections that were never
  blocks would turn a tuning false positive into an nftables ban.
- fail2ban bans at the nftables prerouting hook. Published container
  ports are DNAT'd and never traverse INPUT, where the stock actions
  install their rules.
- The DNS zone, backup bucket, and Gitea signing secrets are not
  Pulumi-owned, so pulumi destroy cannot take them with it.
- The podman subnet is pinned because it is what Gitea's
  REVERSE_PROXY_TRUSTED_PROXIES names.

Three update layers: dnf5-automatic for the OS, podman-auto-update with
health-gated rollback for containers, and a weekly image rebuild that
gives the second layer something to pull.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:45:33 -05:00

87 lines
3.5 KiB
Docker

# syntax=docker/dockerfile:1
#
# Caddy on a Debian 13 (trixie) base, built with xcaddy so two compile-time
# plugins are baked in:
#
# googleclouddns -- ACME DNS-01, so certificates never depend on inbound 80
# coraza-caddy -- OWASP Coraza WAF, with the Core Rule Set embedded
#
# The stock caddy binary can do neither: both are compile-time modules. The CRS
# itself needs no files on disk -- coraza-caddy's `load_owasp_crs` pulls in the
# coraza-coreruleset Go package, which embeds the rules in the binary.
ARG DEBIAN_TAG=13-slim
ARG GOLANG_TAG=1.26-trixie
# ---------------------------------------------------------------------------
# Stage 1: build caddy with the googleclouddns plugin.
# ---------------------------------------------------------------------------
FROM golang:${GOLANG_TAG} AS build
ARG CADDY_VERSION
ARG CORAZA_VERSION
ARG XCADDY_VERSION=latest
ENV CGO_ENABLED=0 \
GOTOOLCHAIN=local
RUN set -eux; \
test -n "${CADDY_VERSION}" || { echo "CADDY_VERSION build-arg is required" >&2; exit 1; }; \
test -n "${CORAZA_VERSION}" || { echo "CORAZA_VERSION build-arg is required" >&2; exit 1; }; \
go install "github.com/caddyserver/xcaddy/cmd/xcaddy@${XCADDY_VERSION}"
RUN set -eux; \
xcaddy build "v${CADDY_VERSION}" \
--with github.com/caddy-dns/googleclouddns \
--with "github.com/corazawaf/coraza-caddy/v2@${CORAZA_VERSION}" \
--output /out/caddy; \
/out/caddy version; \
# Assert BOTH modules landed. It is easy to drop one when editing the build
# line above, and a missing module fails at request time, not build time --
# by which point it is a silently unprotected server or a broken cert renewal.
/out/caddy list-modules > /tmp/modules.txt; \
grep -q '^dns.providers.googleclouddns$' /tmp/modules.txt \
|| { echo "googleclouddns module missing"; cat /tmp/modules.txt; exit 1; }; \
grep -qiE 'coraza|waf' /tmp/modules.txt \
|| { echo "coraza module missing"; cat /tmp/modules.txt; exit 1; }; \
echo "WAF/DNS modules present:"; grep -iE 'coraza|waf|googleclouddns' /tmp/modules.txt
# ---------------------------------------------------------------------------
# Stage 2: runtime.
# ---------------------------------------------------------------------------
FROM debian:${DEBIAN_TAG}
ARG CADDY_VERSION
LABEL org.opencontainers.image.title="caddy-gitea" \
org.opencontainers.image.description="Caddy with Google Cloud DNS ACME and the Coraza WAF, on a Debian 13 base" \
org.opencontainers.image.version="${CADDY_VERSION}" \
org.opencontainers.image.source="https://github.com/caddy-dns/googleclouddns" \
org.opencontainers.image.base.name="docker.io/library/debian:13-slim"
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates curl; \
rm -rf /var/lib/apt/lists/*; \
groupadd --gid 1000 caddy; \
useradd --uid 1000 --gid 1000 --home-dir /config --shell /usr/sbin/nologin caddy; \
mkdir -p /data /config; \
chown -R 1000:1000 /data /config
COPY --from=build --chown=root:root --chmod=0755 /out/caddy /usr/local/bin/caddy
# Where caddy persists ACME account keys and issued certificates.
ENV XDG_DATA_HOME=/data \
XDG_CONFIG_HOME=/config
USER 1000:1000
WORKDIR /config
EXPOSE 80 443 443/udp
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:2019/config/ >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/caddy"]
CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]