Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/instance-deploy-base-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

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.
2 changes: 2 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,8 @@ const EnvironmentSchema = z
),

DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
DEPLOY_BASE_IMAGES: z.string().optional(), // csv of runtime=image, for example: "node-26=registry.example.com/node-fips:26@sha256:..."
DEPLOY_BUILD_BASE_IMAGES: z.string().optional(), // csv of runtime=image for the build stage
DEPLOY_TIMEOUT_MS: z.coerce
.number()
.int()
Expand Down
6 changes: 6 additions & 0 deletions apps/webapp/app/routes/api.v1.deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { env } from "~/env.server";
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";
import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server";

export async function action({ request, params }: ActionFunctionArgs) {
Expand Down Expand Up @@ -60,6 +62,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
? {
externalBuildData: result.deployment
.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
baseImages: resolveDeployBaseImages(result.deployment.runtime, {
base: env.DEPLOY_BASE_IMAGES,
buildBase: env.DEPLOY_BUILD_BASE_IMAGES,
}),
eventStream: result.eventStream,
canceledDeployments: result.canceledDeployments,
}
Expand Down
45 changes: 45 additions & 0 deletions apps/webapp/app/v3/deployBaseImages.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
type BaseImages = { base?: string; buildBase?: string };

/** Base images the operator requires for a runtime, from `runtime=image` csv env vars. */
export function resolveDeployBaseImages(
runtime: string | null | undefined,
config: { base?: string; buildBase?: string }
): BaseImages | undefined {
if (!runtime) {
return undefined;
}

const base = parseImageMap(config.base)[runtime];
const buildBase = parseImageMap(config.buildBase)[runtime];

if (!base && !buildBase) {
return undefined;
}

return {
...(base ? { base } : {}),
...(buildBase ? { buildBase } : {}),
};
}

function parseImageMap(value: string | undefined): Record<string, string> {
if (!value) {
return {};
}

return Object.fromEntries(
value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.flatMap((entry) => {
const separator = entry.indexOf("=");
if (separator <= 0) {
return [];
}
const runtime = entry.slice(0, separator).trim();
const image = entry.slice(separator + 1).trim();
return image ? [[runtime, image] as const] : [];
})
);
}
31 changes: 31 additions & 0 deletions apps/webapp/test/deployBaseImages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server";

describe("resolveDeployBaseImages", () => {
it("returns undefined when nothing is configured", () => {
expect(resolveDeployBaseImages("node-26", {})).toBeUndefined();
});

it("returns the images configured for the runtime", () => {
expect(
resolveDeployBaseImages("node-26", {
base: "node-24=acme/node-fips:24@sha256:aaa, node-26=acme/node-fips:26@sha256:bbb",
buildBase: "node-26=acme/node:26-dev@sha256:ccc",
})
).toEqual({ base: "acme/node-fips:26@sha256:bbb", buildBase: "acme/node:26-dev@sha256:ccc" });
});

it("returns undefined for runtimes without an entry", () => {
expect(resolveDeployBaseImages("bun", { base: "node-26=acme/node-fips:26" })).toBeUndefined();
});

it("returns undefined when the deployment has no runtime", () => {
expect(resolveDeployBaseImages(null, { base: "node-26=acme/node-fips:26" })).toBeUndefined();
});

it("skips malformed entries", () => {
expect(
resolveDeployBaseImages("node-26", { base: "garbage,=nope,node-26=,node-26=acme/node:26" })
).toEqual({ base: "acme/node:26" });
});
});
2 changes: 2 additions & 0 deletions docs/self-hosting/env/webapp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ mode: "wide"
| `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. |
| `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. |
| `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. |
| `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). |
| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage toolchain images per runtime, same format as `DEPLOY_BASE_IMAGES`. Defaults to the published `-build` images. |
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
| `DEPLOY_QUEUE_TIMEOUT_MS` | No | 900000 (15m) | Deploy queue timeout (ms). |
| **Object store (S3)** | | | |
Expand Down
22 changes: 22 additions & 0 deletions docs/self-hosting/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ All fields are optional. Partial overrides are supported:
}
```

## Custom base images

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):

```bash
DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:..."
```

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`.

You own a custom base image. It must provide:

