All checks were successful
PR checks / checks (pull_request) Successful in 9m47s
The merge that landed phase 12 built its image and could not publish it. `docker push` answered 413 Payload Too Large on one blob and stopped, so the registry stayed empty and `needs: build` meant the deploy never ran — the site was merged and undeployed, and the log said only that a digest was too large. The limit is Cloudflare's, not Gitea's: the instance is proxied, and Cloudflare refuses a request body over 100 MB below Enterprise. A push uploads each layer as one monolithic PUT, so the ceiling is per layer and the rejection happens at the edge, where Gitea never sees it and no Gitea setting can lift it. One layer was over, by nine megabytes: COPY node_modules at 108.8 MB compressed, in a 188.6 MB image whose next largest layer is the 47.6 MB Node base. @pagefind and @img (sharp's libvips) account for it and both are needed at run time — the boot rewrite re-indexes the site and re-derives the brand images — so what could move is where they land, not whether they ship. The build stage moves them aside after `npm prune` and the runtime stage copies them as their own layers: 46.8 + 50.5 + 11.5 MB, and the image is exactly the same total size, the same bytes divided differently. Moving rather than copying twice keeps the three disjoint, so a dependency added later needs no maintenance here. A split is a margin and not a guarantee, so the workflow now counts layers before it pushes: docker save, re-compress anything over 8 MB the way the push would, fail at 90 MB — not 100, the blob is not the only thing in the request — naming the layer and what would have happened. Tested in both directions; on the broken image it reports 108 MB, which is what the registry recorded. Verified by running the built container, not only by measuring it: healthy in ~25s, the mounted brand rewritten across 51 files, 50 pages re-indexed by pagefind, and the favicon served as `x-brand-source: derived:mount` — which is sharp resolving from its new layer. npm run verify is green, all eleven checks and both suites. Recorded as D58; DEPLOY.md gains the symptom and what it means for the host (nothing — the running container is untouched). Co-Authored-By: Claude <noreply@anthropic.com>
133 lines
6.8 KiB
Docker
133 lines
6.8 KiB
Docker
# runicgateway.com — the one container (PLAN.md §6).
|
|
#
|
|
# Two stages. The first has the whole toolchain and produces `dist/`; the second
|
|
# carries the built site, the pruned runtime dependencies and nothing else.
|
|
#
|
|
# Debian slim rather than Alpine, deliberately. Two of the runtime dependencies
|
|
# are native — `better-sqlite3` (the beta store, §8) and `sharp` (the brand
|
|
# derivations, §7) — and both publish prebuilt binaries for glibc. On musl they
|
|
# are compiled from source instead, which means a C++ toolchain, libvips headers
|
|
# and several minutes in the image build, to save about sixty megabytes on a
|
|
# thing that is pulled a few times a year. A third, `pagefind`, ships a platform
|
|
# binary and is needed at RUN time, not just build time: the boot rewrite
|
|
# re-indexes the site after the brand strings change.
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
# Stage 1 — build
|
|
# ---------------------------------------------------------------------------------------
|
|
FROM node:22-bookworm-slim AS build
|
|
|
|
WORKDIR /build
|
|
|
|
# Dependencies first, so an edit to a page does not re-resolve the tree.
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci
|
|
|
|
# Then the source. .dockerignore keeps node_modules, dist and BOTH bind mounts out.
|
|
COPY . .
|
|
|
|
# Prerenders every marketing, legal and documentation page, builds the Node
|
|
# server entry for the two routes that run per request, and writes the per-route
|
|
# Content-Security-Policy into dist/_headers.json (D48).
|
|
#
|
|
# No token and no network: everything the build reads is in this context. The
|
|
# checks that DO need the Gitea API — facts, quickstart, reference — run in CI
|
|
# against the pull request, which is the right place for them. An image build
|
|
# that could fail because another repository's server was slow would be an image
|
|
# build people learn to retry rather than read.
|
|
RUN npm run build
|
|
|
|
# Drop the devDependencies from the tree the runtime stage inherits. Pruning
|
|
# here rather than running a second `npm ci --omit=dev` below keeps the native
|
|
# modules exactly as they were resolved and built once.
|
|
RUN npm prune --omit=dev
|
|
|
|
# Set the two largest packages aside so the runtime stage can copy them as their
|
|
# own layers. See the COPY block below for why a single node_modules layer could
|
|
# not be pushed at all. Moving them rather than copying them twice is what keeps
|
|
# the three layers disjoint: whatever is left in node_modules is exactly the
|
|
# remainder, and a dependency added later lands in it automatically.
|
|
RUN mkdir -p /split \
|
|
&& mv node_modules/@pagefind /split/ \
|
|
&& mv node_modules/@img /split/
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
# Stage 2 — runtime
|
|
# ---------------------------------------------------------------------------------------
|
|
FROM node:22-bookworm-slim AS runtime
|
|
|
|
WORKDIR /app
|
|
|
|
ENV NODE_ENV=production \
|
|
HOST=0.0.0.0 \
|
|
PORT=4321
|
|
|
|
# Both bind mounts, named here so the code's own `process.cwd()` defaults are
|
|
# never what a container relies on. See docker-compose.yml.
|
|
ENV BRAND_DIR=/app/brand \
|
|
DATA_DIR=/app/data
|
|
|
|
# The stock brand, baked in and always complete (§7). Every /brand/* URL resolves
|
|
# against the mount first and this second, per file.
|
|
ENV BRAND_DEFAULT_DIR=/app/brand-default
|
|
|
|
# `dist/` is owned by `node` because the boot rewrite WRITES to it: applyBrand.mjs
|
|
# rewrites the prerendered HTML from what it last applied to what the mount now
|
|
# says, records that in dist/.brand-applied.json, and re-indexes dist/client/pagefind
|
|
# so search finds the mounted site name. A read-only dist would make §7's promise
|
|
# — recolour and rename by copying a file — fail at boot with a permission error.
|
|
# node_modules arrives in THREE layers, not one, and the reason is the registry
|
|
# rather than anything about the site.
|
|
#
|
|
# Gitea sits behind Cloudflare, which refuses a request body over 100 MB on every
|
|
# plan below Enterprise, and `docker push` uploads each layer as one monolithic
|
|
# PUT. A single `COPY node_modules` measured **108.8 MB compressed** — nine over —
|
|
# so the first merge to `main` after phase 12 failed with `413 Payload Too Large`
|
|
# on that one blob, from the edge, with Gitea never seeing the request. Nothing
|
|
# was published, and `needs: build` meant nothing was deployed either.
|
|
#
|
|
# `@pagefind` (the search index binaries) and `@img` (sharp's libvips) are the two
|
|
# packages that make it fat and both are needed at RUN time — the boot rewrite
|
|
# re-indexes the site and re-derives the brand images — so the fix is where they
|
|
# land, not whether they ship. Split, they measure 54.7 + 50.6 + 12.1 MB, the
|
|
# largest with about 45 MB of headroom.
|
|
#
|
|
# That headroom is why the workflow counts layers before it pushes: this is a
|
|
# margin, not a guarantee, and a dependency that grows past it would otherwise
|
|
# come back as the same unreadable 413. See `.gitea/workflows/build-image.yml`.
|
|
COPY --from=build --chown=node:node /build/node_modules ./node_modules
|
|
COPY --from=build --chown=node:node /split/@pagefind ./node_modules/@pagefind
|
|
COPY --from=build --chown=node:node /split/@img ./node_modules/@img
|
|
COPY --from=build --chown=node:node /build/dist ./dist
|
|
COPY --from=build --chown=node:node /build/brand-default ./brand-default
|
|
COPY --from=build --chown=node:node /build/scripts ./scripts
|
|
COPY --from=build --chown=node:node /build/package.json ./package.json
|
|
|
|
# `src/` is here for one reason: the tester-list CLI. §8 has no admin page by
|
|
# design, so managing the closed beta is `docker compose exec site node
|
|
# scripts/beta.mjs …`, and that reaches into src/lib/betaStore.mjs. Nothing
|
|
# serving a request reads it — the pages were prerendered in stage 1.
|
|
COPY --from=build --chown=node:node /build/src ./src
|
|
|
|
# Both mount points exist in the image, owned by the runtime user. An operator
|
|
# who forgets a mount then gets a working stock site and an empty store rather
|
|
# than a container that will not start; and `data/` being writable by uid 1000
|
|
# BEFORE Docker creates it is what stops the store failing to open. If the host
|
|
# directory is owned by someone else, `chown 1000:1000 ./data` on the host.
|
|
RUN mkdir -p /app/brand /app/data && chown -R node:node /app/brand /app/data
|
|
|
|
USER node
|
|
|
|
EXPOSE 4321
|
|
|
|
# Cheap, and it tests the thing that actually breaks: `npm start` runs the brand
|
|
# rewrite BEFORE the server, so a container can sit alive for a long moment with
|
|
# nothing listening. A healthcheck that only watched the process would call that
|
|
# healthy.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
|
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 4321) + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
|
|
|
# applyBrand.mjs, then serve.mjs. Not dist/server/entry.mjs directly — see the
|
|
# header comment in scripts/serve.mjs for the adapter bug that wrapper exists for.
|
|
CMD ["npm", "start"]
|