fix(image): ship node_modules in three layers, and count them before pushing #18

Merged
whitlocktech merged 1 commits from fix/registry-layer-cap into main 2026-08-27 04:23:20 +00:00
Member

The merge that landed phase 12 built its image and then could not publish it. docker push answered 413 Payload Too Large on one blob and stopped — the registry stayed empty, needs: build meant the deploy never ran, and the log said only that a digest was too large. The site was merged and undeployed.

One decision of record, D58, taking the count to fifty-eight.

Note added after merge: the port-binding change (D59) described in an earlier revision of this description is not in this pull request — it was written after this one had already merged, and is #19. What merged here is the layer split and the guard, below.

The limit is Cloudflare's, not Gitea's

gitea.whitlocktech.com is proxied (Server: cloudflare), and Cloudflare refuses a request body over 100 MB on every plan below Enterprise — a plan limit, not a setting. docker push uploads each layer as a single monolithic PUT, so the ceiling applies per layer, and the rejection happens at the edge: Gitea never sees the request, and nothing configured in Gitea can lift it.

That is also why this had never happened before. website-app is 103 MB in total, so every layer it has ever pushed fits.

What was over, measured

Built from the merge commit and pushed to a local registry to read the manifest's compressed sizes:

Layer Compressed
COPY node_modules 108.8 MB ← over by nine
node:22-bookworm-slim base 26.9 + 47.6 MB
the other ten < 3 MB each
total 188.6 MB

