# Lint phase, built alone by script/lint. The linter is invoked directly # rather than through `make lint`, which is itself a docker build and # would recurse into a daemon that does not exist in a build step. # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-10-06 FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN golangci-lint run --config .golangci.yml ./... # Test phase, built alone by script/test. -race needs cgo and so a C # compiler, which the Debian Go image ships and the alpine one does not. # The tests query live DNS (TESTING.md), so this step needs the network. # -count=1 keeps Go's test result cache out of both runs, as TESTING.md # requires. -timeout 90s is a backstop above the 60-second cap on the # suite. The rerun with -v only shows details: the build fails however # it ends, because the first run already failed. # golang 1.25.7-trixie, 2026-10-06 FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test # The file permission tests skip themselves as root, so the tests run as # an ordinary user, whose home directory holds Go's build cache. RUN useradd --create-home tester USER tester WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go test -count=1 -race -timeout 90s -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -count=1 -race -timeout 90s -v ./...; exit 1; } # Markdown formatting with prettier, at the version package.json and # yarn.lock pin, so it is never installed on the host. script/fmt-check # builds the fmt-check stage and script/fmt the fmt-out stage; the image # does not depend on any of these stages, so a plain `docker build .` # skips them. # node:22-bookworm-slim, 2026-09-05 FROM node@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS nodedeps # prettier lives outside /src, so the COPY of the repo cannot overwrite # it and node_modules is not in the tree prettier walks. WORKDIR /tools COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile --non-interactive --no-progress ENV PATH="/tools/node_modules/.bin:${PATH}" WORKDIR /src # --config rather than discovery: a missing .prettierrc is then an error # instead of prettier's defaults, under which every wrap passes. # --no-editorconfig so .prettierrc alone sets the style. FROM nodedeps AS fmt-check COPY . . RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" # Only the markdown is copied out, with its paths, so the export cannot # put anything else back over the working tree. FROM nodedeps AS fmt COPY . . RUN prettier --config .prettierrc --no-editorconfig --write "**/*.md" && \ mkdir -p /out && \ find . -name '*.md' -type f -exec cp --parents '{}' /out/ ';' FROM scratch AS fmt-out COPY --from=fmt /out/ / # Build stage. Nothing is wanted from the lint and test phases; the # copies are what make BuildKit build them first, so this stage cannot # run unless lint and test passed. # golang 1.25-alpine, 2026-02-28 FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null RUN apk add --no-cache git # A tar-stream context keeps the sender's file owners, which git refuses. RUN git config --system --add safe.directory /src WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . # The VERSION build arg when one is given (script/docker and # script/cibuild pass one), otherwise `git describe --tags --always` on # the .git in the build context. With .git present, a version that is # still empty, dev or unknown fails the build: git is missing or could # not read the checkout. ARG VERSION RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ case "$VERSION" in ""|dev|unknown) \ echo "version is '$VERSION' although .git is present" >&2; \ exit 1 ;; \ esac; \ fi; \ CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /src/bin/dnswatcher ./cmd/dnswatcher/ # Runtime stage, and the last one: a plain `docker build .` builds this # stage's chain and nothing else. # alpine 3.21, 2026-02-28 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 RUN apk add --no-cache ca-certificates tzdata su-exec COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # dnswatcher runs as this unprivileged user. The entrypoint creates the # data directory and gives it to this user on every start. RUN addgroup -S -g 10001 dnswatcher \ && adduser -S -G dnswatcher -u 10001 dnswatcher ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher # Config loading also reads a `.env` file and a file named `dnswatcher` # (any config extension, or none) from the working directory. `/` holds # neither, so every setting comes from the environment. Do not make the # data directory, or the binary's directory, the working directory. WORKDIR / # No USER: the entrypoint must start as root to set up the data # directory; it then runs dnswatcher as the dnswatcher user. EXPOSE 8080 # busybox wget (already in alpine) probes the health endpoint every 10 # seconds, so the container is healthy well before upaas reads its health # 60 seconds after a deploy and fails the deploy unless it is healthy. HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]