Files
docs/Dockerfile
zooqueen 744d91b1bf ported project docs are a snapshot, not build output
content/docs/projects/ was gitignored and held thousands of tracked
files at the same time, and every build re-mirrored it from hundreds of
upstream checkouts. Only the shipping image opted out, so the image
compiled the committed snapshot while a person's build compiled a fresh
mirror — one local build deleted 521 committed files and modified 444
more. One mirrored upstream imports a partial mid-page, which no alias
resolves, and the build failed with 54 unresolved specifiers after
silently skipping 119 further pages. The site had not built in a while.

The snapshot is the source: the ignore line is gone, the mirror leaves
the build and becomes a refresh run on purpose and read as a diff, and
the env knobs that let one command mean two different sites are
deleted. The section is named in the export gate, so losing it fails
rather than shipping a hole.

The generator's YAML escape emitted a backslash before a colon, which is
not an escape — the parser rejected the whole frontmatter block and
those pages shipped with no title, icon or description.
2026-08-20 15:20:07 -07:00

129 lines
6.9 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'). Ported project docs and the openapi specs are
# committed snapshots that the build reads and never refetches, here or anywhere
# — this stage used to say so with HANZO_DOCS_SYNC=0, which meant the image and
# a person's `pnpm --filter docs build` compiled different sites.
# 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 \
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:v0.5.6
# 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"]