# 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; \ } # JavaScript lint stages: ESLint, at the version package.json and yarn.lock # pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and # prettier for the Markdown stages below. The lint phase below runs js-lint. # # The image's own corepack runs the yarn that package.json's packageManager # field names, yarn 4.18.1 (released 2026-09-24), and checks it against the # hash there. The image also ships yarn 1, which `corepack enable yarn` # replaces. # node:24.21.0-alpine (LTS), 2026-09-18 FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps WORKDIR /src COPY package.json yarn.lock .yarnrc.yml ./ RUN corepack enable yarn && yarn install --immutable --mode=skip-build FROM js-deps AS js-lint COPY . . RUN --network=none node_modules/.bin/eslint static/js # Markdown stages: prettier, at the version package.json and yarn.lock pin, # formats every Markdown file in the tree with the settings in .prettierrc. # `make fmt` (script/fmt) writes the formatted files out from markdown-output. # markdown-check fails on any file prettier would change; `make fmt-check` # runs it, and so does the build stage below. FROM js-deps AS markdown COPY . . RUN --network=none node_modules/.bin/prettier --write '**/*.md' \ && mkdir /out \ && find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \; FROM scratch AS markdown-output COPY --from=markdown /out / FROM js-deps AS markdown-check COPY . . RUN --network=none node_modules/.bin/prettier --check '**/*.md' # Lint phase: the Go formatting check and golangci-lint over the Go code, # and ESLint over static/js/ through the copy from js-lint at the end. # `make lint` (script/lint) builds this stage alone; the build stage below # depends on it. # # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 # 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.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src # Copy go mod files first for better layer caching COPY go.mod go.sum ./ RUN go mod download COPY . . # gofmt and golangci-lint are invoked directly rather than through `make # fmt-check` and `make lint`, which are themselves docker builds and would # need a docker daemon inside this one. The Markdown half of `make # fmt-check` is the markdown-check stage above. RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi # static/static.go embeds the Alpine.js file this extracts from 3p/; without # it the static package does not compile and cannot be linted. RUN script/assets # The golangci-lint steps run with --network=none. `golangci-lint config # verify` is documented as fetching its JSON schema over HTTPS; this pinned # image resolves the schema without any network, and --network=none enforces # that. It also proves no linter reaches out at analysis time. # # `run` silently ignores config keys it does not recognize, so a typo would # disable a setting without a word. `config verify` is what catches that. RUN --network=none golangci-lint config verify --config .golangci.yml # --build-tags browser also lints the browser test, which is built only with # that tag (make test-browser). RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... # Nothing is wanted from js-lint; the copy is what makes this phase run it. COPY --from=js-lint /src/yarn.lock /dev/null # Test phase. -race needs cgo and so a C compiler, which the Debian Go image # ships and the alpine one does not. `make test` (script/test) builds this # stage alone; the build stage below depends on it. # # golang:1.26.1-bookworm (Debian-based), 2026-03-17 FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . # static/static.go embeds the Alpine.js file this extracts from 3p/. RUN script/assets # -timeout applies to each package on its own, so 90s has only to clear the # slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most # four test binaries build or run at once, each with at most eight parallel # tests. Under -race every test binary and every link costs a few hundred MB, # so the defaults (one per core) add up to several GB on a many-core host. # # The first run has no -v: go test then prints one result line per package, # with its coverage, and for a package that fails, everything its tests # wrote. Verbose output from the whole suite passes the 2 MiB at which the # Docker build cuts off a step's log, so on a failure only the tests that # failed run again, with -v. go test reports a failed test as a line starting # "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with # its parent) and a failed package as "FAILpackage/path...". A # failure that names no test, such as a build error or a timeout, is already # shown in full, so there is nothing to rerun. The step fails after the rerun # whatever its result: the first run already showed the suite is broken. # # bash with pipefail, so that the first run's status is go test's, not tee's. SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \ packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \ if [ -n "$tests" ]; then \ echo "--- Rerunning the failed tests with -v for details ---"; \ go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \ fi; \ 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. The image # ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder # Nothing is wanted from the lint and test phases or from the stylesheet and # Markdown checks; the copies are what make BuildKit build them first, so # this stage cannot run unless they all passed. COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null COPY --from=css-check /out/tailwind.css /dev/null COPY --from=markdown-check /src/yarn.lock /dev/null # 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 . . # 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". ARG VERSION # A context that carries .git must not stamp an empty version, "dev" or # "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 version="$(make version VERSION="$VERSION")"; \ if [ -e .git ]; then \ case "$version" in ""|dev|unknown) \ echo "version is '$version' although .git is present" >&2; \ exit 1 ;; \ esac; \ fi # Builds through the Makefile's build target, which runs script/assets # (Alpine.js, extracted from its tarball in 3p/) first. 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"]