Skip to content

Commit 2a0c5ba

Browse files
committed
feat(cli,webapp): harden instance deploy base images
Validate DEPLOY_BASE_IMAGES and DEPLOY_BUILD_BASE_IMAGES when the webapp starts: entries must name a known runtime and a digest-pinned image, with no duplicate runtimes. Invalid values fail startup instead of silently falling back to the published images. Honor the build-stage image when build extensions add image instructions: the build stage is created from the configured build image and the instructions are replayed on it, instead of being derived from the base with a toolchain install. Apply the server's base images on --from-bundle deploys by regenerating the bundle's Containerfile, and print the images in the deploy output. Docs: split the Node and Bun base image requirements, describe the validation rules and the paths the setting applies to.
1 parent 5d3fb37 commit 2a0c5ba

9 files changed

Lines changed: 266 additions & 73 deletions

File tree

‎apps/webapp/app/env.server.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MachinePresetName } from "@trigger.dev/core/v3";
33
import { parseNaturalLanguageDurationInMs } from "@trigger.dev/core/v3/isomorphic";
44
import { BoolEnv } from "./utils/boolEnv";
55
import { isValidDatabaseUrl } from "./utils/db";
6+
import { parseDeployBaseImages } from "~/v3/deployBaseImages.server";
67
import { parseRunOpsShards, validateShardListAgainstNewUrl } from "~/v3/runOpsShards.server";
78
import { isValidRegex } from "./utils/regex";
89
import { isValidDuration } from "./services/realtime/duration.server";
@@ -901,8 +902,14 @@ const EnvironmentSchema = z
901902
),
902903

903904
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
904-
DEPLOY_BASE_IMAGES: z.string().optional(), // csv of runtime=image, for example: "node-26=registry.example.com/node-fips:26@sha256:..."
905-
DEPLOY_BUILD_BASE_IMAGES: z.string().optional(), // csv of runtime=image for the build stage
905+
DEPLOY_BASE_IMAGES: z
906+
.string()
907+
.optional()
908+
.transform((v) => parseDeployBaseImages(v, "DEPLOY_BASE_IMAGES")),
909+
DEPLOY_BUILD_BASE_IMAGES: z
910+
.string()
911+
.optional()
912+
.transform((v) => parseDeployBaseImages(v, "DEPLOY_BUILD_BASE_IMAGES")),
906913
DEPLOY_TIMEOUT_MS: z.coerce
907914
.number()
908915
.int()

‎apps/webapp/app/v3/deployBaseImages.server.ts‎

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,71 @@
1-
type BaseImages = { base?: string; buildBase?: string };
1+
import { BuildRuntime } from "@trigger.dev/core/v3";
2+
3+
type BaseImageMap = Partial<Record<BuildRuntime, string>>;
4+
5+
const DIGEST_PINNED = /@sha256:[a-f0-9]{64}$/;
6+
7+
function invalidSegment(envVarName: string, segment: string, reason: string): Error {
8+
return new Error(`${envVarName}: ${reason} in "${segment}"`);
9+
}
10+
11+
export function parseDeployBaseImages(value: string | undefined, envVarName: string): BaseImageMap {
12+
const result: BaseImageMap = {};
13+
14+
if (!value) {
15+
return result;
16+
}
17+
18+
for (const segment of value.split(",").map((s) => s.trim())) {
19+
if (!segment) {
20+
continue;
21+
}
22+
23+
const separator = segment.indexOf("=");
24+
if (separator === -1) {
25+
throw invalidSegment(envVarName, segment, "expected runtime=image");
26+
}
27+
28+
const runtimeName = segment.slice(0, separator).trim();
29+
const image = segment.slice(separator + 1).trim();
30+
31+
const runtime = BuildRuntime.safeParse(runtimeName);
32+
if (!runtime.success) {
33+
throw invalidSegment(
34+
envVarName,
35+
segment,
36+
`unknown runtime "${runtimeName}" (expected one of ${BuildRuntime.options.join(", ")})`
37+
);
38+
}
39+
40+
if (!image) {
41+
throw invalidSegment(envVarName, segment, "missing image");
42+
}
43+
44+
if (!DIGEST_PINNED.test(image)) {
45+
throw invalidSegment(envVarName, segment, "image must be pinned by digest (@sha256:<64 hex chars>)");
46+
}
47+
48+
if (runtime.data in result) {
49+
throw invalidSegment(envVarName, segment, `duplicate runtime "${runtimeName}"`);
50+
}
51+
52+
result[runtime.data] = image;
53+
}
54+
55+
return result;
56+
}
257