- `node` (or `bun`) on `PATH` at the runtime's major version
- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl`
- a `node` user
Comment on lines +115 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the requirements for a custom Bun base. A Bun image also needs node on PATH: the generated final stage runs dumb-init node. It needs a bun user: the generated build and final stages specify USER bun. An image that provides only the documented bun executable and node user can fail to build or start. State the Node and Bun requirements separately. (docs.docker.com)

- glibc, so native modules built in the build stage load at runtime

<Warning>
`image.pkgs` and build extensions that run `apt-get` (such as `aptGet` and `playwright`) assume a
Debian base. On other distributions, install those packages in your base image instead.
</Warning>

## Community support

It's dangerous to go alone! Join the self-hosting channel on our [Discord server](https://discord.gg/NQTxt5NA7s).
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-v3/src/build/buildWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ async function readProjectPackageJson(packageJsonPath: string) {
return packageJson;
}

async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
export async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) {
throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]");
}
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-v3/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { x } from "tinyexec";
import { z } from "zod";
import chalk from "chalk";
import type { CliApiClient } from "../apiClient.js";
import { buildWorker } from "../build/buildWorker.js";
import { buildWorker, writeContainerfile } from "../build/buildWorker.js";
import { resolveAlwaysExternal } from "../build/externals.js";
import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js";
import { createBundleArchive } from "../deploy/bundleArchive.js";
Expand Down Expand Up @@ -638,6 +638,15 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {

warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId);

if (deployment.baseImages) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Apply the image override to fresh bundle deploys. If a deploy uses --from-bundle, _deployCommand returns before this rewrite. handleFromBundleDeploy then initializes the deployment and builds the supplied bundle without using its baseImages. On an instance with an override, that CLI path does not build from the configured image. Apply the override to the bundle’s Containerfile before buildAndFinalizeFromBundle.

logger.debug("Using base images required by the server", deployment.baseImages);

await writeContainerfile(destination.path, {
...buildManifest,
image: { ...buildManifest.image, ...deployment.baseImages },
});
}

// When `externalBuildData` is not present the deployment implicitly goes into the local build path
// which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud
// and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion.
Expand Down
35 changes: 35 additions & 0 deletions packages/cli-v3/src/deploy/buildImage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,39 @@ describe("generateContainerfile", () => {
expect(excludeCopy).toBeGreaterThan(codeStage);
}
);

it.each(["node", "bun"] as BuildRuntime[])(
"uses the configured base and build images on %s",
async (runtime) => {
const containerfile = await generateContainerfile({
runtime,
build: {},
image: {
base: "acme/node-fips:26@sha256:abc",
buildBase: "acme/node:26-dev@sha256:def",
},
indexScript: "index.js",
entrypoint: "entrypoint.js",
});

expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base");
expect(containerfile).toContain("FROM acme/node:26-dev@sha256:def AS build");
expect(containerfile).toContain("FROM base AS final");
expect(containerfile).not.toContain(BASE_IMAGE[runtime]);
expect(containerfile).not.toContain(BUILD_IMAGE[runtime]);
}
);

it("keeps the published build image when only the base is overridden", async () => {
const containerfile = await generateContainerfile({
runtime: "node-26",
build: {},
image: { base: "acme/node-fips:26@sha256:abc" },
indexScript: "index.js",
entrypoint: "entrypoint.js",
});

expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base");
expect(containerfile).toContain(`FROM ${BUILD_IMAGE["node-26"]} AS build`);
});
});
4 changes: 2 additions & 2 deletions packages/cli-v3/src/deploy/buildImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,14 +808,14 @@ RUN apt-get update && \\
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
: `FROM ${options.image?.buildBase ?? BUILD_IMAGE[options.runtime]} AS build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Honor buildBase when image instructions are present. If image.instructions is nonempty, the other branch generates FROM base AS build. It never reads the operator’s buildBase, even when DEPLOY_BUILD_BASE_IMAGES matches the runtime. This defeats the configured build-stage image for projects with instructions. Make the instructions path use the configured build base, or reject this combination with a clear error. (docs.docker.com)


ENV DEBIAN_FRONTEND=noninteractive${
userPackages.length > 0 ? `\n\n${aptInstall(userPackages, { repair: false })}` : ""
}`;

return {
baseImage: BASE_IMAGE[options.runtime],
baseImage: options.image?.base ?? BASE_IMAGE[options.runtime],
buildStage,
customization,
buildArgs,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,13 @@ export const InitializeDeploymentResponseBody = z.object({
outcome: z.enum(["created", "existing"]).optional(),
isPromoted: z.boolean().optional(),
externalBuildData: ExternalBuildData.optional().nullable(),
/** Base images the instance operator requires for this deployment's runtime */
baseImages: z
.object({
base: z.string().optional(),
buildBase: z.string().optional(),
})
.optional(),
canceledDeployments: z.array(z.object({ version: z.string(), shortCode: z.string() })).optional(),
eventStream: z
.object({
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/v3/schemas/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const BuildManifest = z.object({
.object({
pkgs: z.array(z.string()).optional(),
instructions: z.array(z.string()).optional(),
base: z.string().optional(),
buildBase: z.string().optional(),
})
.optional(),
otelImportHook: z
Expand Down