# syntax=docker/dockerfile:1.7 # # Go Bananas!! container image. # # Design notes # ------------ # * The Go build is fully static. modernc.org/sqlite is a pure-Go translation of # SQLite, so CGO is not needed and the runtime image needs no libc at all. That # is what lets the final stage be `scratch`-adjacent: a smaller image, and no # shared-library CVEs to patch. # * The frontend and the SDK are built before the Go binary and copied in, so the # image build fails if the frontend fails. A container that starts and then 404s # on its own dashboard is worse than one that does not start. # * Dependencies are installed from a lockfile in their own layer, so a source-only # change does not re-download the module cache. # * BuildKit cache mounts are used for the module and package caches. They are a # cache, not an artefact, so they are not part of the image. # ---------------------------------------------------------------- arguments -- ARG GO_VERSION=1.27.1 ARG NODE_VERSION=24.9.0 ARG ALPINE_VERSION=3.22 # ------------------------------------------------------------- web and sdk ---- FROM node:${NODE_VERSION}-alpine AS assets # Corepack is enabled so that the pinned package manager in package.json is the one # that runs, rather than whatever npm happens to ship with the Node release. RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /src/web COPY web/package.json web/package-lock.json* ./ # `npm ci` when a lockfile is present, `npm install` otherwise. The fallback keeps # the image buildable from a source tree that has not installed yet, while CI # always takes the reproducible path. RUN --mount=type=cache,target=/root/.npm \ if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ else npm install --no-audit --no-fund; fi COPY web/ ./ RUN npm run build WORKDIR /src/sdk COPY sdk/package.json sdk/package-lock.json* ./ RUN --mount=type=cache,target=/root/.npm \ if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ else npm install --no-audit --no-fund; fi COPY sdk/ ./ RUN npm run build # ------------------------------------------------------------------- go ------ FROM golang:${GO_VERSION}-alpine AS build ARG VERSION=dev ARG COMMIT=unknown ARG BUILD_DATE=unknown ARG TARGETOS=linux ARG TARGETARCH=amd64 # git is needed for the version stamping below when the build runs outside a # checkout; ca-certificates for module downloads. RUN apk add --no-cache git ca-certificates WORKDIR /src # Dependencies first, in their own layer. go.sum is copied and verified so that a # tampered or truncated lockfile fails the build rather than silently resolving # something different. COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download && go mod verify COPY internal/ ./internal/ COPY cmd/ ./cmd/ ARG CGO_ENABLED=0 ARG GOFLAGS=-trimpath # -trimpath removes local filesystem paths from the binary, which keeps build paths # out of any stack trace a user might paste into a bug report. # -s -w drop the symbol table and DWARF data: neither is useful without a matching # local binary, and together they are a substantial fraction of the image size. RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=${CGO_ENABLED} GOFLAGS=${GOFLAGS} \ go build \ -ldflags "-s -w -X github.com/urpi/go-bananas/internal/app.Version=${VERSION} -X main.version=${VERSION} -X github.com/urpi/go-bananas/internal/app.Commit=${COMMIT} -X github.com/urpi/go-bananas/internal/app.BuildDate=${BUILD_DATE}" \ -o /out/go-bananas ./cmd/go-bananas # Verify the binary is genuinely static before shipping it. A build that silently # picked up cgo would produce an image that fails at run time with a confusing # "no such file or directory" rather than at build time with a clear one. RUN if [ "${CGO_ENABLED}" = "0" ]; then \ if go version -m /out/go-bananas | grep -q 'CGO_ENABLED=1'; then \ echo "error: binary is dynamically linked but CGO_ENABLED=0" >&2; exit 1; \ fi; \ fi # ---------------------------------------------------------------- runtime ---- # Alpine rather than distroless or scratch: the service needs a shell for a health # check and a writable directory for SQLite, and Alpine supplies both in ~8 MB. The # attack surface difference against distroless is small next to the operational # benefit, and the runtime user is unprivileged either way. FROM alpine:${ALPINE_VERSION} AS runtime ARG VERSION=dev # curl is only here for the container health check. wget from busybox would do, but # it is already present and curl's exit codes are the ones the check reasons about. # tzdata is needed because timestamps are rendered in the configured locale. RUN apk add --no-cache ca-certificates tzdata curl \ && addgroup -g 65532 -S bananas \ && adduser -u 65532 -S -G bananas -H -s /sbin/nologin bananas COPY --from=build /out/go-bananas /usr/local/bin/go-bananas COPY --from=assets /src/web/dist /srv/web COPY --from=assets /src/sdk/dist /srv/sdk # The data directory holds the SQLite file. It is a volume in every real deployment; # creating it here means an unconfigured local run still starts. RUN mkdir -p /srv/data && chown -R bananas:bananas /srv USER 65532:65532 WORKDIR /srv ENV BANANAS_HTTP_ADDR=:8080 \ BANANAS_STATIC_DIR=/srv/web \ BANANAS_SDK_DIR=/srv/sdk \ BANANAS_DATABASE_PATH=/srv/data/go-bananas.db \ BANANAS_LOG_FORMAT=json EXPOSE 8080 VOLUME ["/srv/data"] # The health check uses the liveness probe rather than readiness: a container that # is alive but not ready should stay in the load-balancer pool's view of "starting" # rather than be restarted, and Docker's HEALTHCHECK does not have that concept. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl --fail --silent --show-error http://127.0.0.1:8080/healthz || exit 1 ENTRYPOINT ["/usr/local/bin/go-bananas"] # --------------------------------------------------------------- final ------ FROM runtime AS final ARG VERSION=dev LABEL org.opencontainers.image.title="go-bananas" \ org.opencontainers.image.description="Runtime design-token service: author themes, serve them to any application at run time." \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.source="https://github.com/urpi/go-bananas" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.base.name="docker.io/library/alpine:3.22"