3-
/** Base images the operator requires for a runtime, from `runtime=image` csv env vars. */
458
export function resolveDeployBaseImages(
559
runtime: string | null | undefined,
6-
config: { base?: string; buildBase?: string }
7-
): BaseImages | undefined {
8-
if (!runtime) {
60+
config: { base: BaseImageMap; buildBase: BaseImageMap }
61+
): { base?: string; buildBase?: string } | undefined {
62+
const parsedRuntime = BuildRuntime.safeParse(runtime);
63+
if (!parsedRuntime.success) {
964
return undefined;
1065
}
1166

12-
const base = parseImageMap(config.base)[runtime];
13-
const buildBase = parseImageMap(config.buildBase)[runtime];
67+
const base = config.base[parsedRuntime.data];
68+
const buildBase = config.buildBase[parsedRuntime.data];
1469

1570
if (!base && !buildBase) {
1671
return undefined;
@@ -21,25 +76,3 @@ export function resolveDeployBaseImages(
2176
...(buildBase ? { buildBase } : {}),
2277
};
2378
}
24-
25-
function parseImageMap(value: string | undefined): Record<string, string> {
26-
if (!value) {
27-
return {};
28-
}
29-
30-
return Object.fromEntries(
31-
value
32-
.split(",")
33-
.map((entry) => entry.trim())
34-
.filter(Boolean)
35-
.flatMap((entry) => {
36-
const separator = entry.indexOf("=");
37-
if (separator <= 0) {
38-
return [];
39-
}
40-
const runtime = entry.slice(0, separator).trim();
41-
const image = entry.slice(separator + 1).trim();
42-
return image ? [[runtime, image] as const] : [];
43-
})
44-
);
45-
}
Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,86 @@
11
import { describe, expect, it } from "vitest";
2-
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
2+
import { parseDeployBaseImages, resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
33

4-
describe("resolveDeployBaseImages", () => {
5-
it("returns undefined when nothing is configured", () => {
6-
expect(resolveDeployBaseImages("node-26", {})).toBeUndefined();
4+
const digestA = `sha256:${"a".repeat(64)}`;
5+
const digestB = `sha256:${"b".repeat(64)}`;
6+
const digestC = `sha256:${"c".repeat(64)}`;
7+
8+
describe("parseDeployBaseImages", () => {
9+
it("returns an empty map for undefined and empty values", () => {
10+
expect(parseDeployBaseImages(undefined, "DEPLOY_BASE_IMAGES")).toEqual({});
11+
expect(parseDeployBaseImages("", "DEPLOY_BASE_IMAGES")).toEqual({});
12+
expect(parseDeployBaseImages(" , ,", "DEPLOY_BASE_IMAGES")).toEqual({});
713
});
814

9-
it("returns the images configured for the runtime", () => {
15+
it("parses multiple entries and trims whitespace", () => {
1016
expect(
11-
resolveDeployBaseImages("node-26", {
12-
base: "node-24=acme/node-fips:24@sha256:aaa, node-26=acme/node-fips:26@sha256:bbb",
13-
buildBase: "node-26=acme/node:26-dev@sha256:ccc",
14-
})
15-
).toEqual({ base: "acme/node-fips:26@sha256:bbb", buildBase: "acme/node:26-dev@sha256:ccc" });
17+
parseDeployBaseImages(
18+
` node-24 = acme/node-fips:24@${digestA} , bun=acme/bun:1@${digestB},`,
19+
"DEPLOY_BASE_IMAGES"
20+
)
21+
).toEqual({
22+
"node-24": `acme/node-fips:24@${digestA}`,
23+
bun: `acme/bun:1@${digestB}`,
24+
});
25+
});
26+
27+
it.each([
28+
["missing =", "garbage"],
29+
["unknown runtime", `node-23=acme/node:23@${digestA}`],
30+
["empty image", "node-24="],
31+
["missing digest", "node-24=acme/node:24"],
32+
["duplicate runtime", `node-24=acme/a@${digestA},node-24=acme/b@${digestB}`],
33+
])("throws naming the env var and segment: %s", (_name, value) => {
34+
const segments = value.split(",");
35+
const offending = segments[segments.length - 1]!;
36+
37+
let error: Error | undefined;
38+
try {
39+
parseDeployBaseImages(`node-22=acme/ok@${digestC},${value}`, "DEPLOY_BUILD_BASE_IMAGES");
40+
} catch (e) {
41+
error = e as Error;
42+
}
43+
44+
expect(error).toBeInstanceOf(Error);
45+
expect(error?.message).toContain("DEPLOY_BUILD_BASE_IMAGES");
46+
expect(error?.message).toContain(offending);
1647
});
48+
});
49+
50+
describe("resolveDeployBaseImages", () => {
51+
const base = { "node-26": `acme/node-fips:26@${digestA}` } as const;
52+
const buildBase = { "node-26": `acme/node:26-dev@${digestB}` } as const;
1753

18-
it("returns undefined for runtimes without an entry", () => {
19-
expect(resolveDeployBaseImages("bun", { base: "node-26=acme/node-fips:26" })).toBeUndefined();
54+
it("returns undefined for an unknown runtime", () => {
55+
expect(resolveDeployBaseImages("node-23", { base, buildBase })).toBeUndefined();
2056
});
2157

2258
it("returns undefined when the deployment has no runtime", () => {
23-
expect(resolveDeployBaseImages(null, { base: "node-26=acme/node-fips:26" })).toBeUndefined();
59+
expect(resolveDeployBaseImages(null, { base, buildBase })).toBeUndefined();
60+
expect(resolveDeployBaseImages(undefined, { base, buildBase })).toBeUndefined();
2461
});
2562

26-
it("skips malformed entries", () => {
27-
expect(
28-
resolveDeployBaseImages("node-26", { base: "garbage,=nope,node-26=,node-26=acme/node:26" })
29-
).toEqual({ base: "acme/node:26" });
63+
it("returns undefined when the runtime has no entries", () => {
64+
expect(resolveDeployBaseImages("bun", { base, buildBase })).toBeUndefined();
65+
expect(resolveDeployBaseImages("node-26", { base: {}, buildBase: {} })).toBeUndefined();
66+
});
67+
68+
it("returns both images", () => {
69+
expect(resolveDeployBaseImages("node-26", { base, buildBase })).toEqual({
70+
base: base["node-26"],
71+
buildBase: buildBase["node-26"],
72+
});
73+
});
74+
75+
it("returns only the base image", () => {
76+
expect(resolveDeployBaseImages("node-26", { base, buildBase: {} })).toEqual({
77+
base: base["node-26"],
78+
});
79+
});
80+
81+
it("returns only the build base image", () => {
82+
expect(resolveDeployBaseImages("node-26", { base: {}, buildBase })).toEqual({
83+
buildBase: buildBase["node-26"],
84+
});
3085
});
3186
});

‎docs/self-hosting/env/webapp.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ mode: "wide"
9797
| `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. |
9898
| `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY` | No | — | Raw IAM policy JSON applied via SetRepositoryPolicy to every ECR repo created by the webapp. Use to grant cross-account pull access to EKS workers when the ECR account is separate from the cluster account. |
9999
| `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. |
100-
| `DEPLOY_BASE_IMAGES` | No | — | Base images every deploy must build on, per runtime, as `runtime=image` csv, e.g. `node-26=registry.example.com/node-fips:26@sha256:...`. Use for FIPS-validated or hardened images. See [custom base images](/self-hosting/overview#custom-base-images). |
101-
| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage toolchain images per runtime, same format as `DEPLOY_BASE_IMAGES`. Defaults to the published `-build` images. |
100+
| `DEPLOY_BASE_IMAGES` | No | — | Base images every deploy must build on. Comma-separated `runtime=image@sha256:<digest>` entries, e.g. `node-26=registry.example.com/node-fips:26@sha256:...`. Runtimes: `node`, `node-22`, `node-24`, `node-26`, `bun`. The digest is required. An invalid value prevents the webapp from starting. See [custom base images](/self-hosting/overview#custom-base-images). |
101+
| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage images per runtime. Same format and validation as `DEPLOY_BASE_IMAGES`. Defaults to the published `-build` images. |
102102
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
103103
| `DEPLOY_QUEUE_TIMEOUT_MS` | No | 900000 (15m) | Deploy queue timeout (ms). |
104104
| **Object store (S3)** | | | |

‎docs/self-hosting/overview.mdx‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,29 @@ All fields are optional. Partial overrides are supported:
105105
Deploys build on the published `triggerdotdev/node` and `triggerdotdev/bun` Debian images. To require a different base for every deploy to your instance, such as a FIPS-validated or hardened Node image, set `DEPLOY_BASE_IMAGES` on the webapp (and optionally `DEPLOY_BUILD_BASE_IMAGES` for the build stage):
106106

107107
```bash
108-
DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:..."
108+
DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:<64-character-digest>"
109109
```
110110

111-
The CLI builds with these images for any runtime that has an entry. Runtimes without one keep the published images. With the Helm chart, set them through `webapp.extraEnvVars`.
111+
Entries are comma-separated `runtime=image@sha256:<digest>`. The runtimes are `node`, `node-22`, `node-24`, `node-26` and `bun`, and every image must be pinned by digest. An invalid value prevents the webapp from starting. With the Helm chart, set them through `webapp.extraEnvVars`.
112112

113-
You own a custom base image. It must provide:
113+
The CLI builds with these images for any runtime that has an entry. Runtimes without one keep the published images. It applies to deploys built with the CLI's local build path, which is what self-hosted instances use. `--from-bundle` deploys regenerate the bundle's Containerfile with these images. This is a self-hosting setting and does not apply to Trigger.dev Cloud.
114114

115-
- `node` (or `bun`) on `PATH` at the runtime's major version
115+
You own a custom base image. A Node image must provide:
116+
117+
- `node` on `PATH` at the runtime's major version
116118
- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl`
117119
- a `node` user
118120
- glibc, so native modules built in the build stage load at runtime
119121

122+
A Bun image must provide:
123+
124+
- `bun` and `node` on `PATH`, because the final stage starts the app with `dumb-init node`
125+
- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl`
126+
- a `bun` user
127+
- glibc, so native modules built in the build stage load at runtime
128+
129+
A `DEPLOY_BUILD_BASE_IMAGES` image must contain everything the base image provides, plus the toolchain the published build images include: `python3`, `make` and `g++`. When a project's build extensions add image instructions, those instructions are replayed on the build-stage image, so it must be able to run them. Without a `DEPLOY_BUILD_BASE_IMAGES` entry, the build stage is created from your base image and the toolchain is installed with `apt-get`, so the base image must be Debian-based for those projects.
130+
120131
<Warning>
121132
`image.pkgs` and build extensions that run `apt-get` (such as `aptGet` and `playwright`) assume a
122133
Debian base. On other distributions, install those packages in your base image instead.

‎packages/cli-v3/src/commands/deploy.ts‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -638,14 +638,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
638638

639639
warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId);
640640

641-
if (deployment.baseImages) {
642-
logger.debug("Using base images required by the server", deployment.baseImages);
643-
644-
await writeContainerfile(destination.path, {
645-
...buildManifest,
646-
image: { ...buildManifest.image, ...deployment.baseImages },
647-
});
648-
}
641+
await applyServerBaseImages({
642+
baseImages: deployment.baseImages,
643+
outputPath: destination.path,
644+
buildManifest,
645+
options,
646+
});
649647

650648
// When `externalBuildData` is not present the deployment implicitly goes into the local build path
651649
// which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud
@@ -1211,6 +1209,42 @@ function buildDeploymentLinks({
12111209
};
12121210
}
12131211

1212+
async function applyServerBaseImages({
1213+
baseImages,
1214+
outputPath,
1215+
buildManifest,
1216+
options,
1217+
}: {
1218+
baseImages: InitializeDeploymentResponseBody["baseImages"];
1219+
outputPath: string;
1220+
buildManifest: BuildManifest;
1221+
options: DeployCommandOptions;
1222+
}) {
1223+
if (!baseImages) {
1224+
return;
1225+
}
1226+
1227+
logger.debug("Using base images required by the server", baseImages);
1228+
1229+
const required = [
1230+
baseImages.base ? `base ${baseImages.base}` : undefined,
1231+
baseImages.buildBase ? `build ${baseImages.buildBase}` : undefined,
1232+
].filter(Boolean);
1233+
1234+
const message = `Building on base images required by this instance: ${required.join(", ")}`;
1235+
1236+
if (options.plain) {
1237+
console.log(message);
1238+
} else {
1239+
log.info(message);
1240+
}
1241+
1242+
await writeContainerfile(outputPath, {
1243+
...buildManifest,
1244+
image: { ...buildManifest.image, ...baseImages },
1245+
});
1246+
}
1247+
12141248
function warnAboutSkippedBuild(externalId: string | undefined, isPromoted: boolean | undefined) {
12151249
prettyWarning(
12161250
"Environment variables were not synced because nothing was built.",
@@ -2274,6 +2308,13 @@ async function handleFromBundleDeploy({
22742308
existingDeploymentId
22752309
);
22762310

2311+
await applyServerBaseImages({
2312+
baseImages: deployment.baseImages,
2313+
outputPath: bundlePath,
2314+
buildManifest: bundleManifest,
2315+
options,
2316+
});
2317+
22772318
// Fail fast if we know local builds will fail
22782319
const buildxResult = await x("docker", ["buildx", "version"]);
22792320

0 commit comments

Comments
 (0)