From ef931bf9a3287080ad4a5426e90539d50e7bd2a3 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 12:06:39 +0200 Subject: [PATCH 1/8] perf(cli): build deployed images on prebuilt base images Every project's build ran apt-get against the live Debian archive, making the package layer a per-project near-duplicate that worker nodes each cached separately and adding an installation step to every cold build. The generated Containerfile now starts from the published triggerdotdev/node and triggerdotdev/bun images (and their -build toolchain variants for the build stage), which ship the default packages prebuilt: no apt runs for uncustomized projects, and the base layers are identical across all projects by construction. User instructions and packages apply on top in both stages, with user packages in their own sorted install that allows downgrading pinned defaults. --- .changeset/prebuilt-base-images.md | 5 + packages/cli-v3/src/deploy/buildImage.test.ts | 72 +++++++++++- packages/cli-v3/src/deploy/buildImage.ts | 105 ++++++++++++------ 3 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 .changeset/prebuilt-base-images.md diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md new file mode 100644 index 0000000000..9553153c08 --- /dev/null +++ b/.changeset/prebuilt-base-images.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Deployed images now build on prebuilt base images (triggerdotdev/node and triggerdotdev/bun on Docker Hub) instead of installing system packages during every build. Builds skip the package installation step entirely, and the base layers are identical across all projects, so worker nodes cache one copy fleet-wide and image pulls get faster. Custom packages from the aptGet extension install on top as before. diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index cbeb58c083..248fbc4acd 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -2,19 +2,26 @@ import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; import { generateContainerfile } from "./buildImage.js"; -const nodeImages: Array<[BuildRuntime, string]> = [ +const images: Array<[BuildRuntime, string, string]> = [ [ "node-24", - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:7cb5dcce8a2ae96ba3164ea6a16b14fe77cfb9b4c9161ebb2cc2b045392fada9", + "triggerdotdev/node:24-bookworm-build@sha256:3dbc4abde322a71ea91eb2516912589c136d9aa1094b2dd0e787dd73783b8047", ], [ "node-26", - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:04420c0cb9bd1890fe9dd51fcdfd0a263276e76c5fe088b175a943ccbab36b2a", + "triggerdotdev/node:26-bookworm-build@sha256:75776ca741da628bb2478283aa93f75626a495a3a2601c4828b2bb19386264a6", + ], + [ + "bun", + "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", + "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:fdd8dcaf4d0370f9571156d8c71c4b91c0cd02bb49850e0019e3e233fe1b35e1", ], ]; describe("generateContainerfile", () => { - it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => { + it.each(images)("uses the pinned published base images for %s", async (runtime, base, build) => { const containerfile = await generateContainerfile({ runtime, build: {}, @@ -23,7 +30,62 @@ describe("generateContainerfile", () => { entrypoint: "entrypoint.js", }); - expect(containerfile).toContain(`FROM ${image} AS base`); + expect(containerfile).toContain(`FROM ${base} AS base`); + expect(containerfile).toContain(`FROM ${build} AS build`); + }); + + it.each(["node", "bun"] as BuildRuntime[])( + "runs no package installation for uncustomized projects on %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).not.toContain("apt-get"); + } + ); + + it("installs user packages after instructions, in both stages", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { + pkgs: ["jq", "curl", "git"], + instructions: ["RUN echo custom > /etc/marker"], + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + // sorted, defaults filtered out, downgrades allowed for pinned defaults + const installLine = "apt-get install -y --no-install-recommends --allow-downgrades curl jq"; + const first = containerfile.indexOf(installLine); + const second = containerfile.indexOf(installLine, first + 1); + const firstInstructions = containerfile.indexOf("RUN echo custom > /etc/marker"); + + expect(first).toBeGreaterThan(firstInstructions); + // base and build come from separate images, so both need the customization + expect(second).toBeGreaterThan(first); + }); + + it("repairs dpkg state after instructions when there are no user packages", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { instructions: ["RUN echo custom > /etc/marker"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const instructions = containerfile.indexOf("RUN echo custom > /etc/marker"); + const repair = containerfile.indexOf("apt-get --fix-broken install -y"); + + expect(instructions).toBeGreaterThan(-1); + expect(repair).toBeGreaterThan(instructions); }); it.each(["node", "bun"] as BuildRuntime[])( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 210a70be34..aa5b1c24b3 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,17 +687,32 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; +// Prebuilt in base-images/ with the default system packages included, so the +// package layer is one blob shared by every project instead of an apt install +// per build. Both maps must be bumped together from the same publish run. const BASE_IMAGE: Record = { - bun: "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b", - node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", + bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", + node: "triggerdotdev/node:21-bookworm@sha256:2580fbfa9a1f75d53126d98bb4bbafabaf3db6b7c1b7996b6603dbea0efcd88c", "node-22": - "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", + "triggerdotdev/node:22-bookworm@sha256:4c85fbb6805f07d1b2d9b311fb53f180f5b281e30b671305c0fbe2a5f4b473b0", "node-24": - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:7cb5dcce8a2ae96ba3164ea6a16b14fe77cfb9b4c9161ebb2cc2b045392fada9", "node-26": - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:04420c0cb9bd1890fe9dd51fcdfd0a263276e76c5fe088b175a943ccbab36b2a", }; +const BUILD_IMAGE: Record = { + bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:fdd8dcaf4d0370f9571156d8c71c4b91c0cd02bb49850e0019e3e233fe1b35e1", + node: "triggerdotdev/node:21-bookworm-build@sha256:39e1d485e759280c4935f14ee5b31fdfc322b3d246d631abada11fd9734e0ae1", + "node-22": + "triggerdotdev/node:22-bookworm-build@sha256:af582b998838d9923fe05075e1d39aca560153293163651f99f779c497c80d25", + "node-24": + "triggerdotdev/node:24-bookworm-build@sha256:3dbc4abde322a71ea91eb2516912589c136d9aa1094b2dd0e787dd73783b8047", + "node-26": + "triggerdotdev/node:26-bookworm-build@sha256:75776ca741da628bb2478283aa93f75626a495a3a2601c4828b2bb19386264a6", +}; + +// Already installed in the published base images const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; export async function generateContainerfile(options: GenerateContainerfileOptions) { @@ -714,6 +729,25 @@ export async function generateContainerfile(options: GenerateContainerfileOption } } +function aptInstall(packages: string[]): string { + // --allow-downgrades: a user pin of a preinstalled package (e.g. + // openssl=) is a downgrade by the time this runs + return `RUN apt-get update && \\ + apt-get install -y --no-install-recommends --allow-downgrades ${packages.join(" ")} && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + +// Instructions can leave dpkg in a broken state (e.g. dpkg -i of a local .deb) +// that installing user packages would normally repair; when there are none, +// repair explicitly +function aptRepair(): string { + return `RUN apt-get update && \\ + apt-get --fix-broken install -y && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const buildArgs = Object.entries(options.build.env || {}) .flatMap(([key]) => `ARG ${key}`) @@ -726,43 +760,46 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const postInstallCommands = (options.build.commands || []).map((cmd) => `RUN ${cmd}`).join("\n"); const baseInstructions = (options.image?.instructions || []).join("\n"); - const packages = Array.from(new Set(DEFAULT_PACKAGES.concat(options.image?.pkgs || []))).join( - " " - ); + const userPackages = Array.from(new Set(options.image?.pkgs || [])) + .filter((pkg) => !DEFAULT_PACKAGES.includes(pkg)) + .sort(); + + // Rendered into both the base and build stages: they come from separate + // published images, so customization no longer inherits from base to build + const customization = [ + baseInstructions, + userPackages.length > 0 ? aptInstall(userPackages) : baseInstructions ? aptRepair() : "", + ] + .filter(Boolean) + .join("\n\n"); return { baseImage: BASE_IMAGE[options.runtime], - baseInstructions, + buildImage: BUILD_IMAGE[options.runtime], + customization, buildArgs, buildEnvVars, - packages, postInstallCommands, }; }; async function generateBunContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildImage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} - ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* -FROM base AS build +${customization} + +FROM ${buildImage} AS build + +ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +${customization} USER bun WORKDIR /app @@ -853,28 +890,22 @@ CMD [] } async function generateNodeContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildImage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} +ENV DEBIAN_FRONTEND=noninteractive + +${customization} + +FROM ${buildImage} AS build ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -FROM base AS build - -# Install build dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* + +${customization} USER node WORKDIR /app From 5483ea958deac0cee46a2a195622b399678e334a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 12:30:55 +0200 Subject: [PATCH 2/8] fix(cli): repair dpkg before user packages, single-pass customization apt-get install refuses to run on dpkg state an instruction left broken (the dpkg -i pattern audioWaveform uses), so repair ahead of the user package install whenever instructions preceded it. Customized projects build FROM base with the toolchain installed on top so instructions run exactly once instead of twice in independent stages, keeping external downloads single-shot; uncustomized projects keep the prebuilt toolchain image and zero apt. A test now pins DEFAULT_PACKAGES to the published images' package list. --- packages/cli-v3/src/deploy/buildImage.test.ts | 32 +++++++--- packages/cli-v3/src/deploy/buildImage.ts | 62 +++++++++++-------- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 248fbc4acd..3833b49dad 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -1,6 +1,8 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; -import { generateContainerfile } from "./buildImage.js"; +import { DEFAULT_PACKAGES, generateContainerfile } from "./buildImage.js"; const images: Array<[BuildRuntime, string, string]> = [ [ @@ -46,10 +48,19 @@ describe("generateContainerfile", () => { }); expect(containerfile).not.toContain("apt-get"); + expect(containerfile).not.toContain("FROM base AS build"); } ); - it("installs user packages after instructions, in both stages", async () => { + it("matches the published base image package list", () => { + const imagesJson = JSON.parse( + readFileSync(join(process.cwd(), "../../base-images/images.json"), "utf-8") + ); + + expect(imagesJson.packages.split(" ").sort()).toEqual([...DEFAULT_PACKAGES].sort()); + }); + + it("applies customization once and builds FROM base when customized", async () => { const containerfile = await generateContainerfile({ runtime: "node-22", build: {}, @@ -63,13 +74,16 @@ describe("generateContainerfile", () => { // sorted, defaults filtered out, downgrades allowed for pinned defaults const installLine = "apt-get install -y --no-install-recommends --allow-downgrades curl jq"; - const first = containerfile.indexOf(installLine); - const second = containerfile.indexOf(installLine, first + 1); - const firstInstructions = containerfile.indexOf("RUN echo custom > /etc/marker"); - - expect(first).toBeGreaterThan(firstInstructions); - // base and build come from separate images, so both need the customization - expect(second).toBeGreaterThan(first); + expect(containerfile.indexOf(installLine)).toBeGreaterThan( + containerfile.indexOf("RUN echo custom > /etc/marker") + ); + expect(containerfile.indexOf(installLine, containerfile.indexOf(installLine) + 1)).toBe(-1); + // apt-get install refuses to run on dpkg state broken by an instruction + expect(containerfile.indexOf("apt-get --fix-broken install -y")).toBeLessThan( + containerfile.indexOf(installLine) + ); + expect(containerfile).toContain("FROM base AS build"); + expect(containerfile).toContain("python3 make g++"); }); it("repairs dpkg state after instructions when there are no user packages", async () => { diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index aa5b1c24b3..5fe5d7b98a 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -712,8 +712,8 @@ const BUILD_IMAGE: Record = { "triggerdotdev/node:26-bookworm-build@sha256:75776ca741da628bb2478283aa93f75626a495a3a2601c4828b2bb19386264a6", }; -// Already installed in the published base images -const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; +// Preinstalled in the published base images; must match base-images/images.json +export const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; export async function generateContainerfile(options: GenerateContainerfileOptions) { switch (options.runtime) { @@ -729,18 +729,22 @@ export async function generateContainerfile(options: GenerateContainerfileOption } } -function aptInstall(packages: string[]): string { - // --allow-downgrades: a user pin of a preinstalled package (e.g. - // openssl=) is a downgrade by the time this runs +// Instructions can leave dpkg in a broken state (e.g. dpkg -i of a local +// .deb), and apt-get install refuses to run on one, so repair first whenever +// instructions preceded. --allow-downgrades: a user pin of a preinstalled +// package (e.g. openssl=) is a downgrade by the time this runs. +function aptInstall(packages: string[], { repair }: { repair: boolean }): string { + const repairStep = repair + ? `apt-get --fix-broken install -y && \\ + ` + : ""; + return `RUN apt-get update && \\ - apt-get install -y --no-install-recommends --allow-downgrades ${packages.join(" ")} && \\ + ${repairStep}apt-get install -y --no-install-recommends --allow-downgrades ${packages.join(" ")} && \\ apt-get clean && \\ rm -rf /var/lib/apt/lists/*`; } -// Instructions can leave dpkg in a broken state (e.g. dpkg -i of a local .deb) -// that installing user packages would normally repair; when there are none, -// repair explicitly function aptRepair(): string { return `RUN apt-get update && \\ apt-get --fix-broken install -y && \\ @@ -764,18 +768,34 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { .filter((pkg) => !DEFAULT_PACKAGES.includes(pkg)) .sort(); - // Rendered into both the base and build stages: they come from separate - // published images, so customization no longer inherits from base to build const customization = [ baseInstructions, - userPackages.length > 0 ? aptInstall(userPackages) : baseInstructions ? aptRepair() : "", + userPackages.length > 0 + ? aptInstall(userPackages, { repair: baseInstructions.length > 0 }) + : baseInstructions + ? aptRepair() + : "", ] .filter(Boolean) .join("\n\n"); + // Customized projects build FROM base so instructions and packages apply + // exactly once; uncustomized projects use the prebuilt toolchain image and + // run no apt at all + const buildStage = customization + ? `FROM base AS build + +RUN apt-get update && \\ + apt-get install -y --no-install-recommends python3 make g++ && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*` + : `FROM ${BUILD_IMAGE[options.runtime]} AS build + +ENV DEBIAN_FRONTEND=noninteractive`; + return { baseImage: BASE_IMAGE[options.runtime], - buildImage: BUILD_IMAGE[options.runtime], + buildStage, customization, buildArgs, buildEnvVars, @@ -784,7 +804,7 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { }; async function generateBunContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildImage, buildArgs, buildEnvVars, postInstallCommands, customization } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 @@ -795,11 +815,7 @@ ENV DEBIAN_FRONTEND=noninteractive ${customization} -FROM ${buildImage} AS build - -ENV DEBIAN_FRONTEND=noninteractive - -${customization} +${buildStage} USER bun WORKDIR /app @@ -890,7 +906,7 @@ CMD [] } async function generateNodeContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildImage, buildArgs, buildEnvVars, postInstallCommands, customization } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 @@ -901,11 +917,7 @@ ENV DEBIAN_FRONTEND=noninteractive ${customization} -FROM ${buildImage} AS build - -ENV DEBIAN_FRONTEND=noninteractive - -${customization} +${buildStage} USER node WORKDIR /app From c3b4954d411bb3dc7e9a2b3c00fac472881b0e05 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 12:54:37 +0200 Subject: [PATCH 3/8] fix(cli): keep the prebuilt toolchain for package-only projects, tighten repairs Package-only projects have no instructions to run twice, so they keep the prebuilt toolchain image and repeat only the small package install instead of fetching the toolchain from the live archive per build. The repair steps use --no-install-recommends like every other apt call, the regression test asserts the composed repair-then-install RUN (the index comparison was vacuously true when the repair was missing), and the package-list sync test resolves paths from the test file and also pins the toolchain list. --- packages/cli-v3/src/deploy/buildImage.test.ts | 54 +++++++++++++------ packages/cli-v3/src/deploy/buildImage.ts | 21 +++++--- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 3833b49dad..e8b4be0516 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; -import { DEFAULT_PACKAGES, generateContainerfile } from "./buildImage.js"; +import { DEFAULT_PACKAGES, TOOLCHAIN_PACKAGES, generateContainerfile } from "./buildImage.js"; const images: Array<[BuildRuntime, string, string]> = [ [ @@ -52,15 +53,19 @@ describe("generateContainerfile", () => { } ); - it("matches the published base image package list", () => { + it("matches the published base image package lists", () => { const imagesJson = JSON.parse( - readFileSync(join(process.cwd(), "../../base-images/images.json"), "utf-8") + readFileSync( + join(fileURLToPath(import.meta.url), "../../../../../base-images/images.json"), + "utf-8" + ) ); expect(imagesJson.packages.split(" ").sort()).toEqual([...DEFAULT_PACKAGES].sort()); + expect(imagesJson.buildPackages).toBe(TOOLCHAIN_PACKAGES); }); - it("applies customization once and builds FROM base when customized", async () => { + it("applies instructions once and builds FROM base when they are present", async () => { const containerfile = await generateContainerfile({ runtime: "node-22", build: {}, @@ -72,18 +77,37 @@ describe("generateContainerfile", () => { entrypoint: "entrypoint.js", }); - // sorted, defaults filtered out, downgrades allowed for pinned defaults - const installLine = "apt-get install -y --no-install-recommends --allow-downgrades curl jq"; - expect(containerfile.indexOf(installLine)).toBeGreaterThan( - containerfile.indexOf("RUN echo custom > /etc/marker") - ); - expect(containerfile.indexOf(installLine, containerfile.indexOf(installLine) + 1)).toBe(-1); - // apt-get install refuses to run on dpkg state broken by an instruction - expect(containerfile.indexOf("apt-get --fix-broken install -y")).toBeLessThan( - containerfile.indexOf(installLine) - ); + // sorted, defaults filtered out, downgrades allowed for pinned defaults, + // and repaired first: apt-get install refuses to run on dpkg state broken + // by an instruction + const installRun = `apt-get --fix-broken install -y --no-install-recommends && \\ + apt-get install -y --no-install-recommends --allow-downgrades curl jq`; + const first = containerfile.indexOf(installRun); + + expect(first).toBeGreaterThan(containerfile.indexOf("RUN echo custom > /etc/marker")); + expect(containerfile.indexOf(installRun, first + 1)).toBe(-1); expect(containerfile).toContain("FROM base AS build"); - expect(containerfile).toContain("python3 make g++"); + expect(containerfile).toContain(TOOLCHAIN_PACKAGES); + }); + + it("keeps the prebuilt toolchain image for package-only projects", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { pkgs: ["jq"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const installLine = "apt-get install -y --no-install-recommends --allow-downgrades jq"; + const first = containerfile.indexOf(installLine); + + // installed in both stages, since base and build are separate images + expect(first).toBeGreaterThan(-1); + expect(containerfile.indexOf(installLine, first + 1)).toBeGreaterThan(first); + expect(containerfile).not.toContain("FROM base AS build"); + // a pristine base has nothing to repair + expect(containerfile).not.toContain("--fix-broken"); }); it("repairs dpkg state after instructions when there are no user packages", async () => { diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 5fe5d7b98a..9402b6356e 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -735,7 +735,7 @@ export async function generateContainerfile(options: GenerateContainerfileOption // package (e.g. openssl=) is a downgrade by the time this runs. function aptInstall(packages: string[], { repair }: { repair: boolean }): string { const repairStep = repair - ? `apt-get --fix-broken install -y && \\ + ? `apt-get --fix-broken install -y --no-install-recommends && \\ ` : ""; @@ -747,11 +747,14 @@ function aptInstall(packages: string[], { repair }: { repair: boolean }): string function aptRepair(): string { return `RUN apt-get update && \\ - apt-get --fix-broken install -y && \\ + apt-get --fix-broken install -y --no-install-recommends && \\ apt-get clean && \\ rm -rf /var/lib/apt/lists/*`; } +// Must match base-images/images.json buildPackages, which the -build images preinstall +export const TOOLCHAIN_PACKAGES = "python3 make g++"; + const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const buildArgs = Object.entries(options.build.env || {}) .flatMap(([key]) => `ARG ${key}`) @@ -779,19 +782,21 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { .filter(Boolean) .join("\n\n"); - // Customized projects build FROM base so instructions and packages apply - // exactly once; uncustomized projects use the prebuilt toolchain image and - // run no apt at all - const buildStage = customization + // Projects with instructions build FROM base so instructions run exactly + // once (their downloads are unbounded); package-only projects keep the + // prebuilt toolchain image and repeat the small package install + const buildStage = baseInstructions ? `FROM base AS build RUN apt-get update && \\ - apt-get install -y --no-install-recommends python3 make g++ && \\ + apt-get install -y --no-install-recommends ${TOOLCHAIN_PACKAGES} && \\ apt-get clean && \\ rm -rf /var/lib/apt/lists/*` : `FROM ${BUILD_IMAGE[options.runtime]} AS build -ENV DEBIAN_FRONTEND=noninteractive`; +ENV DEBIAN_FRONTEND=noninteractive${ + userPackages.length > 0 ? `\n\n${aptInstall(userPackages, { repair: false })}` : "" + }`; return { baseImage: BASE_IMAGE[options.runtime], From ed587063d13ce518ab117ba5cc9feec3cae54d51 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 13:33:38 +0200 Subject: [PATCH 4/8] chore(cli): trim base image comments to the constraints --- packages/cli-v3/src/deploy/buildImage.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 9402b6356e..deb1e0930f 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,9 +687,7 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; -// Prebuilt in base-images/ with the default system packages included, so the -// package layer is one blob shared by every project instead of an apt install -// per build. Both maps must be bumped together from the same publish run. +// Prebuilt in base-images/; both maps must be bumped together, from one publish run const BASE_IMAGE: Record = { bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", node: "triggerdotdev/node:21-bookworm@sha256:2580fbfa9a1f75d53126d98bb4bbafabaf3db6b7c1b7996b6603dbea0efcd88c", @@ -729,10 +727,9 @@ export async function generateContainerfile(options: GenerateContainerfileOption } } -// Instructions can leave dpkg in a broken state (e.g. dpkg -i of a local -// .deb), and apt-get install refuses to run on one, so repair first whenever -// instructions preceded. --allow-downgrades: a user pin of a preinstalled -// package (e.g. openssl=) is a downgrade by the time this runs. +// repair: apt-get install refuses to run on dpkg state an instruction left +// broken (the dpkg -i pattern). --allow-downgrades: a user pin of a +// preinstalled package is a downgrade by the time this runs. function aptInstall(packages: string[], { repair }: { repair: boolean }): string { const repairStep = repair ? `apt-get --fix-broken install -y --no-install-recommends && \\ @@ -782,9 +779,8 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { .filter(Boolean) .join("\n\n"); - // Projects with instructions build FROM base so instructions run exactly - // once (their downloads are unbounded); package-only projects keep the - // prebuilt toolchain image and repeat the small package install + // Instructions run once (FROM base) since their downloads are unbounded; + // package-only projects keep the prebuilt toolchain and repeat the small install const buildStage = baseInstructions ? `FROM base AS build From f0aacf55f37e31fdea81dd50f078b71d45e67474 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 13:40:31 +0200 Subject: [PATCH 5/8] chore(cli): describe the base image change for users in the changeset --- .changeset/prebuilt-base-images.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md index 9553153c08..3859d92ac0 100644 --- a/.changeset/prebuilt-base-images.md +++ b/.changeset/prebuilt-base-images.md @@ -2,4 +2,4 @@ "trigger.dev": patch --- -Deployed images now build on prebuilt base images (triggerdotdev/node and triggerdotdev/bun on Docker Hub) instead of installing system packages during every build. Builds skip the package installation step entirely, and the base layers are identical across all projects, so worker nodes cache one copy fleet-wide and image pulls get faster. Custom packages from the aptGet extension install on top as before. +Deploys are faster: images no longer install system packages during every build, and repeat deploys pull less because the shared base layers are already cached. Custom packages from the aptGet extension still install as before. From e54f0cae9e6b976af1e9fddd931dcd37e76d6934 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 13:47:14 +0200 Subject: [PATCH 6/8] test(cli): pin the runtime and build image maps to matching tag pairs --- packages/cli-v3/src/deploy/buildImage.test.ts | 19 ++++++++++++++++++- packages/cli-v3/src/deploy/buildImage.ts | 4 ++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index e8b4be0516..91aaec124c 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -3,7 +3,13 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; -import { DEFAULT_PACKAGES, TOOLCHAIN_PACKAGES, generateContainerfile } from "./buildImage.js"; +import { + BASE_IMAGE, + BUILD_IMAGE, + DEFAULT_PACKAGES, + TOOLCHAIN_PACKAGES, + generateContainerfile, +} from "./buildImage.js"; const images: Array<[BuildRuntime, string, string]> = [ [ @@ -53,6 +59,17 @@ describe("generateContainerfile", () => { } ); + it("pairs every runtime image with its -build variant", () => { + for (const runtime of Object.keys(BASE_IMAGE) as BuildRuntime[]) { + const baseRef = BASE_IMAGE[runtime].split("@")[0]; + const buildRef = BUILD_IMAGE[runtime].split("@")[0]; + + expect(buildRef).toBe(`${baseRef}-build`); + expect(BASE_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + expect(BUILD_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + } + }); + it("matches the published base image package lists", () => { const imagesJson = JSON.parse( readFileSync( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index deb1e0930f..ac4a6b097e 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -688,7 +688,7 @@ export type GenerateContainerfileOptions = { }; // Prebuilt in base-images/; both maps must be bumped together, from one publish run -const BASE_IMAGE: Record = { +export const BASE_IMAGE: Record = { bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", node: "triggerdotdev/node:21-bookworm@sha256:2580fbfa9a1f75d53126d98bb4bbafabaf3db6b7c1b7996b6603dbea0efcd88c", "node-22": @@ -699,7 +699,7 @@ const BASE_IMAGE: Record = { "triggerdotdev/node:26-bookworm@sha256:04420c0cb9bd1890fe9dd51fcdfd0a263276e76c5fe088b175a943ccbab36b2a", }; -const BUILD_IMAGE: Record = { +export const BUILD_IMAGE: Record = { bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:fdd8dcaf4d0370f9571156d8c71c4b91c0cd02bb49850e0019e3e233fe1b35e1", node: "triggerdotdev/node:21-bookworm-build@sha256:39e1d485e759280c4935f14ee5b31fdfc322b3d246d631abada11fd9734e0ae1", "node-22": From 81704beba31fecd87bf094f392b83022af3cff16 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 16:08:52 +0200 Subject: [PATCH 7/8] chore(cli): update the changeset wording --- .changeset/prebuilt-base-images.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md index 3859d92ac0..4f3f0be094 100644 --- a/.changeset/prebuilt-base-images.md +++ b/.changeset/prebuilt-base-images.md @@ -2,4 +2,4 @@ "trigger.dev": patch --- -Deploys are faster: images no longer install system packages during every build, and repeat deploys pull less because the shared base layers are already cached. Custom packages from the aptGet extension still install as before. +Deployment builds now use custom base layer images and no longer install system packages during every build. This improves layer caching resulting in both faster deployments and faster image pulls on the worker cluster side. From 044d3d07b4ab86c55871312a66723496e05d010f Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 18:31:09 +0200 Subject: [PATCH 8/8] chore(cli): pin the tag-protected base image digests The publish now pushes an immutable per-publish tag, so these digests stay tag-referenced permanently regardless of future republishes. --- packages/cli-v3/src/deploy/buildImage.test.ts | 12 +++++------ packages/cli-v3/src/deploy/buildImage.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 91aaec124c..3540d75794 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -14,18 +14,18 @@ import { const images: Array<[BuildRuntime, string, string]> = [ [ "node-24", - "triggerdotdev/node:24-bookworm@sha256:7cb5dcce8a2ae96ba3164ea6a16b14fe77cfb9b4c9161ebb2cc2b045392fada9", - "triggerdotdev/node:24-bookworm-build@sha256:3dbc4abde322a71ea91eb2516912589c136d9aa1094b2dd0e787dd73783b8047", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", ], [ "node-26", - "triggerdotdev/node:26-bookworm@sha256:04420c0cb9bd1890fe9dd51fcdfd0a263276e76c5fe088b175a943ccbab36b2a", - "triggerdotdev/node:26-bookworm-build@sha256:75776ca741da628bb2478283aa93f75626a495a3a2601c4828b2bb19386264a6", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", ], [ "bun", - "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", - "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:fdd8dcaf4d0370f9571156d8c71c4b91c0cd02bb49850e0019e3e233fe1b35e1", + "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", ], ]; diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index ac4a6b097e..0a077a5d8c 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -689,25 +689,25 @@ export type GenerateContainerfileOptions = { // Prebuilt in base-images/; both maps must be bumped together, from one publish run export const BASE_IMAGE: Record = { - bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:61d0f681429e69a0eb0eb054c6dbbc5876012feebabf012dd9b80e2f3f776771", - node: "triggerdotdev/node:21-bookworm@sha256:2580fbfa9a1f75d53126d98bb4bbafabaf3db6b7c1b7996b6603dbea0efcd88c", + bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + node: "triggerdotdev/node:21-bookworm@sha256:49c6575cda32f63ac21a4aeaabc360dc50c6f767b86b675b44de7a9e9b6ca3fc", "node-22": - "triggerdotdev/node:22-bookworm@sha256:4c85fbb6805f07d1b2d9b311fb53f180f5b281e30b671305c0fbe2a5f4b473b0", + "triggerdotdev/node:22-bookworm@sha256:3d1b59a1d50c3df713078a7b18386441cf7fdbaeea6da799247df5a2e180bdd5", "node-24": - "triggerdotdev/node:24-bookworm@sha256:7cb5dcce8a2ae96ba3164ea6a16b14fe77cfb9b4c9161ebb2cc2b045392fada9", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", "node-26": - "triggerdotdev/node:26-bookworm@sha256:04420c0cb9bd1890fe9dd51fcdfd0a263276e76c5fe088b175a943ccbab36b2a", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", }; export const BUILD_IMAGE: Record = { - bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:fdd8dcaf4d0370f9571156d8c71c4b91c0cd02bb49850e0019e3e233fe1b35e1", - node: "triggerdotdev/node:21-bookworm-build@sha256:39e1d485e759280c4935f14ee5b31fdfc322b3d246d631abada11fd9734e0ae1", + bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", + node: "triggerdotdev/node:21-bookworm-build@sha256:98f2bc6beb124da3c3aa9abba587a6c3d08a1aada217b5bb91f843364184d1d0", "node-22": - "triggerdotdev/node:22-bookworm-build@sha256:af582b998838d9923fe05075e1d39aca560153293163651f99f779c497c80d25", + "triggerdotdev/node:22-bookworm-build@sha256:acc6f0143021f532b601bf9fa2cd7745b07612358f94acb8e1cd864468320a81", "node-24": - "triggerdotdev/node:24-bookworm-build@sha256:3dbc4abde322a71ea91eb2516912589c136d9aa1094b2dd0e787dd73783b8047", + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", "node-26": - "triggerdotdev/node:26-bookworm-build@sha256:75776ca741da628bb2478283aa93f75626a495a3a2601c4828b2bb19386264a6", + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", }; // Preinstalled in the published base images; must match base-images/images.json