From ccb256954378b97a406524cf45fe996b2ecba354 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Mon, 10 Aug 2026 12:20:58 +0200 Subject: [PATCH 1/3] perf(cli): split deployed image dependencies and code into separate layers The final image stage copied all of /app in one layer, so node_modules was re-pushed and re-pulled on every deploy even when dependencies were unchanged. Copy node_modules as its own layer and the bundled code (via a stage that strips node_modules) separately, so unchanged dependencies produce an identical blob that registries and workers already have. --- .changeset/split-image-dependency-layers.md | 5 +++ packages/cli-v3/src/deploy/buildImage.test.ts | 28 ++++++++++++++++ packages/cli-v3/src/deploy/buildImage.ts | 32 +++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .changeset/split-image-dependency-layers.md diff --git a/.changeset/split-image-dependency-layers.md b/.changeset/split-image-dependency-layers.md new file mode 100644 index 00000000000..324af979f5f --- /dev/null +++ b/.changeset/split-image-dependency-layers.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Deployed images now ship dependencies and bundled task code as separate layers. Repeat deploys with unchanged dependencies push and pull far less data, making deploys and worker image pulls faster. diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 58ac10b229c..e33ac99f10c 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -25,4 +25,32 @@ describe("generateContainerfile", () => { expect(containerfile).toContain(`FROM ${image} AS base`); }); + + it.each(["node", "bun"] as BuildRuntime[])( + "splits node_modules and app code into separate layers for %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const user = runtime === "bun" ? "bun:bun" : "node:node"; + + expect(containerfile).toContain("FROM build AS code"); + expect(containerfile).toContain("RUN rm -rf node_modules"); + expect(containerfile).toContain( + `COPY --from=build --chown=${user} /app/node_modules ./node_modules` + ); + expect(containerfile).toContain(`COPY --from=code --chown=${user} /app ./`); + // The final stage must not copy all of /app from the build stage anymore, + // or node_modules would be duplicated across two layers + expect(containerfile).not.toContain(`COPY --from=build --chown=${user} /app ./`); + + // node_modules must exist even for projects with zero external dependencies + expect(containerfile).toContain("mkdir -p node_modules"); + } + ); }); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 8e393a70ddc..ed4807c8d5a 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -772,7 +772,8 @@ ${buildArgs} ${buildEnvVars} COPY --chown=bun:bun package.json ./ -RUN bun install --production --no-save +# mkdir guards against bun not creating node_modules when there are no dependencies +RUN bun install --production --no-save && mkdir -p node_modules # Now copy all the files # IMPORTANT: Do this after running npm install because npm i will wipe out the node_modules directory @@ -780,6 +781,11 @@ COPY --chown=bun:bun . . ${postInstallCommands} +# App files without node_modules, so the final stage can layer them separately +FROM build AS code + +RUN rm -rf node_modules + FROM build AS indexer USER bun @@ -831,8 +837,12 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production -# Copy the files from the build stage -COPY --from=build --chown=bun:bun /app ./ +# Dependencies as their own layer: unchanged deps produce an identical blob +# that registries and workers already have, so repeat deploys skip it +COPY --from=build --chown=bun:bun /app/node_modules ./node_modules + +# Copy the app files (without node_modules) from the code stage +COPY --from=code --chown=bun:bun /app ./ # Copy the index.json file from the indexer stage COPY --from=indexer --chown=bun:bun /app/index.json ./ @@ -877,7 +887,8 @@ ENV NODE_ENV=production ENV NPM_CONFIG_UPDATE_NOTIFIER=false COPY --chown=node:node package.json ./ -RUN npm i --no-audit --no-fund --no-save --no-package-lock +# mkdir guards against npm not creating node_modules when there are no dependencies +RUN npm i --no-audit --no-fund --no-save --no-package-lock && mkdir -p node_modules # Now copy all the files # IMPORTANT: Do this after running npm install because npm i will wipe out the node_modules directory @@ -888,6 +899,11 @@ ${postInstallCommands} # IMPORTANT: Doing this again to fix an issue with prisma generate removing the files in node_modules/trigger.dev for some reason... COPY --chown=node:node . . +# App files without node_modules, so the final stage can layer them separately +FROM build AS code + +RUN rm -rf node_modules + FROM build AS indexer USER node @@ -941,8 +957,12 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production -# Copy the files from the install stage -COPY --from=build --chown=node:node /app ./ +# Dependencies as their own layer: unchanged deps produce an identical blob +# that registries and workers already have, so repeat deploys skip it +COPY --from=build --chown=node:node /app/node_modules ./node_modules + +# Copy the app files (without node_modules) from the code stage +COPY --from=code --chown=node:node /app ./ # Copy the index.json file from the indexer stage COPY --from=indexer --chown=node:node /app/index.json ./ From cc15136d81d8e76f10c59229299442ec17c69fb3 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Mon, 10 Aug 2026 12:40:37 +0200 Subject: [PATCH 2/3] fix(cli): harden the image dependency-layer split Create node_modules after post-install commands so a command that prunes it can't fail the final-stage copy, keep the original install instruction so existing layer caches still hit, and make the code stage's node_modules removal work as a non-root user when a directory is read-only. --- .changeset/split-image-dependency-layers.md | 2 +- packages/cli-v3/src/deploy/buildImage.test.ts | 29 +++++++++++++++++-- packages/cli-v3/src/deploy/buildImage.ts | 18 ++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/.changeset/split-image-dependency-layers.md b/.changeset/split-image-dependency-layers.md index 324af979f5f..472017ee293 100644 --- a/.changeset/split-image-dependency-layers.md +++ b/.changeset/split-image-dependency-layers.md @@ -2,4 +2,4 @@ "trigger.dev": patch --- -Deployed images now ship dependencies and bundled task code as separate layers. Repeat deploys with unchanged dependencies push and pull far less data, making deploys and worker image pulls faster. +Deployed images now ship dependencies and bundled task code as separate layers. Repeat deploys with unchanged dependencies typically push and pull far less data, making deploys and worker image pulls faster. diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index e33ac99f10c..61e08390fea 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -40,7 +40,6 @@ describe("generateContainerfile", () => { const user = runtime === "bun" ? "bun:bun" : "node:node"; expect(containerfile).toContain("FROM build AS code"); - expect(containerfile).toContain("RUN rm -rf node_modules"); expect(containerfile).toContain( `COPY --from=build --chown=${user} /app/node_modules ./node_modules` ); @@ -48,9 +47,33 @@ describe("generateContainerfile", () => { // The final stage must not copy all of /app from the build stage anymore, // or node_modules would be duplicated across two layers expect(containerfile).not.toContain(`COPY --from=build --chown=${user} /app ./`); + } + ); + + it.each(["node", "bun"] as BuildRuntime[])( + "orders post-install commands, the node_modules guard, and the code stage for %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: { commands: ["echo post-install"] }, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const postInstall = containerfile.indexOf("RUN echo post-install"); + // The guard must run after post-install commands so a command that prunes + // node_modules can't break the final-stage COPY of /app/node_modules + const mkdirGuard = containerfile.indexOf("RUN mkdir -p node_modules"); + const codeStage = containerfile.indexOf("FROM build AS code"); + const rmNodeModules = containerfile.indexOf( + "RUN chmod -R u+w node_modules && rm -rf node_modules" + ); - // node_modules must exist even for projects with zero external dependencies - expect(containerfile).toContain("mkdir -p node_modules"); + expect(postInstall).toBeGreaterThan(-1); + expect(mkdirGuard).toBeGreaterThan(postInstall); + expect(codeStage).toBeGreaterThan(mkdirGuard); + expect(rmNodeModules).toBeGreaterThan(codeStage); } ); }); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index ed4807c8d5a..2876fb33267 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -772,8 +772,7 @@ ${buildArgs} ${buildEnvVars} COPY --chown=bun:bun package.json ./ -# mkdir guards against bun not creating node_modules when there are no dependencies -RUN bun install --production --no-save && mkdir -p node_modules +RUN bun install --production --no-save # Now copy all the files # IMPORTANT: Do this after running npm install because npm i will wipe out the node_modules directory @@ -781,10 +780,14 @@ COPY --chown=bun:bun . . ${postInstallCommands} +# node_modules may not exist when there are no dependencies to install +RUN mkdir -p node_modules + # App files without node_modules, so the final stage can layer them separately FROM build AS code -RUN rm -rf node_modules +# u+w first: rm as a non-root user fails on read-only directories +RUN chmod -R u+w node_modules && rm -rf node_modules FROM build AS indexer @@ -887,8 +890,7 @@ ENV NODE_ENV=production ENV NPM_CONFIG_UPDATE_NOTIFIER=false COPY --chown=node:node package.json ./ -# mkdir guards against npm not creating node_modules when there are no dependencies -RUN npm i --no-audit --no-fund --no-save --no-package-lock && mkdir -p node_modules +RUN npm i --no-audit --no-fund --no-save --no-package-lock # Now copy all the files # IMPORTANT: Do this after running npm install because npm i will wipe out the node_modules directory @@ -899,10 +901,14 @@ ${postInstallCommands} # IMPORTANT: Doing this again to fix an issue with prisma generate removing the files in node_modules/trigger.dev for some reason... COPY --chown=node:node . . +# node_modules may not exist when there are no dependencies to install +RUN mkdir -p node_modules + # App files without node_modules, so the final stage can layer them separately FROM build AS code -RUN rm -rf node_modules +# u+w first: rm as a non-root user fails on read-only directories +RUN chmod -R u+w node_modules && rm -rf node_modules FROM build AS indexer From b3f96046b86b6435cf9d751cfc662b6f4f9a120c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Mon, 10 Aug 2026 13:41:35 +0200 Subject: [PATCH 3/3] fix(cli): traverse unreadable directories when stripping node_modules in the code stage chmod -R u+w itself fails on a directory without owner execute; u+rwX grants traversal as it recurses. Also trim generated-Containerfile comments to the non-obvious constraints. --- packages/cli-v3/src/deploy/buildImage.test.ts | 8 +++----- packages/cli-v3/src/deploy/buildImage.ts | 18 ++++++------------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 61e08390fea..cbeb58c083c 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -44,8 +44,7 @@ describe("generateContainerfile", () => { `COPY --from=build --chown=${user} /app/node_modules ./node_modules` ); expect(containerfile).toContain(`COPY --from=code --chown=${user} /app ./`); - // The final stage must not copy all of /app from the build stage anymore, - // or node_modules would be duplicated across two layers + // copying all of /app from build would duplicate node_modules across two layers expect(containerfile).not.toContain(`COPY --from=build --chown=${user} /app ./`); } ); @@ -62,12 +61,11 @@ describe("generateContainerfile", () => { }); const postInstall = containerfile.indexOf("RUN echo post-install"); - // The guard must run after post-install commands so a command that prunes - // node_modules can't break the final-stage COPY of /app/node_modules + // guard after post-install so a command that prunes node_modules can't break the COPY const mkdirGuard = containerfile.indexOf("RUN mkdir -p node_modules"); const codeStage = containerfile.indexOf("FROM build AS code"); const rmNodeModules = containerfile.indexOf( - "RUN chmod -R u+w node_modules && rm -rf node_modules" + "RUN chmod -R u+rwX node_modules && rm -rf node_modules" ); expect(postInstall).toBeGreaterThan(-1); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 2876fb33267..210a70be348 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -783,11 +783,10 @@ ${postInstallCommands} # node_modules may not exist when there are no dependencies to install RUN mkdir -p node_modules -# App files without node_modules, so the final stage can layer them separately FROM build AS code -# u+w first: rm as a non-root user fails on read-only directories -RUN chmod -R u+w node_modules && rm -rf node_modules +# u+rwX first: non-root rm fails on read-only or non-traversable directories +RUN chmod -R u+rwX node_modules && rm -rf node_modules FROM build AS indexer @@ -840,11 +839,9 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production -# Dependencies as their own layer: unchanged deps produce an identical blob -# that registries and workers already have, so repeat deploys skip it +# Unchanged dependencies produce an identical layer that repeat deploys skip COPY --from=build --chown=bun:bun /app/node_modules ./node_modules -# Copy the app files (without node_modules) from the code stage COPY --from=code --chown=bun:bun /app ./ # Copy the index.json file from the indexer stage @@ -904,11 +901,10 @@ COPY --chown=node:node . . # node_modules may not exist when there are no dependencies to install RUN mkdir -p node_modules -# App files without node_modules, so the final stage can layer them separately FROM build AS code -# u+w first: rm as a non-root user fails on read-only directories -RUN chmod -R u+w node_modules && rm -rf node_modules +# u+rwX first: non-root rm fails on read-only or non-traversable directories +RUN chmod -R u+rwX node_modules && rm -rf node_modules FROM build AS indexer @@ -963,11 +959,9 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production -# Dependencies as their own layer: unchanged deps produce an identical blob -# that registries and workers already have, so repeat deploys skip it +# Unchanged dependencies produce an identical layer that repeat deploys skip COPY --from=build --chown=node:node /app/node_modules ./node_modules -# Copy the app files (without node_modules) from the code stage COPY --from=code --chown=node:node /app ./ # Copy the index.json file from the indexer stage