Files
docs/Dockerfile
zeekayandHanzo Dev e3b2d13fdf Take the static base that ships arm64
ghcr.io/hanzoai/static served only linux/amd64 from v0.5.1 through 0.5.14,
so an image built on it has no manifest an arm64 node can pull. 0.5.15 is
the same server source published as an index over both architectures.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 21:22:29 -07:00

128 lines
6.7 KiB
Docker

# The ONE Fumadocs build for this monorepo, exported static and served by
# hanzoai/static (the house static server; no nginx anywhere in the stack).
#
# APP selects which workspace to export, so every static site here is built the
# same way and the recipe exists once:
#
# docker build . # docs.hanzo.ai
# docker build --build-arg APP=gui-docs . # any sibling
#
# The siblings still reach Cloudflare Pages through their own workflows in
# .hanzo/workflows/. This is the path they move onto, one host at a time, as each
# gets a verified App CR in hanzoai/universe (see LLM.md).
#
# Built by the platform-native fabric (POST /v1/runner → buildkit Job with this
# repo as the git context) and deployed as the docs-landing Service CR at
# docs.hanzo.ai. Mirrors the pnpm recipe the retired GitHub workflow ran:
# NEXT_EXPORT=1 (output:'export'), HANZO_DOCS_SYNC=0 (committed project-docs +
# openapi snapshots — the in-cluster re-sync is incomplete by design).
# build:pre runs `bun ./scripts/*.ts` (gen-services-nav, pre-build). node:alpine has
# no bun, so the build stage produced an empty export → an empty /public → 404s.
# Bring the musl bun binary in from the official image.
FROM oven/bun:1-alpine AS bun
# Prune the workspace to what ${APP} actually needs, BEFORE anything is installed.
#
# This repo is 21 apps and 36 packages; apps/docs depends on 11 of them. Installing
# the whole workspace put node_modules for all 57 on the runner's 38Gi emptyDir,
# alongside .next and out, and the kubelet evicts the runner over that line — after
# a build that had already completed every one of its tasks.
#
# `turbo prune --docker` emits a workspace containing only ${APP} and its
# transitive internal deps. It is the same source and the same lockfile, so the
# image is unchanged; what changes is how much of the monorepo ever lands on disk.
FROM public.ecr.aws/docker/library/node:22-alpine AS pruner
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /src
COPY . .
ARG APP=docs
RUN pnpm dlx [email protected] prune "${APP}" --docker
FROM public.ecr.aws/docker/library/node:22-alpine AS build
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apk add --no-cache git libstdc++ libgcc && corepack enable && corepack prepare [email protected] --activate
WORKDIR /src
COPY --from=pruner /src/out/json/ ./
COPY --from=pruner /src/out/full/ ./
# turbo prune emits the workspace, not the repo: root-level scripts are not in it
# and the export gate below runs one. Copied explicitly so the gate still holds.
COPY --from=pruner /src/scripts/ ./scripts/
ENV NEXT_EXPORT=1 \
HANZO_DOCS_SYNC=0 \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=24576
ARG APP=docs
# The ingest key, gated HERE for the same reason the export gate is here: this is
# the one thing every builder passes through. A guard in deploy.yml protects one
# of six lanes, and the lane that produced the last live image was not that one,
# so the guard never fired and the bundle shipped keyless.
#
# PUBLISHABLE_KEY is the name in KMS and on the --build-arg; NEXT_PUBLIC_ is
# added here because that prefix is what makes Next inline it.
#
# Fail closed: an empty key builds, serves and looks correct while cloud files
# every pageview under $public, which this org cannot read, and ingest answers
# 200 either way. Refuse the artifact instead.
ARG PUBLISHABLE_KEY=
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
RUN case "$PUBLISHABLE_KEY" in \
pk-*) : ;; \
'') echo "PUBLISHABLE_KEY is empty - pass --build-arg PUBLISHABLE_KEY=<pk-...> (KMS deploy/PUBLISHABLE_KEY, env prod)" >&2; exit 1 ;; \
*) echo "PUBLISHABLE_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
esac
# Install, build, and drop every intermediate IN ONE LAYER. Only `out/` survives.
#
# The single `&&` chain is the whole point, and it is a disk budget rather than
# tidiness. A RUN is a layer, and a layer stores what existed when it ended — so
# installing in one RUN and deleting in a later one frees NOTHING: node_modules
# is still in the earlier layer, on disk, for the rest of the build. Only files
# that never outlive their own layer cost nothing.
#
# What that saves here: node_modules for a 21-app / 36-package workspace, plus
# .next (~6.9G), plus the turbo cache (~2.4G), plus the pnpm store. The runner's
# docker storage is a 38Gi emptyDir and this build wanted more than all of it.
#
# Over that line the kubelet EVICTS the runner pod --
# Evicted: Usage of EmptyDir volume "docker-storage" exceeds the limit "38Gi"
# -- which looks nothing like a build failure. The pod restarts, so dockerd
# restarts, so the job log truncates with no error and the run sits in_progress.
# It reads exactly like an OOM and is not one; MemoryPressure stays False and
# node memory sits near 7%. Check `kubectl -n hanzo get events | grep -i evict`
# before believing anything the build log implies about why it stopped.
#
# Splitting this chain back into separate RUNs will reintroduce the eviction
# without changing a line of application code.
#
# Nothing downstream needs the toolchain: the export gate reads `out/` with sh,
# and the serving stage copies `out/` alone.
RUN pnpm install --frozen-lockfile \
&& pnpm build --filter="${APP}" \
&& rm -rf node_modules apps/*/node_modules packages/*/node_modules \
"apps/${APP}/.next" .turbo \
/root/.cache /root/.local/share/pnpm /root/.npm
# The export gate, INSIDE the recipe — so it is not a property of one builder.
#
# A static-export build fails by exporting nothing: the layer is valid, the push
# succeeds, and the host answers 404. Gating here means the failure happens
# before an image exists, for every builder, without any of them knowing this
# repo is special — buildx `--push` builds and pushes in one invocation and has
# no step in between to hold, and an in-cluster BuildKit job has none either.
# scripts/check-export.sh states what "a site" means; apps/${APP}/export.require
# names the sections this app must not silently lose.
RUN sh scripts/check-export.sh "apps/${APP}/out"
# v0.5.1 had no content-encoding path at all, so this site shipped every byte
# identity-encoded: 1,527,061 bytes of HTML on the index alone, a 454 KB
# sitemap and a 1 MB RSC prefetch, on every cold load, for a document that is
# ~14% entropy. hanzoai/static gained gzip in 900bccf and released it as
# v0.5.6. Measured by running the v0.5.6 binary over this site's own index:
# 1,527,061 -> 213,911 bytes with Vary: Accept-Encoding, the PNG untouched,
# and Accept-Encoding: gzip;q=0 correctly served identity.
FROM ghcr.io/hanzoai/static:0.5.15
# Re-declared: an ARG is scoped to the stage that names it.
ARG APP=docs
COPY --from=build /src/apps/${APP}/out /public
EXPOSE 3000
ENTRYPOINT ["/static", "-port", "3000", "-root", "/public"]