# Every stage declares ARG CACHEBUST first: script/ passes a new value # on every build, which reruns every RUN step after it. The steps that # compile mount Go's build cache, which survives 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 ARG CACHEBUST # 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 COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ 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 ARG CACHEBUST WORKDIR /src # script/bootstrap --cgo installs the build dependencies (the libvips # and libheif headers, and libvips' JPEG XL support, which the tests # need) and downloads the Go modules. COPY script/ ./script/ COPY go.mod go.sum ./ RUN script/bootstrap --cgo 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 this stage runs only when 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 ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null WORKDIR /src # Build dependencies, git among them, and Go modules, as in the test # phase COPY script/ ./script/ COPY go.mod go.sum ./ RUN script/bootstrap --cgo # 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 ARG CACHEBUST # 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 # 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"]