# Lint stage # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # Using Debian-based image because mattn/go-sqlite3 (CGO) does not # compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* WORKDIR /src # Copy go mod files first for better layer caching COPY go.mod go.sum ./ RUN go mod download # Copy source code. In CI the context also carries .ci-fingerprint, which # holds the hash of the commit being checked (see # .gitea/workflows/check.yml). That invalidates this layer, so the checks # below cannot report success by replaying a cached pass. Do not add it to # .dockerignore. COPY . . # Run formatting check and linter. golangci-lint is invoked directly rather # than through `make lint`: this stage is already the pinned linter image, and # script/lint is a wrapper that builds Dockerfile.lint, so calling it here # would need a docker daemon inside the build. Keep these steps in step with # Dockerfile.lint, including --network=none (see its header for why). RUN make fmt-check RUN script/assets RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... # Stylesheet stages. static/css/tailwind.css is generated, by this pinned # tailwindcss, from static/css/input.css and the files its @source lines # name. `make css` (script/css) writes it out from the css-output stage. # The css-check stage fails when the committed file differs from what is # generated; `make check` runs it, and so does the build stage below. # # tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per # architecture, each pinned by its sha256 from the release's sha256sums.txt. # debian:bookworm-slim, 2026-10-02: the binary needs glibc. FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64 ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \ https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64 ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \ https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss # TARGETARCH, set by docker, is the architecture being built for. FROM tailwind-${TARGETARCH} AS css WORKDIR /src COPY . . RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify FROM scratch AS css-output COPY --from=css /out/tailwind.css / # Both files are split after each "}", one rule per line, so that when they # differ the diff shows the rules that differ. FROM css AS css-check RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ && sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \ && diff -U0 /tmp/committed.css /tmp/generated.css || { \ echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \ exit 1; \ } # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 # Using Debian-based image because gorm.io/driver/sqlite pulls in # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder # Depend on the lint and stylesheet check stages passing COPY --from=lint /src/go.sum /dev/null COPY --from=css-check /out/tailwind.css /dev/null # jq is a runtime dependency of script/ci-mark-superseded, which the test # suite executes. git is what script/version derives the version with. RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/* # A build context sent as a tar archive keeps its files' owners, and git # refuses to read a checkout owned by another user. Trust this one # whoever owns it. RUN git config --system --add safe.directory /build WORKDIR /build # Copy go mod files first for better layer caching COPY go.mod go.sum ./ RUN go mod download # Copy source code, including the .ci-fingerprint cache barrier described in # the lint stage above. COPY . . # Run tests and build. Both first run script/assets, which extracts Alpine.js # from its tarball in 3p/. RUN make test # Version stamped into the binary: the VERSION build arg when one is # given, otherwise what script/version derives from the .git the build # context carries, so any `docker build .` of a clone stamps its commit. # With neither, as from a source tarball, it is "unknown". # # Declared here, below the test step, so a changed version does not # invalidate its cached layer. ARG VERSION # A context that carries .git must not stamp "unknown": that means git is # missing here or could not read the checkout, and the image could not be # traced back to its commit. RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \ echo "version is unknown although the build context carries .git" >&2; \ exit 1; \ fi RUN make build VERSION="$VERSION" # Rebuild with static linking for Alpine runtime. # make build already verified compilation. # The CGO binary from `make build` is dynamically linked against glibc, # which doesn't exist on Alpine (musl). Rebuild with static linking so # the binary runs on Alpine without glibc. # # The static flags go in through GO_LDFLAGS rather than a -ldflags of # their own: the build target composes them with the -X that stamps the # version, so this relink cannot silently drop the stamp. RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"' # Runtime stage # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 # su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app # as webhooker with it. RUN apk --no-cache add ca-certificates su-exec=0.2-r3 # Create non-root user RUN addgroup -g 1000 -S webhooker && \ adduser -u 1000 -S webhooker -G webhooker WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/webhooker /app/webhooker # Not under /app, which belongs to webhooker: this script runs as root. COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # Create data directory for all SQLite databases (main app DB + # per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker. RUN mkdir -p /var/lib/webhooker RUN chown -R webhooker:webhooker /app /var/lib/webhooker # No USER: the entrypoint starts as root to make the data directory # webhooker's, then runs the app as webhooker. EXPOSE 8080 # The binary defaults BIND_ADDRESS to 127.0.0.1, which is right for a # bare host: the cleartext listener serves the admin UI and the # unauthenticated receiver, so it must not appear on every interface # of a machine that configured nothing. A container is the other case. # Its network namespace is already the isolation boundary, so binding # every address inside it exposes nothing; what decides exposure is # the publish flag, and `-p 127.0.0.1:8080:8080` is the operator's # control there. Shipping the image on loopback would buy no security # and would make the process unreachable through its own published # port. ENV BIND_ADDRESS=0.0.0.0 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"]