Skip to content

Commit 84b6702

Browse files
committed
feat(deploy): store local-bundle build env vars encrypted on the deployment
Replaces the trigger-build-args.json file and generated .dockerignore: the bundle artifact is now secret-free. Build-arg values are sent with the init request, stored aes-256-gcm encrypted in a new WorkerDeployment.buildEnvVars column, and cleared on every terminal status transition. - new dedicated GET /api/v1/deployments/:id/build-env-vars endpoint, used by the from-bundle build in attach mode; returns an empty record for terminal deployments and never 500s on a bad envelope - size limits enforced server-side and pre-checked client-side (128 KiB serialized, 200 keys) - version-skew guard: the CLI hard-errors when it sent vars and the server did not ack storing them
1 parent 5cf331f commit 84b6702

14 files changed

Lines changed: 305 additions & 58 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { type GetDeploymentBuildEnvVarsResponseBody } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { prisma } from "~/db.server";
5+
import { env } from "~/env.server";
6+
import { authenticateApiRequest } from "~/services/apiAuth.server";
7+
import { logger } from "~/services/logger.server";
8+
import { decryptSecret, EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
9+
import { FINAL_DEPLOYMENT_STATUSES } from "~/v3/services/failDeployment.server";
10+
11+
const ParamsSchema = z.object({
12+
deploymentId: z.string(),
13+
});
14+
15+
// Returns the decrypted build-time env vars stored on a fromBundle deployment.
16+
// Deliberately separate from the main GET deployment endpoint: this is secret
17+
// material, and a dedicated route keeps access explicit and auditable. The vars
18+
// are cleared when the deployment reaches a terminal status, so this only ever
19+
// serves the active build window.
20+
export async function loader({ request, params }: LoaderFunctionArgs) {
21+
const parsedParams = ParamsSchema.safeParse(params);
22+
23+
if (!parsedParams.success) {
24+
return json({ error: "Invalid params" }, { status: 400 });
25+
}
26+
27+
try {
28+
// Next authenticate the request
29+
const authenticationResult = await authenticateApiRequest(request);
30+
31+
if (!authenticationResult) {
32+
logger.info("Invalid or missing api key", { url: request.url });
33+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
34+
}
35+
36+
const authenticatedEnv = authenticationResult.environment;
37+
38+
const { deploymentId } = parsedParams.data;
39+
40+
const deployment = await prisma.workerDeployment.findFirst({
41+
where: {
42+
friendlyId: deploymentId,
43+
environmentId: authenticatedEnv.id,
44+
},
45+
select: {
46+
id: true,
47+
status: true,
48+
buildEnvVars: true,
49+
},
50+
});
51+
52+
if (!deployment) {
53+
return json({ error: "Deployment not found" }, { status: 404 });
54+
}
55+
56+
logger.info("Build env vars read", {
57+
deploymentId,
58+
environmentId: authenticatedEnv.id,
59+
projectId: authenticatedEnv.projectId,
60+
status: deployment.status,
61+
hasVars: deployment.buildEnvVars !== null,
62+
});
63+
64+
// Terminal deployments have their vars cleared; even if a clear is still in
65+
// flight, never serve secrets for a build that is no longer active.
66+
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
67+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
68+
status: 200,
69+
});
70+
}
71+
72+
if (!deployment.buildEnvVars) {
73+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
74+
status: 200,
75+
});
76+
}
77+
78+
const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars);
79+
80+
if (!envelope.success) {
81+
logger.error("Stored build env vars are not a valid encrypted envelope", {
82+
deploymentId,
83+
environmentId: authenticatedEnv.id,
84+
});
85+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
86+
status: 200,
87+
});
88+
}
89+
90+
let variables: Record<string, string>;
91+
92+
try {
93+
const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data);
94+
variables = z.record(z.string()).parse(JSON.parse(decrypted));
95+
} catch (error) {
96+
logger.error("Failed to decrypt stored build env vars", {
97+
deploymentId,
98+
environmentId: authenticatedEnv.id,
99+
error,
100+
});
101+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
102+
status: 200,
103+
});
104+
}
105+
106+
return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 });
107+
} catch (error) {
108+
if (error instanceof Response) throw error;
109+
logger.error("Failed to load deployment build env vars", { error });
110+
return json({ error: "Internal Server Error" }, { status: 500 });
111+
}
112+
}

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
3737
const service = new InitializeDeploymentService();
3838

