Skip to content

Commit d5c1239

Browse files
committed
feat(cli,webapp): enforce instance base images end to end
Reject initialize-deployment requests from CLIs that cannot apply the instance's base images, so the setting is enforced rather than advisory. The CLI declares support on the paths that can honour it, and fails with a clear error on --native-build and --local-bundle, which cannot. Return the base images on the get-deployment response as well, so deploys that attach to an existing deployment build on them too. Validate image refs with one shared schema in core on both the server and the CLI: a single token pinned by digest. Reject the runtime alias node in favour of the concrete runtime keys, and report every invalid env entry in one error at startup. Build the custom build stage with the same customization block as the base stage, so instructions and package installs run in the same order.
1 parent 2a0c5ba commit d5c1239

12 files changed

Lines changed: 165 additions & 53 deletions

File tree

‎.changeset/instance-deploy-base-images.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"trigger.dev": patch
44
---
55

6-
Self-hosted instances can require custom base images for deploys, such as FIPS-validated or hardened Node images, with the new `DEPLOY_BASE_IMAGES` webapp setting. The CLI builds on the base images the instance specifies.
6+
Self-hosted instances can require custom deploy base images per runtime via the new `DEPLOY_BASE_IMAGES` and `DEPLOY_BUILD_BASE_IMAGES` webapp settings. The CLI builds on the images the instance specifies, and older CLIs are rejected with an upgrade message.

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ function durationString() {
1515
return z.string().refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y");
1616
}
1717

18+
const parseDeployBaseImagesEnv = (
19+
value: string | undefined,
20+
envVarName: string,
21+
ctx: z.RefinementCtx
22+
) => {
23+
const { images, errors } = parseDeployBaseImages(value, envVarName);
24+
for (const message of errors) {
25+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
26+
}
27+
return errors.length > 0 ? z.NEVER : images;
28+
};
29+
1830
// Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a
1931
// non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS
2032
// and its _REQUIRED variant. Adds zod issues for empty input or unknown names.
@@ -905,11 +917,11 @@ const EnvironmentSchema = z
905917
DEPLOY_BASE_IMAGES: z
906918
.string()
907919
.optional()
908-
.transform((v) => parseDeployBaseImages(v, "DEPLOY_BASE_IMAGES")),
920+
.transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BASE_IMAGES", ctx)),
909921
DEPLOY_BUILD_BASE_IMAGES: z
910922
.string()
911923
.optional()
912-
.transform((v) => parseDeployBaseImages(v, "DEPLOY_BUILD_BASE_IMAGES")),
924+
.transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BUILD_BASE_IMAGES", ctx)),
913925
DEPLOY_TIMEOUT_MS: z.coerce
914926
.number()
915927
.int()

‎apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
22
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5+
import { env } from "~/env.server";
6+
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
57
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
68
import { logger } from "~/services/logger.server";
79

@@ -65,6 +67,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6567
externalId: deployment.externalId ?? undefined,
6668
externalBuildData:
6769
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
70+
baseImages: resolveDeployBaseImages(deployment.runtime, {
71+
base: env.DEPLOY_BASE_IMAGES,
72+
buildBase: env.DEPLOY_BUILD_BASE_IMAGES,
73+
}),
6874
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
6975
canceledReason: deployment.canceledReason,
7076
worker: deployment.worker

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

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,66 @@
1-
import { BuildRuntime } from "@trigger.dev/core/v3";
1+
import { BuildRuntime, DeployBaseImageRef } from "@trigger.dev/core/v3";
22

33
type BaseImageMap = Partial<Record<BuildRuntime, string>>;
44

