# 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. script/lint builds it alone. The linter is run directly: # `make lint` and script/lint are themselves a docker build. # golangci/golangci-lint:v2.14.0, 2026-10-15 FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint # The linter compiles every package, and govips needs the libvips # headers for that. REPO_POLICIES.md has the lint phase install them # itself; this image is Debian, so with apt-get rather than apk. RUN apt-get update \ && apt-get install -y --no-install-recommends libvips-dev \ && rm -rf /var/lib/apt/lists/* 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. script/test builds it alone. -race needs cgo and so a C # compiler, which the Debian Go image ships and the alpine one does not. # golang:1.25.4-trixie, 2026-10-15 FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test # govips compiles against the libvips and libheif headers, and the tests # need libvips' JPEG XL support and libheif's AVIF encoder. Debian's # libvips-dev brings all of them, the encoder as a recommended package, # so recommended packages are installed here. RUN apt-get update \ && apt-get install -y libvips-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY . . # Without -v first; on a failure, again with -v for the details, and # the step fails even if the second run passes. -count=1 because the # mounted cache also holds test results, which go test would otherwise # replay. -parallel 4: by default a package runs as many of its tests at # once as the host has CPUs, and on a busy host they then wait so long # to be scheduled that a test that times a request can see it return a # second late. RUN --mount=type=cache,target=/root/.cache/go-build \ go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; } # Build stage. Nothing is wanted from the two phases above: these copies # make BuildKit build them first, so nothing after them runs unless lint # and test passed. # golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder # git for the version, and for govips a C compiler, pkg-config and the # libvips and libheif headers RUN apk add --no-cache git build-base pkgconfig vips-dev libheif-dev 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 source code COPY . . # 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. A context without .git, as from a source tarball, stamps an # empty version. CGO stays enabled for govips; -trimpath keeps build # paths out of the binary, and -s -w leave out the symbol table and # debug information. 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; \ CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /pixad ./cmd/pixad # Runtime stage, and the last one: a plain `docker build .` builds this # stage and what it depends on, and nothing else. It must use the Alpine # release the golang image above is based on, so that pixad runs against # the libvips and musl it was built with. # alpine:3.22, 2026-10-08 FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 # Install runtime dependencies only. vips-jxl is libvips' JPEG XL # support, without which pixad does not start. RUN apk add --no-cache \ vips \ vips-jxl \ libheif \ ca-certificates \ tzdata \ su-exec ARG CACHEBUST # Copy binary from builder COPY --from=builder /pixad /usr/local/bin/pixad COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # Create non-root user, config directory, and data directory. pixad # gets uid and gid 65532, which host login and system accounts do not # use: a bind-mounted /var/lib/pixa is given to pixad, and on the host # it must not belong to a person's account. RUN addgroup -g 65532 pixad && \ adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \ mkdir -p /var/lib/pixa /etc/pixa && \ chown pixad:pixad /var/lib/pixa # No USER: the entrypoint must start as root to give a bind-mounted # /var/lib/pixa to pixad; it then runs the server as pixad. WORKDIR /var/lib/pixa EXPOSE 8080 # Shell form so the probe follows PORT; a port set only in a mounted # config file is not seen here. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --spider -q "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1 # Settings come from PORT and the PIXA_ environment variables; only # PIXA_SIGNING_KEY is required. A config file mounted at # /etc/pixa/config.yml is optional and is read when present. ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]