3939
try {
40-
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
40+
const { deployment, imageRef, eventStream, buildEnvVarsStored } = await service.call(
41+
authenticatedEnv,
42+
body.data
43+
);
4144

4245
const responseBody: InitializeDeploymentResponseBody = {
4346
id: deployment.friendlyId,
@@ -49,6 +52,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
4952
imageTag: imageRef,
5053
imagePlatform: deployment.imagePlatform,
5154
eventStream,
55+
// Only ack when we actually stored vars; older CLIs ignore this field.
56+
...(buildEnvVarsStored ? { buildEnvVarsStored: true } : {}),
5257
};
5358

5459
return json(responseBody, { status: 200 });

apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
22
import { logger, tryCatch } from "@trigger.dev/core/v3";
3-
import type {
4-
BackgroundWorker,
5-
PrismaClientOrTransaction,
6-
WorkerDeployment,
3+
import {
4+
Prisma,
5+
type BackgroundWorker,
6+
type PrismaClientOrTransaction,
7+
type WorkerDeployment,
78
} from "@trigger.dev/database";
89
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
910
import { type TaskMetadataCache } from "~/services/taskMetadataCache.server";
@@ -288,6 +289,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
288289
name: error.name,
289290
message: error.message,
290291
},
292+
// Build env vars only live for the active build window
293+
buildEnvVars: Prisma.DbNull,
291294
},
292295
});
293296

apps/webapp/app/v3/services/deployment.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
22
import { BaseService } from "./baseService.server";
33
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
4-
import { type WorkerDeployment, type Project } from "@trigger.dev/database";
4+
import { Prisma, type WorkerDeployment, type Project } from "@trigger.dev/database";
55
import {
66
BuildServerMetadata,
77
logger,
@@ -227,6 +227,8 @@ export class DeploymentService extends BaseService {
227227
status: "CANCELED",
228228
canceledAt: new Date(),
229229
canceledReason: data?.canceledReason,
230+
// Build env vars only live for the active build window
231+
buildEnvVars: Prisma.DbNull,
230232
},
231233
}),
232234
(error) => ({

apps/webapp/app/v3/services/failDeployment.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
22
import { BaseService } from "./baseService.server";
33
import { logger } from "~/services/logger.server";
4-
import { type WorkerDeploymentStatus } from "@trigger.dev/database";
4+
import { Prisma, type WorkerDeploymentStatus } from "@trigger.dev/database";
55
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
66
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
77
import { DeploymentService } from "./deployment.server";
@@ -49,6 +49,8 @@ export class FailDeploymentService extends BaseService {
4949
status: "FAILED",
5050
failedAt: new Date(),
5151
errorData: params.error,
52+
// Build env vars only live for the active build window
53+
buildEnvVars: Prisma.DbNull,
5254
},
5355
});
5456

apps/webapp/app/v3/services/finalizeDeployment.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
2+
import { Prisma } from "@trigger.dev/database";
23
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
34
import { logger } from "~/services/logger.server";
45
import { updateEnvConcurrencyLimits } from "../runQueue.server";
@@ -75,6 +76,8 @@ export class FinalizeDeploymentService extends BaseService {
7576
deployedAt: new Date(),
7677
// Only add the digest, if any
7778
imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined,
79+
// Build env vars only live for the active build window
80+
buildEnvVars: Prisma.DbNull,
7881
},
7982
});
8083

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { customAlphabet } from "nanoid";
77
import { env } from "~/env.server";
88
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
9+
import { encryptSecret } from "~/services/secrets/secretStore.server";
910
import { logger } from "~/services/logger.server";
1011
import { generateFriendlyId } from "../friendlyIdentifiers";
1112
import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server";
@@ -20,6 +21,11 @@ import { errAsync } from "neverthrow";
2021

