# 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 builder stage depends on both, so the image # cannot be built unless they pass. Each 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 backend with backend/.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 backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ . 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 stage: the unit tests in test/unit/, then the production # build into dist/, which the runtime stage serves. The test phase below # runs 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 WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile # vite.config.js reads the commit for the page's footer with git. RUN apk add --no-cache git 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; } RUN yarn build # Test phase: the backend's tests with the race detector and coverage, # and the frontend's through the copy from the frontend 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 backend/ . 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 stage; the copy is what makes # this phase run its tests. COPY --from=frontend /app/yarn.lock /dev/null # 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 backend/go.mod backend/go.sum backend/ RUN cd backend && go mod download COPY . . # backend/script/build is the one definition of the build command: # CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=..." # It reads VERSION from the environment. # # 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; \ VERSION="$version" backend/script/build # 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 nginx.conf /etc/nginx/templates/netwatch.conf.template COPY security-headers.conf /etc/nginx/security-headers.conf COPY --from=frontend /app/dist /usr/share/nginx/html COPY --from=builder /src/backend/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"]