From 395e201d77580e77aebc71af1202e87e66b5ac87 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:26:03 -0400 Subject: [PATCH 1/2] Improve: smaller image and one nginx worker - Copy the cleaned Node base into a scratch stage so the removed npm, yarn, corepack and C headers no longer ship in a lower layer (about 24 MB smaller). - Remove /usr/local/include/node from the runtime image. - Set nginx worker_processes to 1 instead of one worker per host core. - Update the Dockerfile shape tests for the new stages. --- Dockerfile | 23 +++++++++++++++-------- api/test/nginx-config.test.js | 6 ++++++ test/repo/readme-dependencies.test.js | 3 ++- test/repo/supervisord-fatal.test.js | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7ed8ceef..1fc9fd87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,17 +8,13 @@ COPY nginx/ ./nginx/ COPY scripts/bump-cache-busting.js ./scripts/bump-cache-busting.js RUN node scripts/bump-cache-busting.js -FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f +FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f AS base -LABEL org.opencontainers.image.title="Stackyard" \ - org.opencontainers.image.description="Self-hosted homelab dashboard" \ - org.opencontainers.image.source="https://github.com/SandObserver/stackyard" \ - org.opencontainers.image.licenses="Apache-2.0" - -# No package manager in the runtime image. The check fails the build if a base -# image change makes a path stop matching. +# No package manager and no C headers in the runtime image. The check fails the +# build if a base image change makes a path stop matching. RUN rm -rf /usr/local/lib/node_modules/npm \ /usr/local/lib/node_modules/corepack \ + /usr/local/include/node \ /opt/yarn-* && \ rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ /usr/local/bin/yarn /usr/local/bin/yarnpkg && \ @@ -27,6 +23,15 @@ RUN rm -rf /usr/local/lib/node_modules/npm \ fi && \ node -e "process.exit(0)" +FROM scratch +COPY --from=base / / +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +LABEL org.opencontainers.image.title="Stackyard" \ + org.opencontainers.image.description="Self-hosted homelab dashboard" \ + org.opencontainers.image.source="https://github.com/SandObserver/stackyard" \ + org.opencontainers.image.licenses="Apache-2.0" + # Keep `apk upgrade` first. The base image is pinned by digest and gets no # security updates without it. # @@ -41,6 +46,8 @@ RUN echo "packages upgraded for ${APP_VERSION}" && \ apk upgrade --no-cache && \ apk add --no-cache nginx supervisor && \ rm -f /etc/nginx/conf.d/default.conf /etc/nginx/http.d/default.conf && \ + sed -i 's/^worker_processes auto;$/worker_processes 1;/' /etc/nginx/nginx.conf && \ + grep -qx 'worker_processes 1;' /etc/nginx/nginx.conf && \ mkdir -p /var/log/nginx /var/log/supervisor /var/lib/nginx /run/nginx && \ # Users mount volumes here. Owned by node so the API can write config and # icons without running as root. diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index dc821a2f..1b9e3d8d 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -480,6 +480,12 @@ test('the image stamps assets itself, so a locally built image is not pinned to ); }); +test('the image runs one nginx worker, not one per host core', () => { + const dockerfile = fs.readFileSync(path.join(__dirname, '../../Dockerfile'), 'utf8'); + assert.match(dockerfile, /sed -i 's\/\^worker_processes auto;\$\/worker_processes 1;\/' \/etc\/nginx\/nginx\.conf/); + assert.match(dockerfile, /grep -qx 'worker_processes 1;' \/etc\/nginx\/nginx\.conf/); +}); + /* ── the API policy ───────────────────────────────────────────────────────── */ /* /api/ sets its own headers, so the server-level policy does not reach it. The diff --git a/test/repo/readme-dependencies.test.js b/test/repo/readme-dependencies.test.js index a9f674d3..1827dc4c 100644 --- a/test/repo/readme-dependencies.test.js +++ b/test/repo/readme-dependencies.test.js @@ -24,5 +24,6 @@ test('the API declares no npm dependencies', () => { test('the image installs nginx and supervisor and no other package', () => { const adds = [...read('Dockerfile').matchAll(/apk add\s+((?:--\S+\s+)*)([^&\\\n]+)/g)].map(m => m[2].trim()); assert.deepEqual(adds, ['nginx supervisor']); - assert.match(read('Dockerfile'), /^FROM node:\d+-alpine@sha256:[0-9a-f]{64}$/m); + assert.match(read('Dockerfile'), /^FROM node:\d+-alpine@sha256:[0-9a-f]{64} AS base$/m); + assert.match(read('Dockerfile'), /^COPY --from=base \/ \/$/m); }); diff --git a/test/repo/supervisord-fatal.test.js b/test/repo/supervisord-fatal.test.js index 248341e1..1537484f 100644 --- a/test/repo/supervisord-fatal.test.js +++ b/test/repo/supervisord-fatal.test.js @@ -177,7 +177,7 @@ test('the rest of scripts/ is still kept out of the build context', () => { image would ship build tooling to every user. */ test('only the supervisor listener reaches the runtime image', () => { const runtime = read('Dockerfile') - .split(/^FROM node:24-alpine(?:@sha256:[0-9a-f]{64})?$/m) + .split(/^FROM /m) .pop(); const copied = [...runtime.matchAll(/^COPY (?:--\S+ )*(scripts\/\S+)/gm)].map(m => m[1]); assert.deepEqual(copied, ['scripts/exit-on-fatal.py']); From fcd0f8413ef3a64ca8c2821d27c17313559c7a57 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:29:03 -0400 Subject: [PATCH 2/2] Keep the copied base layer identical across releases Reset every directory time in the base stage to the node binary's time, so COPY --from=base produces the same layer for one base digest and updates reuse it. Fail the build if node.h is still anywhere in the image. Check every shipped stage, not only the last, in the runtime-content tests. --- Dockerfile | 6 +++++- test/repo/image-hardening.test.js | 21 ++++++++++++++++++--- test/repo/supervisord-fatal.test.js | 4 +++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1fc9fd87..bf365157 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,11 @@ RUN rm -rf /usr/local/lib/node_modules/npm \ if command -v npm || command -v npx || command -v yarn || command -v corepack; then \ echo "a package manager survived removal; check the base image layout" >&2; exit 1; \ fi && \ - node -e "process.exit(0)" + if [ -n "$(find / -xdev -name node.h)" ]; then \ + echo "the C headers survived removal; check the base image layout" >&2; exit 1; \ + fi && \ + node -e "process.exit(0)" && \ + find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -type d -exec touch -r /usr/local/bin/node {} + FROM scratch COPY --from=base / / diff --git a/test/repo/image-hardening.test.js b/test/repo/image-hardening.test.js index 28d229b8..41f9ce08 100644 --- a/test/repo/image-hardening.test.js +++ b/test/repo/image-hardening.test.js @@ -6,8 +6,13 @@ const path = require('node:path'); const root = path.join(__dirname, '..', '..'); const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8'); -test('the Dockerfile removes npm, corepack and yarn', () => { - for (const target of ['/usr/local/lib/node_modules/npm', '/usr/local/lib/node_modules/corepack', '/opt/yarn-*']) { +test('the Dockerfile removes npm, corepack, yarn and the C headers', () => { + for (const target of [ + '/usr/local/lib/node_modules/npm', + '/usr/local/lib/node_modules/corepack', + '/opt/yarn-*', + '/usr/local/include/node', + ]) { assert.ok(dockerfile.includes(target), `${target} is no longer removed`); } for (const bin of ['/usr/local/bin/npm', '/usr/local/bin/npx', '/usr/local/bin/corepack', '/usr/local/bin/yarn']) { @@ -18,6 +23,12 @@ test('the Dockerfile removes npm, corepack and yarn', () => { test('the removal is verified inside the build', () => { assert.match(dockerfile, /if command -v npm \|\| command -v npx \|\| command -v yarn \|\| command -v corepack; then/); assert.match(dockerfile, /a package manager survived removal/); + assert.match(dockerfile, /if \[ -n "\$\(find \/ -xdev -name node\.h\)" \]; then/); + assert.match(dockerfile, /the C headers survived removal/); +}); + +test('the copied base layer is the same for one base digest', () => { + assert.match(dockerfile, /-type d -exec touch -r \/usr\/local\/bin\/node \{\} \+$/m); }); test('node itself is still checked to work afterwards', () => { @@ -123,7 +134,11 @@ test('the upgrade cannot be served from a cache across releases', () => { /* The API runs as node. Code it owns, it can rewrite, and the change survives a restart. */ test('the API cannot rewrite its own code', () => { - const runtime = dockerfile.split(/^FROM /m).pop(); + const runtime = dockerfile + .split(/^FROM /m) + .slice(1) + .filter(stage => !/^\S+ AS assets$/m.test(stage.split('\n')[0])) + .join('\n'); const copies = runtime.split('\n').filter(l => /^COPY .*\/app\//.test(l)); assert.ok(copies.length > 0); for (const line of copies) assert.doesNotMatch(line, /--chown/, line); diff --git a/test/repo/supervisord-fatal.test.js b/test/repo/supervisord-fatal.test.js index 1537484f..132bbfd5 100644 --- a/test/repo/supervisord-fatal.test.js +++ b/test/repo/supervisord-fatal.test.js @@ -178,7 +178,9 @@ test('the rest of scripts/ is still kept out of the build context', () => { test('only the supervisor listener reaches the runtime image', () => { const runtime = read('Dockerfile') .split(/^FROM /m) - .pop(); + .slice(1) + .filter(stage => !/^\S+ AS assets$/m.test(stage.split('\n')[0])) + .join('\n'); const copied = [...runtime.matchAll(/^COPY (?:--\S+ )*(scripts\/\S+)/gm)].map(m => m[1]); assert.deepEqual(copied, ['scripts/exit-on-fatal.py']); });