2122
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
2223

24+
// Limits for fromBundle build env vars — they expand into --build-arg values, so
25+
// keep them well under exec argv limits while staying generous for env vars.
26+
const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024;
27+
const BUILD_ENV_VARS_MAX_KEYS = 200;
28+
2329
export class InitializeDeploymentService extends BaseService {
2430
public async call(
2531
environment: AuthenticatedEnvironment,
@@ -56,6 +62,7 @@ export class InitializeDeploymentService extends BaseService {
5662
return {
5763
deployment: existingDeployment,
5864
imageRef: existingDeployment.imageReference ?? "",
65+
buildEnvVarsStored: false,
5966
};
6067
}
6168

@@ -172,6 +179,36 @@ export class InitializeDeploymentService extends BaseService {
172179
}
173180
: undefined;
174181

182+
// Encrypt fromBundle build env vars for storage on the deployment row. Only
183+
// meaningful for pre-bundled deploys; cleared on every terminal transition.
184+
let encryptedBuildEnvVars: Awaited<ReturnType<typeof encryptSecret>> | undefined;
185+
186+
if (
187+
payload.isNativeBuild &&
188+
payload.fromBundle &&
189+
payload.buildEnvVars &&
190+
Object.keys(payload.buildEnvVars).length > 0
191+
) {
192+
const buildEnvVars = payload.buildEnvVars;
193+
194+
const keyCount = Object.keys(buildEnvVars).length;
195+
if (keyCount > BUILD_ENV_VARS_MAX_KEYS) {
196+
throw new ServiceValidationError(
197+
`Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).`
198+
);
199+
}
200+
201+
const serialized = JSON.stringify(buildEnvVars);
202+
const serializedBytes = Buffer.byteLength(serialized, "utf8");
203+
if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) {
204+
throw new ServiceValidationError(
205+
`Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.`
206+
);
207+
}
208+
209+
encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized);
210+
}
211+
175212
const buildServerMetadata: BuildServerMetadata | undefined =
176213
payload.isNativeBuild || payload.buildId
177214
? {
@@ -248,6 +285,7 @@ export class InitializeDeploymentService extends BaseService {
248285
projectId: environment.projectId,
249286
externalBuildData,
250287
buildServerMetadata,
288+
buildEnvVars: encryptedBuildEnvVars,
251289
triggeredById: triggeredBy?.id,
252290
type: payload.type,
253291
imageReference: imageRef,
@@ -312,6 +350,7 @@ export class InitializeDeploymentService extends BaseService {
312350
deployment,
313351
imageRef: deployment.imageReference ?? "",
314352
eventStream,
353+
buildEnvVarsStored: encryptedBuildEnvVars !== undefined,
315354
};
316355
});
317356
}

apps/webapp/app/v3/services/timeoutDeployment.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Prisma } from "@trigger.dev/database";
12
import { logger } from "~/services/logger.server";
23
import { BaseService } from "./baseService.server";
34
import { commonWorker } from "../commonWorker.server";
@@ -45,6 +46,8 @@ export class TimeoutDeploymentService extends BaseService {
4546
status: "TIMED_OUT",
4647
failedAt: new Date(),
4748
errorData: { message: errorMessage, name: "TimeoutError" },
49+
// Build env vars only live for the active build window
50+
buildEnvVars: Prisma.DbNull,
4851
},
4952
});
5053

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN IF NOT EXISTS "buildEnvVars" JSONB;

internal-packages/database/prisma/schema.prisma

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,10 @@ model WorkerDeployment {
21332133
21342134
externalBuildData Json?
21352135
buildServerMetadata Json?
2136+
/// Encrypted build-time env vars for pre-bundled (fromBundle) deploys — an
2137+
/// EncryptedSecretValue envelope of a JSON record. Cleared when the deployment
2138+
/// reaches a terminal status; only ever exists for the active build window.
2139+
buildEnvVars Json?
21362140
21372141
status WorkerDeploymentStatus @default(PENDING)
21382142
type WorkerDeploymentType @default(V1)

0 commit comments

Comments
 (0)