node_modules is 289 MB on disk, and two packages account for most of it: @pagefind (57 MB, the search binaries) and @img (27 MB, sharp's libvips). Both are needed at run time, not just build time — applyBrand.mjs re-indexes the site and re-derives the brand images at boot — so what could move was where they land, not whether they ship.

The fix: the same bytes, divided differently

The build stage moves those two aside after npm prune; the runtime stage copies them as their own layers.

before   COPY node_modules                    108.8 MB
after    COPY node_modules (remainder)         46.8 MB
         COPY @pagefind                        50.5 MB
         COPY @img                             11.5 MB

Largest layer 50.5 MB, about 45 MB of headroom, and the image is exactly the same total size — 188.6 MB either way. Moving rather than copying twice is what keeps the three disjoint: whatever remains in node_modules is the remainder by construction, so a dependency added later lands in it with no maintenance here.

The guard, because a split is a margin and not a guarantee

build-image.yml now counts layers before it pushes: docker save, re-compress anything over 8 MB the way the push would, and fail at 90 MB — not 100, since the blob is not the only thing in the request — with a message naming the layer and what would otherwise have happened.

It was tested in both directions, against the fixed image and the broken one:

rgsite:split     Largest layer: 50 MB compressed (limit 90 MB)   PASS
rgsite:measure   Largest layer: 108 MB compressed (limit 90 MB)
                 ::error::A layer is 108 MB compressed (…234066afc6ed). Cloudflare
                 rejects a request body over 100 MB, so this push would fail with
                 413 Payload Too Large and publish nothing.

Re-compressing agrees with what the registry actually recorded (108 vs 108.8 MB), so the number in the error is the real one.

This is a workflow step, not a twelfth check script: it needs a built image rather than a source tree, which is the one thing the eleven never have. D53 holds.

What was run

npm run verify, green end to end — eleven checks, astro check, both suites (36 + 7).

checkSidebar: 5 groups and 40 pages agree with plannedSidebar.
checkScreens: 17 screens, all present, sized and used.
checkTokens: 55 files scanned, every colour comes from src/styles/tokens.css.
checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, …
playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.
astro check: 0 errors        npm test: 36 pass, 0 fail
checkLinks: 2410 internal link(s) resolve.
checkFacts: 19 facts agree with their authorities.
checkQuickstart: 59 checks passed against website main.
checkReference: 22 enumeration check(s) passed against their sources.
test:served: 7 pass, 0 fail
checkA11y: 50 built pages pass all seven structural checks.
checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed.

And the part no script does — the rebuilt container was run, because moving two packages is exactly the change that measures fine and fails at boot:

Boot healthy, [serve] listening … 50 prerendered policies
Brand rewrite mounted brand applied to 51 files (siteName 294×, tagline 58×)
@pagefind from its new layer 50 pages re-indexed; /pagefind/pagefind.js 200
@img from its new layer favicon served x-brand-source: derived:mount — sharp derived it from the mounted logo
Per-request route /beta 200
Layers, re-measured after the split 46.8 / 50.5 / 11.5, nothing over 90 MB

Notes

  • Nothing on the host needs doing. The failed build never reached the deploy job, so there is no half-deployed container; this merge is the first one that will actually deploy.
  • DEPLOY.md gains the symptom (§10) and a paragraph in §7 saying it is fixed in the Dockerfile, not on the host.
  • The rgcom runner is registered and idle, in host mode, so §13's "start the runner" line is done — recorded in PLAN.md.

  • AI-assisted — written with Claude Code (Opus 5); commits carry Co-Authored-By: Claude.

🤖 Generated with Claude Code

The merge that landed phase 12 built its image and then could not publish it. `docker push` answered **`413 Payload Too Large`** on one blob and stopped — the registry stayed empty, `needs: build` meant the deploy never ran, and the log said only that a digest was too large. The site was merged and undeployed. One decision of record, **D58**, taking the count to **fifty-eight**. > **Note added after merge:** the port-binding change (**D59**) described in an earlier revision of this description is *not* in this pull request — it was written after this one had already merged, and is [#19](https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/pulls/19). What merged here is the layer split and the guard, below. ## The limit is Cloudflare's, not Gitea's `gitea.whitlocktech.com` is proxied (`Server: cloudflare`), and Cloudflare refuses a request body over **100 MB** on every plan below Enterprise — a plan limit, not a setting. `docker push` uploads each layer as a single monolithic `PUT`, so the ceiling applies **per layer**, and the rejection happens at the edge: Gitea never sees the request, and nothing configured in Gitea can lift it. That is also why this had never happened before. `website-app` is 103 MB in total, so every layer it has ever pushed fits. ## What was over, measured Built from the merge commit and pushed to a local registry to read the manifest's compressed sizes: | Layer | Compressed | |---|---| | `COPY node_modules` | **108.8 MB** ← over by nine | | `node:22-bookworm-slim` base | 26.9 + 47.6 MB | | the other ten | < 3 MB each | | **total** | **188.6 MB** | `node_modules` is 289 MB on disk, and two packages account for most of it: **`@pagefind`** (57 MB, the search binaries) and **`@img`** (27 MB, sharp's libvips). Both are needed at **run** time, not just build time — `applyBrand.mjs` re-indexes the site and re-derives the brand images at boot — so what could move was *where they land*, not whether they ship. ## The fix: the same bytes, divided differently The build stage moves those two aside after `npm prune`; the runtime stage copies them as their own layers. ``` before COPY node_modules 108.8 MB after COPY node_modules (remainder) 46.8 MB COPY @pagefind 50.5 MB COPY @img 11.5 MB ``` Largest layer **50.5 MB**, about 45 MB of headroom, and the image is **exactly the same total size** — 188.6 MB either way. Moving rather than copying twice is what keeps the three disjoint: whatever remains in `node_modules` is the remainder by construction, so a dependency added later lands in it with no maintenance here. ## The guard, because a split is a margin and not a guarantee `build-image.yml` now counts layers **before** it pushes: `docker save`, re-compress anything over 8 MB the way the push would, and fail at **90 MB** — not 100, since the blob is not the only thing in the request — with a message naming the layer and what would otherwise have happened. It was tested in both directions, against the fixed image and the broken one: ``` rgsite:split Largest layer: 50 MB compressed (limit 90 MB) PASS rgsite:measure Largest layer: 108 MB compressed (limit 90 MB) ::error::A layer is 108 MB compressed (…234066afc6ed). Cloudflare rejects a request body over 100 MB, so this push would fail with 413 Payload Too Large and publish nothing. ``` Re-compressing agrees with what the registry actually recorded (108 vs 108.8 MB), so the number in the error is the real one. This is a **workflow step, not a twelfth check script**: it needs a built image rather than a source tree, which is the one thing the eleven never have. D53 holds. ## What was run `npm run verify`, green end to end — eleven checks, `astro check`, both suites (36 + 7). ``` checkSidebar: 5 groups and 40 pages agree with plannedSidebar. checkScreens: 17 screens, all present, sized and used. checkTokens: 55 files scanned, every colour comes from src/styles/tokens.css. checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, … playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs. astro check: 0 errors npm test: 36 pass, 0 fail checkLinks: 2410 internal link(s) resolve. checkFacts: 19 facts agree with their authorities. checkQuickstart: 59 checks passed against website main. checkReference: 22 enumeration check(s) passed against their sources. test:served: 7 pass, 0 fail checkA11y: 50 built pages pass all seven structural checks. checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed. ``` And the part no script does — **the rebuilt container was run**, because moving two packages is exactly the change that measures fine and fails at boot: | | | |---|---| | Boot | healthy, `[serve] listening … 50 prerendered policies` | | Brand rewrite | mounted brand applied to **51 files** (`siteName` 294×, `tagline` 58×) | | `@pagefind` from its new layer | **50 pages re-indexed**; `/pagefind/pagefind.js` 200 | | `@img` from its new layer | favicon served `x-brand-source: derived:mount` — sharp derived it from the mounted logo | | Per-request route | `/beta` 200 | | Layers, re-measured after the split | 46.8 / 50.5 / 11.5, nothing over 90 MB | ## Notes - **Nothing on the host needs doing.** The failed build never reached the deploy job, so there is no half-deployed container; this merge is the first one that will actually deploy. - `DEPLOY.md` gains the symptom (§10) and a paragraph in §7 saying it is fixed in the `Dockerfile`, not on the host. - The `rgcom` runner is registered and idle, in host mode, so §13's "start the runner" line is done — recorded in PLAN.md. --- - [x] **AI-assisted** — written with Claude Code (Opus 5); commits carry `Co-Authored-By: Claude`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-27 04:11:18 +00:00
fix(image): ship node_modules in three layers, and count them before pushing
All checks were successful
PR checks / checks (pull_request) Successful in 9m47s
92f00ab20a
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>
whitlocktech merged commit e60812fb34 into main 2026-08-27 04:23:20 +00:00
whitlocktech deleted branch fix/registry-layer-cap 2026-08-27 04:23:20 +00:00
wtclaude changed title from fix(image): ship node_modules in three layers, and count them before pushing to fix(deploy): publish an image the registry accepts, and a port the network can reach 2026-08-27 04:41:22 +00:00
wtclaude changed title from fix(deploy): publish an image the registry accepts, and a port the network can reach to fix(image): ship node_modules in three layers, and count them before pushing 2026-08-27 04:42:38 +00:00
Sign in to join this conversation.
No description provided.