# 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"]