Skip to content

Commit 5d3fb37

Browse files
feat(webapp,cli): let self-hosted instances require deploy base images
Adds DEPLOY_BASE_IMAGES / DEPLOY_BUILD_BASE_IMAGES (runtime=image csv) to the webapp. The deployment initialize response carries the images for the deploy's runtime, and the CLI rewrites the Containerfile to build on them.
1 parent eb04c31 commit 5d3fb37

13 files changed

Lines changed: 171 additions & 4 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 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.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,8 @@ const EnvironmentSchema = z
901901
),
902902

903903
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
904906
DEPLOY_TIMEOUT_MS: z.coerce
905907
.number()
906908
.int()

‎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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type BaseImages = { base?: string; buildBase?: string };
2+
3+
/** Base images the operator requires for a runtime, from `runtime=image` csv env vars. */
4+
export function resolveDeployBaseImages(
5+
runtime: string | null | undefined,
6+
config: { base?: string; buildBase?: string }
7+
): BaseImages | undefined {
8+
if (!runtime) {
9+
return undefined;
10+
}
11+
12+
const base = parseImageMap(config.base)[runtime];
13+
const buildBase = parseImageMap(config.buildBase)[runtime];
14+
15+
if (!base && !buildBase) {
16+
return undefined;
17+
}
18+
19+
return {
20+
...(base ? { base } : {}),
21+
...(buildBase ? { buildBase } : {}),
22+
};
23+
}
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
3+
4+
describe("resolveDeployBaseImages", () => {
5+
it("returns undefined when nothing is configured", () => {
6+
expect(resolveDeployBaseImages("node-26", {})).toBeUndefined();
7+
});
8+
9+
it("returns the images configured for the runtime", () => {
10+
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" });
16+
});
17+
18+
it("returns undefined for runtimes without an entry", () => {
19+
expect(resolveDeployBaseImages("bun", { base: "node-26=acme/node-fips:26" })).toBeUndefined();
20+
});
21+
22+
it("returns undefined when the deployment has no runtime", () => {
23+
expect(resolveDeployBaseImages(null, { base: "node-26=acme/node-fips:26" })).toBeUndefined();
24+
});
25+
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" });
30+
});
31+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +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. |
100102
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
101103
| `DEPLOY_QUEUE_TIMEOUT_MS` | No | 900000 (15m) | Deploy queue timeout (ms). |
102104
| **Object store (S3)** | | | |

‎docs/self-hosting/overview.mdx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ 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:..."
109+
```
110+
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`.
112+
113+
You own a custom base image. It must provide:
114+
115+
- `node` (or `bun`) on `PATH` at the runtime's major version
116+
- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl`
117+
- a `node` user
118+
- glibc, so native modules built in the build stage load at runtime
119+
120+
<Warning>
121+
`image.pkgs` and build extensions that run `apt-get` (such as `aptGet` and `playwright`) assume a
122+
Debian base. On other distributions, install those packages in your base image instead.
123+
</Warning>
124+
103125
## Community support
104126

105127
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
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { x } from "tinyexec";
2222
import { z } from "zod";
2323
import chalk from "chalk";
2424
import type { CliApiClient } from "../apiClient.js";
25-
import { buildWorker } from "../build/buildWorker.js";
25+
import { buildWorker, writeContainerfile } from "../build/buildWorker.js";
2626
import { resolveAlwaysExternal } from "../build/externals.js";
2727
import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js";
2828
import { createBundleArchive } from "../deploy/bundleArchive.js";
@@ -638,6 +638,15 @@ 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+
}
649+
641650
// When `externalBuildData` is not present the deployment implicitly goes into the local build path
642651
// which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud
643652
// and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion.

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,39 @@ describe("generateContainerfile", () => {
233233
expect(excludeCopy).toBeGreaterThan(codeStage);
234234
}
235235
);
236+
237+
it.each(["node", "bun"] as BuildRuntime[])(
238+
"uses the configured base and build images on %s",
239+
async (runtime) => {
240+
const containerfile = await generateContainerfile({
241+
runtime,
242+
build: {},
243+
image: {
244+
base: "acme/node-fips:26@sha256:abc",
245+
buildBase: "acme/node:26-dev@sha256:def",
246+
},
247+
indexScript: "index.js",
248+
entrypoint: "entrypoint.js",
249+
});
250+
251+
expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base");
252+
expect(containerfile).toContain("FROM acme/node:26-dev@sha256:def AS build");
253+
expect(containerfile).toContain("FROM base AS final");
254+
expect(containerfile).not.toContain(BASE_IMAGE[runtime]);
255+
expect(containerfile).not.toContain(BUILD_IMAGE[runtime]);
256+
}
257+
);
258+
259+
it("keeps the published build image when only the base is overridden", async () => {
260+
const containerfile = await generateContainerfile({
261+
runtime: "node-26",
262+
build: {},
263+
image: { base: "acme/node-fips:26@sha256:abc" },
264+
indexScript: "index.js",
265+
entrypoint: "entrypoint.js",
266+
});
267+
268+
expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base");
269+
expect(containerfile).toContain(`FROM ${BUILD_IMAGE["node-26"]} AS build`);
270+
});
236271
});

0 commit comments

Comments
 (0)