# The one image netwatch ships: nginx serves the built frontend and # passes /api/, /.well-known/healthcheck and /metrics to netwatch-server, # the Go backend, which runs in the same container on loopback only. # bin/entrypoint.sh starts and watches both. # # The lint and test phases are the gates: `make lint` (script/lint) # builds the lint stage alone and `make test` (script/test) the test # stage alone, and the two build stages, frontend-build and builder, # depend on both, so the image cannot be built unless they pass. Each # phase covers the frontend as well, through a copy from a node stage. # Inside them each tool is invoked directly, never through make or # script/, whose lint and test are themselves docker builds. # Frontend lint stage: eslint with the rules in eslint.config.js. The # lint phase below runs it. # node:22-alpine as of 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY . . RUN yarn eslint . # Lint phase: golangci-lint over the Go packages with .golangci.yml, # and eslint through the copy from frontend-lint at the end. The # golangci/golangci-lint image ships Go and the linter. # golangci/golangci-lint:v2.14.0, 2026-09-24 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 ./... # Nothing is wanted from frontend-lint; the copy is what makes this # phase run it. COPY --from=frontend-lint /app/yarn.lock /dev/null # Frontend test stage: the unit tests in test/unit/. The test phase # below runs it, and the frontend-build stage starts from it. The tests # print a dot each; if any fails, they run again with every test listed, # and the step fails even if that run passes. NODE_OPTIONS chooses the # reporter because yarn adds its arguments after the test files, where # node would take a reporter option for one more file. # node:22-alpine as of 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY . . RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \ { echo "--- Rerunning with every test listed for details ---"; \ NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \ exit 1; } # Test phase: the backend's tests with the race detector and coverage, # and the frontend's through the copy from the frontend-test stage at # the end. -race needs cgo and so a C compiler, which the Debian Go image # ships and the alpine one does not. -timeout 90s is a backstop above # the 60-second cap on the suite. The rerun with -v only shows details: # the step fails however it ends, because the first run already failed. # Go's module and build caches are in memory (tmpfs) for the go test # step alone, so the image make test tags does not carry them and the # build spends no time writing them into it. They start empty on every # build, so no stored test result can stand in for a run. # golang:1.25.7-trixie, 2026-10-07 FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test WORKDIR /src COPY . . RUN --mount=type=tmpfs,target=/go/pkg/mod \ --mount=type=tmpfs,target=/root/.cache/go-build \ go test -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -timeout 90s -race -v ./...; exit 1; } # Nothing is wanted from the frontend-test stage; the copy is what makes # this phase run its tests. COPY --from=frontend-test /app/yarn.lock /dev/null # Frontend build stage: the production build into dist/, which the # runtime stage serves, on the yarn packages and source of the # frontend-test stage. Nothing is wanted from the two phases; the copies # are what make BuildKit build them first, so this stage cannot run # unless lint and test passed. FROM frontend-test AS frontend-build COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null # vite.config.js reads the commit for the page's footer with git. RUN apk add --no-cache git RUN yarn build # Backend build stage. Nothing is wanted from the two 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-27) 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 same go build as in script/build, which builds the binary on the # host. # # The version is the VERSION build argument when one is given, otherwise # `git describe --tags --always` on the .git in the build context: the # tag on a tagged commit, tag-N-gHASH on a commit after one, the short # commit when no tag is reachable. 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 netwatch-server ./cmd/netwatch-server/ # Runtime stage, and the last one: a plain `docker build .` builds it # and the stages it copies from, the two phases included. # nginx:stable-alpine as of 2026-02-22 FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab # netwatch-server runs as this user, which owns the report directory. # nginx keeps the image's own arrangement: its main process runs as # root, its worker processes as the nginx user. RUN addgroup -g 1000 -S netwatch && \ adduser -u 1000 -S netwatch -G netwatch # At start-up the nginx image renders every template here into # conf.d; bin/entrypoint.sh says how. RUN rm /etc/nginx/conf.d/default.conf COPY configs/nginx.conf /etc/nginx/templates/netwatch.conf.template COPY configs/security-headers.conf /etc/nginx/security-headers.conf COPY --from=frontend-build /app/dist /usr/share/nginx/html COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server COPY bin/entrypoint.sh /usr/local/bin/entrypoint.sh # bin/entrypoint.sh creates DATA_DIR at start and gives it and /data to # the netwatch user, whatever is mounted there. ENV DATA_DIR=/data/reports VOLUME /data # The default public port; PORT changes it. EXPOSE 8080 # Requests the backend's health check through nginx, on the port from # PORT, so it fails unless both answer. upaas reads the result 60 # seconds after a deploy and fails the deploy unless it is healthy. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" # The nginx image stops its container with SIGQUIT; the entrypoint # acts on TERM and INT. STOPSIGNAL SIGTERM ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]