# In every stage ARG CACHEBUST comes right after the steps that install # system packages and download Go's modules, or right after FROM in a # stage with neither, and every step that lints, tests or compiles # comes after it. script/ passes a new value on every build, which # reruns every RUN step after it. Docker keeps the layers above it # until the base image, their own lines or go.mod and go.sum change. # The steps that compile mount Go's build cache, and golangci-lint run # also mounts golangci-lint's; both survive from build to build. # Lint phase. The image is Debian-based and has gcc, so it can check the # cgo SQLite package without extra installs. # golangci/golangci-lint:v2.14.0 (Go 1.27.0), 2026-10-19 FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/golangci-lint \ golangci-lint run --config .golangci.yml ./... # Test phase. -race needs cgo and so a C compiler, which the Debian Go # image ships and the alpine one does not. -count=1 because the mounted # cache also holds test results, which go test would otherwise replay. # -short skips the live RIS Live feed test, so the suite needs no network. # golang:1.27.2-bookworm, 2026-10-19 FROM golang@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS test WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ go test -short -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -short -count=1 -timeout 90s -race -v ./...; exit 1; } # Build stage. Nothing is wanted from either phase above; the copies # are what make BuildKit build them first, so nothing after them can # run unless lint and test passed. # golang:1.27.2-bookworm, 2026-10-19 FROM golang@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS builder # Install build dependencies (zstd for archive, gcc for CGO/sqlite3) RUN apt-get update && apt-get install -y --no-install-recommends \ zstd \ gcc \ libc6-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null # A tar-stream context keeps the sender's file owners, which git refuses. RUN git config --system --add safe.directory /src COPY . . # Vendor dependencies for the source archive (must be after copying source) RUN go mod vendor # Build the binary with CGO enabled (required for sqlite3). The version the # page footer shows is the VERSION build arg 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 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. # Without .git the footer says unknown. The footer links to the full commit. ARG VERSION RUN --mount=type=cache,target=/root/.cache/go-build \ 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; \ REVISION="$(git rev-parse --verify HEAD || echo unknown)"; \ CGO_ENABLED=1 GOOS=linux go build -o /routewatch -ldflags "\ -X git.eeqj.de/sneak/routewatch/internal/version.GitRevision=$REVISION \ -X git.eeqj.de/sneak/routewatch/internal/version.GitRevisionShort=${VERSION:-unknown}" \ ./cmd/routewatch # Create source archive with vendored dependencies RUN tar --zstd -cf /routewatch-source.tar.zst \ --exclude='.git' \ --exclude='*.tar.zst' \ . # Runtime stage, and the last one # debian:bookworm-slim, 2026-09-21 FROM debian@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 # Install runtime dependencies # - ca-certificates: for HTTPS connections # - curl: for health checks RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ && rm -rf /var/lib/apt/lists/* ARG CACHEBUST # Create non-root user RUN useradd -r -u 1000 -m routewatch RUN mkdir -p /var/lib/berlin.sneak.app.routewatch && chown routewatch:routewatch /var/lib/berlin.sneak.app.routewatch RUN mkdir /app WORKDIR /app # Copy binary and source archive from builder COPY --from=builder /routewatch /app/routewatch COPY --from=builder /routewatch-source.tar.zst /app/source/routewatch-source.tar.zst # Set ownership RUN chown -R routewatch:routewatch /app ENV XDG_DATA_HOME=/var/lib # Cap the Go heap at 1.5 GiB so the runtime collects harder before the # container's memory limit is reached. setpriv in the entrypoint preserves this # the way it does XDG_DATA_HOME above. ENV GOMEMLIMIT=1536MiB # Cap glibc's malloc arenas. The SQLite C library allocates and frees millions # of small page-cache chunks from many threads; glibc otherwise creates up to # eight arenas per core (hundreds on a large host) and keeps each arena's freed # chunks resident, so process RSS climbs far above SQLite's live heap and never # comes back down. Two arenas keep that retained memory bounded; database writes # are already serialized, so the lost allocator concurrency costs nothing here. ENV MALLOC_ARENA_MAX=2 # Expose HTTP port EXPOSE 8080 COPY ./entrypoint.sh /entrypoint.sh # Health check using the health endpoint, on the port PORT names HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1 ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]