5-
const DIGEST_PINNED = /@sha256:[a-f0-9]{64}$/;
5+
export type ParsedDeployBaseImages = { images: BaseImageMap; errors: string[] };
66

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 = {};
7+
export function parseDeployBaseImages(
8+
value: string | undefined,
9+
envVarName: string
10+
): ParsedDeployBaseImages {
11+
const images: BaseImageMap = {};
12+
const errors: string[] = [];
1313

1414
if (!value) {
15-
return result;
15+
return { images, errors };
1616
}
1717

1818
for (const segment of value.split(",").map((s) => s.trim())) {
1919
if (!segment) {
2020
continue;
2121
}
2222

23+
const fail = (reason: string) => errors.push(`${envVarName}: ${reason} in "${segment}"`);
24+
2325
const separator = segment.indexOf("=");
2426
if (separator === -1) {
25-
throw invalidSegment(envVarName, segment, "expected runtime=image");
27+
fail("expected runtime=image");
28+
continue;
2629
}
2730

2831
const runtimeName = segment.slice(0, separator).trim();
2932
const image = segment.slice(separator + 1).trim();
3033

34+
if (runtimeName === "node") {
35+
fail('runtime "node" is an alias; use the concrete runtime (node-22, node-24, node-26)');
36+
continue;
37+
}
38+
3139
const runtime = BuildRuntime.safeParse(runtimeName);
3240
if (!runtime.success) {
33-
throw invalidSegment(
34-
envVarName,
35-
segment,
36-
`unknown runtime "${runtimeName}" (expected one of ${BuildRuntime.options.join(", ")})`
37-
);
41+
fail(`unknown runtime "${runtimeName}" (expected one of ${BuildRuntime.options.join(", ")})`);
42+
continue;
3843
}
3944

4045
if (!image) {
41-
throw invalidSegment(envVarName, segment, "missing image");
46+
fail("missing image");
47+
continue;
4248
}
4349

44-
if (!DIGEST_PINNED.test(image)) {
45-
throw invalidSegment(envVarName, segment, "image must be pinned by digest (@sha256:<64 hex chars>)");
50+
if (!DeployBaseImageRef.safeParse(image).success) {
51+
fail("image must be image@sha256:<64 hex chars> with no whitespace before the digest");
52+
continue;
4653
}
4754

48-
if (runtime.data in result) {
49-
throw invalidSegment(envVarName, segment, `duplicate runtime "${runtimeName}"`);
55+
if (runtime.data in images) {
56+
fail(`duplicate runtime "${runtimeName}"`);
57+
continue;
5058
}
5159

52-
result[runtime.data] = image;
60+
images[runtime.data] = image;
5361
}
5462

55-
return result;
63+
return { images, errors };
5664
}
5765

5866
export function resolveDeployBaseImages(

‎apps/webapp/app/v3/services/initializeDeployment.server.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { generateFriendlyId } from "../friendlyIdentifiers";
1414
import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server";
1515
import { BaseService, ServiceValidationError } from "./baseService.server";
1616
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
17+
import { resolveDeployBaseImages } from "../deployBaseImages.server";
1718
import { getDeploymentImageRef } from "../getDeploymentImageRef.server";
1819
import { tryCatch } from "@trigger.dev/core";
1920
import { getRegistryConfig } from "../registryConfig.server";
@@ -147,6 +148,19 @@ export class InitializeDeploymentService extends BaseService {
147148
throw new ServiceValidationError("UNMANAGED deployments are not supported");
148149
}
149150

151+
if (
152+
resolveDeployBaseImages(runtime, {
153+
base: env.DEPLOY_BASE_IMAGES,
154+
buildBase: env.DEPLOY_BUILD_BASE_IMAGES,
155+
}) &&
156+
payload.supportsInstanceBaseImages !== true
157+
) {
158+
throw new ServiceValidationError(
159+
"This instance requires custom deploy base images, which this version of the CLI cannot apply. Upgrade the trigger.dev CLI and deploy again.",
160+
400
161+
);
162+
}
163+
150164
// Upgrade the project to engine "V2" if it's not already. This should cover cases where people deploy to V2 without running dev first.
151165
if (payload.type === "MANAGED" && environment.project.engine === "V1") {
152166
await this._prisma.project.update({

‎apps/webapp/test/deployBaseImages.test.ts‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ const digestA = `sha256:${"a".repeat(64)}`;
55
const digestB = `sha256:${"b".repeat(64)}`;
66
const digestC = `sha256:${"c".repeat(64)}`;
77

8+
const hex64 = "a".repeat(64);
9+
810
describe("parseDeployBaseImages", () => {
911
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({});
12+
const empty = { images: {}, errors: [] };
13+
expect(parseDeployBaseImages(undefined, "DEPLOY_BASE_IMAGES")).toEqual(empty);
14+
expect(parseDeployBaseImages("", "DEPLOY_BASE_IMAGES")).toEqual(empty);
15+
expect(parseDeployBaseImages(" , ,", "DEPLOY_BASE_IMAGES")).toEqual(empty);
1316
});
1417

1518
it("parses multiple entries and trims whitespace", () => {
@@ -19,31 +22,61 @@ describe("parseDeployBaseImages", () => {
1922
"DEPLOY_BASE_IMAGES"
2023
)
2124
).toEqual({
22-
"node-24": `acme/node-fips:24@${digestA}`,
23-
bun: `acme/bun:1@${digestB}`,
25+
images: {
26+
"node-24": `acme/node-fips:24@${digestA}`,
27+
bun: `acme/bun:1@${digestB}`,
28+
},
29+
errors: [],
30+
});
31+
});
32+
33+
it("accepts a registry with a port and a tag before the digest", () => {
34+
const image = `registry.example.com:5000/ns/img:tag@sha256:${hex64}`;
35+
expect(parseDeployBaseImages(`node-24=${image}`, "DEPLOY_BASE_IMAGES")).toEqual({
36+
images: { "node-24": image },
37+
errors: [],
2438
});
2539
});
2640

2741
it.each([
2842
["missing =", "garbage"],
2943
["unknown runtime", `node-23=acme/node:23@${digestA}`],
44+
["node alias", `node=acme/node:24@${digestA}`],
3045
["empty image", "node-24="],
3146
["missing digest", "node-24=acme/node:24"],
47+
["flag before image", `node-24=--platform=linux/arm64 acme/node@sha256:${hex64}`],
48+
["bare digest", `node-24=@sha256:${hex64}`],
49+
["newline in image", `node-24=acme/node\nx@sha256:${hex64}`],
3250
["duplicate runtime", `node-24=acme/a@${digestA},node-24=acme/b@${digestB}`],
33-
])("throws naming the env var and segment: %s", (_name, value) => {
51+
])("reports an error naming the env var and segment: %s", (_name, value) => {
3452
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);
53+
const offending = segments[segments.length - 1]!.trim();
54+
55+
const { images, errors } = parseDeployBaseImages(
56+
`node-22=acme/ok@${digestC},${value}`,
57+
"DEPLOY_BUILD_BASE_IMAGES"
58+
);
59+
60+
expect(images["node-22"]).toBe(`acme/ok@${digestC}`);
61+
expect(errors).toHaveLength(1);
62+
expect(errors[0]).toContain("DEPLOY_BUILD_BASE_IMAGES");
63+
expect(errors[0]).toContain(offending);
64+
});
65+
66+
it("explains that node is an alias", () => {
67+
const { errors } = parseDeployBaseImages(`node=acme/node@${digestA}`, "DEPLOY_BASE_IMAGES");
68+
expect(errors[0]).toContain('runtime "node" is an alias; use the concrete runtime');
69+
});
70+
71+
it("reports every bad segment", () => {
72+
const { errors } = parseDeployBaseImages(
73+
`garbage,node-23=acme/node@${digestA},bun=acme/bun`,
74+
"DEPLOY_BASE_IMAGES"
75+
);
76+
expect(errors).toHaveLength(3);
77+
expect(errors[0]).toContain("garbage");
78+
expect(errors[1]).toContain("node-23");
79+
expect(errors[2]).toContain("bun=acme/bun");
4780
});
4881
});
4982

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ 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. 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). |
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-22`, `node-24`, `node-26`, `bun`. The digest is required. Projects with `runtime: "node"` resolve to the current default Node runtime (`node-24` today), so set that key for them. Deploys from CLI versions that cannot apply the images are rejected with an upgrade message, and deploys using `--native-build` or `--local-bundle` fail when base images are configured. An invalid value prevents the webapp from starting. See [custom base images](/self-hosting/overview#custom-base-images). |
101101
| `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). |

‎docs/self-hosting/overview.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ Deploys build on the published `triggerdotdev/node` and `triggerdotdev/bun` Debi
108108
DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:<64-character-digest>"
109109
```
110110

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`.
111+
Entries are comma-separated `runtime=image@sha256:<digest>`. The runtimes are `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`.
112+
113+
Projects with `runtime: "node"` in their config resolve to the current default Node runtime (`node-24` today), so set that key for them. Deploys from CLI versions that cannot apply the images are rejected with an error asking to upgrade. Deploys using `--native-build` or `--local-bundle` fail with an error when base images are configured, since those paths cannot apply them.
112114

113115
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.
114116

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
591591
triggeredVia: getTriggeredVia(),
592592
externalId: options.externalId,
593593
force: options.force,
594+
supportsInstanceBaseImages: true,
594595
},
595596
envVars.TRIGGER_EXISTING_DEPLOYMENT_ID
596597
);
@@ -1497,6 +1498,13 @@ async function handleNativeBuildServerDeploy({
14971498

14981499
const deployment = initializeDeploymentResult.data;
14991500

1501+
if (deployment.baseImages) {
1502+
$deploymentSpinner.stop("Failed to initialize deployment");
1503+
throw new Error(
1504+
"This instance requires custom deploy base images, which cannot be applied with --native-build. Deploy without that flag."
1505+
);
1506+
}
1507+
15001508
const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`;
15011509
const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${
15021510
options.env === "prod" ? "prod" : "stg"
@@ -1830,6 +1838,13 @@ async function handleLocalBundleDeploy({
18301838

18311839
const deployment = initializeDeploymentResult.data;
18321840

1841+
if (deployment.baseImages) {
1842+
$deploymentSpinner.stop("Failed to initialize deployment");
1843+
throw new Error(
1844+
"This instance requires custom deploy base images, which cannot be applied with --local-bundle. Deploy without that flag."
1845+
);
1846+
}
1847+
18331848
const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`;
18341849
const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${
18351850
options.env === "prod" ? "prod" : "stg"
@@ -2304,6 +2319,7 @@ async function handleFromBundleDeploy({
23042319
isLocalBuild: true,
23052320
isNativeBuild: false,
23062321
triggeredVia: getTriggeredVia(),
2322+
supportsInstanceBaseImages: true,
23072323
},
23082324
existingDeploymentId
23092325
);

‎packages/cli-v3/src/deploy/buildImage.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,27 @@ describe("generateContainerfile", () => {
289289
expect(containerfile).toContain("FROM acme/node:26-dev@sha256:def AS build");
290290
expect(containerfile).not.toContain("FROM base AS build");
291291
expect(containerfile).not.toContain(TOOLCHAIN_PACKAGES);
292-
expect(buildStage).toContain("apt-get install -y --no-install-recommends --allow-downgrades jq");
292+
expect(buildStage).toContain(
293+
"apt-get install -y --no-install-recommends --allow-downgrades jq"
294+
);
293295
expect(buildStage).toContain("RUN echo first > /etc/first");
294296
expect(buildStage).toContain("RUN echo second > /etc/second");
295297
expect(containerfile.indexOf("RUN echo first > /etc/first")).toBeLessThan(
296298
containerfile.indexOf("AS build")
297299
);
300+
301+
const buildFrom = "FROM acme/node:26-dev@sha256:def AS build";
302+
const baseEnv = "ENV DEBIAN_FRONTEND=noninteractive\n\n";
303+
const baseStart = containerfile.indexOf(baseEnv) + baseEnv.length;
304+
const baseCustomization = containerfile.slice(
305+
baseStart,
306+
containerfile.indexOf(buildFrom) - "\n\n".length
307+
);
308+
309+
expect(buildStage.indexOf("RUN echo second > /etc/second")).toBeLessThan(
310+
buildStage.indexOf("apt-get install")
311+
);
312+
expect(containerfile).toContain(`${buildFrom}\n\n${baseEnv}${baseCustomization}\n\n`);
298313
});
299314

300315
it("builds from the base stage when instructions have no configured build image", async () => {

0 commit comments

Comments
 (0)