Skip to content

Commit 93465d8

Browse files
feat(webapp,cli): let self-hosted instances require deploy base images (#5005)
Closes #5004 Lets a self-hosted operator require custom base images for every deploy to their instance, such as FIPS-validated or hardened Node images. Cloud never sets the variables, so nothing changes there. ```bash DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:<digest>" DEPLOY_BUILD_BASE_IMAGES="node-26=registry.example.com/node:26-dev@sha256:<digest>" # optional ``` - Webapp validates both variables at startup (known runtime, digest-pinned, no duplicates) and fails loud on bad values. - Deployments return the images for their runtime as `baseImages`; the CLI rewrites the Containerfile with them. A configured build image is also used when a project has `image.instructions`. - Deploys from CLIs that can't apply them, and `--native-build`, `--local-bundle` and `--from-bundle` deploys, are rejected with a clear error. - Docs: env rows plus a "Custom base images" section in the self-hosting overview. ## Testing `apps/webapp/test/deployBaseImages.test.ts` and `packages/cli-v3/src/deploy/buildImage.test.ts` cover the parsing, rejection and Containerfile cases. Typecheck, format and lint are clean. --------- Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
1 parent 21f1dcc commit 93465d8

15 files changed

Lines changed: 444 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@trigger.dev/core/v3/isomorphic";
66
import { BoolEnv } from "./utils/boolEnv";
77
import { isValidDatabaseUrl } from "./utils/db";
8+
import { parseDeployBaseImages } from "~/v3/deployBaseImages.server";
89
import { parseRunOpsShards, validateShardListAgainstNewUrl } from "~/v3/runOpsShards.server";
910
import { isValidRegex } from "./utils/regex";
1011
import { isValidDuration } from "./services/realtime/duration.server";
@@ -16,6 +17,18 @@ function durationString() {
1617
return z.string().refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y");
1718
}
1819

20+
const parseDeployBaseImagesEnv = (
21+
value: string | undefined,
22+
envVarName: string,
23+
ctx: z.RefinementCtx
24+
) => {
25+
const { images, errors } = parseDeployBaseImages(value, envVarName);
26+
for (const message of errors) {
27+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
28+
}
29+
return errors.length > 0 ? z.NEVER : images;
30+
};
31+
1932
const GithubAppEnvSchema = z.preprocess(
2033
(val) => {
2134
const obj = val as any;
@@ -856,6 +869,14 @@ const EnvironmentSchema = z
856869
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY),
857870

858871
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
872+
DEPLOY_BASE_IMAGES: z
873+
.string()
874+
.optional()
875+
.transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BASE_IMAGES", ctx)),
876+
DEPLOY_BUILD_BASE_IMAGES: z
877+
.string()
878+
.optional()
879+
.transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BUILD_BASE_IMAGES", ctx)),
859880
DEPLOY_TIMEOUT_MS: z.coerce
860881
.number()
861882
.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/routes/api.v1.deployments.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
99
import { logger } from "~/services/logger.server";
1010
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
1111
import { ServiceValidationError } from "~/v3/services/baseService.server";
12+
import { env } from "~/env.server";
13+
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
1214
import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server";
1315

1416
export async function action({ request, params }: ActionFunctionArgs) {
@@ -60,6 +62,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
6062
? {
6163
externalBuildData: result.deployment
6264
.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
65+
baseImages: resolveDeployBaseImages(result.deployment.runtime, {
66+
base: env.DEPLOY_BASE_IMAGES,
67+
buildBase: env.DEPLOY_BUILD_BASE_IMAGES,
68+
}),
6369
eventStream: result.eventStream,
6470
canceledDeployments: result.canceledDeployments,
6571
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { BuildRuntime, DeployBaseImageRef } from "@trigger.dev/core/v3";
2+
3+
type BaseImageMap = Partial<Record<BuildRuntime, string>>;
4+
5+
export function parseDeployBaseImages(
6+
value: string | undefined,
7+
envVarName: string
8+
): { images: BaseImageMap; errors: string[] } {
9+
const images: BaseImageMap = {};
10+
const errors: string[] = [];
11+
12+
if (!value) {
13+
return { images, errors };
14+
}
15+
16+
for (const segment of value.split(",").map((s) => s.trim())) {
17+
if (!segment) {
18+
continue;
19+
}
20+
21+
const fail = (reason: string) => errors.push(`${envVarName}: ${reason} in "${segment}"`);
22+
23+
const separator = segment.indexOf("=");
24+
if (separator === -1) {
25+
fail("expected runtime=image");
26+
continue;
27+
}
28+
29+
const runtimeName = segment.slice(0, separator).trim();
30+
const image = segment.slice(separator + 1).trim();
31+
32+
if (runtimeName === "node") {
33+
fail('runtime "node" is an alias; use the concrete runtime (node-22, node-24, node-26)');
34+
continue;
35+
}
36+
37+
const runtime = BuildRuntime.safeParse(runtimeName);
38+
if (!runtime.success) {
39+
fail(`unknown runtime "${runtimeName}" (expected one of ${BuildRuntime.options.join(", ")})`);
40+
continue;
41+
}
42+
43+
if (!image) {
44+
fail("missing image");
45+
continue;
46+
}
47+
48+
if (!DeployBaseImageRef.safeParse(image).success) {
49+
fail("image must be image@sha256:<64 hex chars> with no whitespace before the digest");
50+
continue;
51+
}
52+
53+
if (runtime.data in images) {
54+
fail(`duplicate runtime "${runtimeName}"`);
55+
continue;
56+
}
57+
58+
images[runtime.data] = image;
59+
}
60+
61+
return { images, errors };
62+
}
63+
64+
export function resolveDeployBaseImages(
65+
runtime: string | null | undefined,
66+
config: { base: BaseImageMap; buildBase: BaseImageMap }
67+
): { base?: string; buildBase?: string } | undefined {
68+
const parsedRuntime = BuildRuntime.safeParse(runtime);
69+
if (!parsedRuntime.success) {
70+
return undefined;
71+
}
72+
73+
const base = config.base[parsedRuntime.data];
74+
const buildBase = config.buildBase[parsedRuntime.data];
75+
76+
return base || buildBase ? { base, buildBase } : undefined;
77+
}

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

Lines changed: 20 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,25 @@ export class InitializeDeploymentService extends BaseService {
147148
throw new ServiceValidationError("UNMANAGED deployments are not supported");
148149
}
149150

151+
const requiredBaseImages = resolveDeployBaseImages(runtime, {
152+
base: env.DEPLOY_BASE_IMAGES,
153+
buildBase: env.DEPLOY_BUILD_BASE_IMAGES,
154+
});
155+
156+
if (requiredBaseImages && payload.isNativeBuild) {
157+
throw new ServiceValidationError(
158+
"This instance requires custom deploy base images, which native builds cannot apply. Deploy without --native-build or --local-bundle.",
159+
400
160+
);
161+
}
162+
163+
if (requiredBaseImages && payload.supportsInstanceBaseImages !== true) {
164+
throw new ServiceValidationError(
165+
"This instance requires custom deploy base images, which this version of the CLI cannot apply. Upgrade the trigger.dev CLI and deploy again.",
166+
400
167+
);
168+
}
169+
150170
// Upgrade the project to engine "V2" if it's not already. This should cover cases where people deploy to V2 without running dev first.
151171
if (payload.type === "MANAGED" && environment.project.engine === "V1") {
152172
await this._prisma.project.update({
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseDeployBaseImages, resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
3+
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+
const empty = { images: {}, errors: [] };
11+
expect(parseDeployBaseImages(undefined, "DEPLOY_BASE_IMAGES")).toEqual(empty);
12+
expect(parseDeployBaseImages("", "DEPLOY_BASE_IMAGES")).toEqual(empty);
13+
expect(parseDeployBaseImages(" , ,", "DEPLOY_BASE_IMAGES")).toEqual(empty);
14+
});
15+
16+
it("parses multiple entries and trims whitespace", () => {
17+
expect(
18+
parseDeployBaseImages(
19+
` node-24 = acme/node-fips:24@${digestA} , bun=acme/bun:1@${digestB},`,
20+
"DEPLOY_BASE_IMAGES"
21+
)
22+
).toEqual({
23+
images: {
24+
"node-24": `acme/node-fips:24@${digestA}`,
25+
bun: `acme/bun:1@${digestB}`,
26+
},
27+
errors: [],
28+
});
29+
});
30+
31+
it("accepts a registry with a port and a tag before the digest", () => {
32+
const image = `registry.example.com:5000/ns/img:tag@${digestA}`;
33+
expect(parseDeployBaseImages(`node-24=${image}`, "DEPLOY_BASE_IMAGES")).toEqual({
34+
images: { "node-24": image },
35+
errors: [],
36+
});
37+
});
38+
39+
it.each([
40+
["missing =", "garbage"],
41+
["unknown runtime", `node-23=acme/node:23@${digestA}`],
42+
["node alias", `node=acme/node:24@${digestA}`],
43+
["empty image", "node-24="],
44+
["missing digest", "node-24=acme/node:24"],
45+
["flag before image", `node-24=--platform=linux/arm64 acme/node@${digestA}`],
46+
["bare digest", `node-24=@${digestA}`],
47+
["newline in image", `node-24=acme/node\nx@${digestA}`],
48+
["duplicate runtime", `node-24=acme/a@${digestA},node-24=acme/b@${digestB}`],
49+
])("reports an error naming the env var and segment: %s", (_name, value) => {
50+
const segments = value.split(",");
51+
const offending = segments[segments.length - 1]!.trim();
52+
53+
const { images, errors } = parseDeployBaseImages(
54+
`node-22=acme/ok@${digestC},${value}`,
55+
"DEPLOY_BUILD_BASE_IMAGES"
56+
);
57+
58+
expect(images["node-22"]).toBe(`acme/ok@${digestC}`);
59+
expect(errors).toHaveLength(1);
60+
expect(errors[0]).toContain("DEPLOY_BUILD_BASE_IMAGES");
61+
expect(errors[0]).toContain(offending);
62+
});
63+
64+
it("explains that node is an alias", () => {
65+
const { errors } = parseDeployBaseImages(`node=acme/node@${digestA}`, "DEPLOY_BASE_IMAGES");
66+
expect(errors[0]).toContain('runtime "node" is an alias; use the concrete runtime');
67+
});
68+
69+
it("reports every bad segment", () => {
70+
const { errors } = parseDeployBaseImages(
71+
`garbage,node-23=acme/node@${digestA},bun=acme/bun`,
72+
"DEPLOY_BASE_IMAGES"
73+
);
74+
expect(errors).toHaveLength(3);
75+
expect(errors[0]).toContain("garbage");
76+
expect(errors[1]).toContain("node-23");
77+
expect(errors[2]).toContain("bun=acme/bun");
78+
});
79+
});
80+
81+
describe("resolveDeployBaseImages", () => {
82+
const base = { "node-26": `acme/node-fips:26@${digestA}` } as const;
83+
const buildBase = { "node-26": `acme/node:26-dev@${digestB}` } as const;
84+
85+
it("returns undefined for a missing or unknown runtime", () => {
86+
expect(resolveDeployBaseImages("node-23", { base, buildBase })).toBeUndefined();
87+
expect(resolveDeployBaseImages(null, { base, buildBase })).toBeUndefined();
88+
expect(resolveDeployBaseImages(undefined, { base, buildBase })).toBeUndefined();
89+
});
90+
91+
it("returns undefined when the runtime has no entries", () => {
92+
expect(resolveDeployBaseImages("bun", { base, buildBase })).toBeUndefined();
93+
expect(resolveDeployBaseImages("node-26", { base: {}, buildBase: {} })).toBeUndefined();
94+
});
95+
96+
it("returns both images", () => {
97+
expect(resolveDeployBaseImages("node-26", { base, buildBase })).toEqual({
98+
base: base["node-26"],
99+
buildBase: buildBase["node-26"],
100+
});
101+
});
102+
103+
it("returns only the base image", () => {
104+
expect(resolveDeployBaseImages("node-26", { base, buildBase: {} })).toEqual({
105+
base: base["node-26"],
106+
});
107+
});
108+
109+
it("returns only the build base image", () => {
110+
expect(resolveDeployBaseImages("node-26", { base: {}, buildBase })).toEqual({
111+
buildBase: buildBase["node-26"],
112+
});
113+
});
114+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ mode: "wide"
101101
| `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. |
102102
| `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. |
103103
| `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. |
104+
| `DEPLOY_BASE_IMAGES` | No | — | Base images every deploy must build on, as comma-separated `runtime=image@sha256:<digest>`. See [custom base images](/self-hosting/overview#custom-base-images). |
105+
| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage images, in the same format as `DEPLOY_BASE_IMAGES`. See [custom base images](/self-hosting/overview#custom-base-images). |
104106
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
105107
| `DEPLOY_QUEUE_TIMEOUT_MS` | No | 900000 (15m) | Deploy queue timeout (ms). |
106108
| **Object store (S3)** | | | |

‎docs/self-hosting/overview.mdx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,32 @@ All fields are optional. Partial overrides are supported:
100100
}
101101
```
102102

103+
## Custom base images
104+
105+
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):
106+
107+
```bash
108+
DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:<64-character-digest>"
109+
```
110+
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. Projects with `runtime: "node"` in their config resolve to the current default Node runtime (`node-24` today), so set that key for them. With the Helm chart, set the variables through `webapp.extraEnvVars`.
112+
113+
Runtimes with an entry build on that image; the others keep the published images. Deploys from CLI versions that cannot apply the images are rejected with an error asking to upgrade, and so are `--native-build`, `--local-bundle` and `--from-bundle` deploys. This is a self-hosting setting and does not apply to Trigger.dev Cloud.
114+
115+
You own a custom base image. It must provide:
116+
117+
- `node` on `PATH` at the runtime's major version (for `bun`, both `bun` and `node`, because the final stage starts the app with `dumb-init node`)
118+
- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl`
119+
- a `node` user (a `bun` user for the Bun runtime)
120+
- glibc, so native modules built in the build stage load at runtime
121+
122+
A `DEPLOY_BUILD_BASE_IMAGES` image needs everything the base image provides, plus `python3`, `make` and `g++`. Build extensions that add image instructions are replayed on it. Without an entry, the build stage uses the published build image, except for projects whose build extensions add image instructions: those build from your base image and install the toolchain with `apt-get`, so that base must be Debian-based. To avoid the published images entirely, set both variables.
123+
124+
<Warning>
125+
`image.pkgs` and build extensions that run `apt-get` (such as `aptGet` and `playwright`) assume a
126+
Debian base. On other distributions, install those packages in your base image instead.
127+
</Warning>
128+
103129
## Community support
104130

105131
It's dangerous to go alone! Join the self-hosting channel on our [Discord server](https://discord.gg/NQTxt5NA7s).

‎packages/cli-v3/src/build/buildWorker.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ async function readProjectPackageJson(packageJsonPath: string) {
276276
return packageJson;
277277
}
278278

279-
async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
279+
export async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
280280
if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) {
281281
throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]");
282282
}

0 commit comments

Comments
 (0)