Skip to content

Commit f866210

Browse files
authored
feat(cli): experimental --local-bundle deploy mode (#4331)
Adds an experimental `--local-bundle` flag to native build deployments: the project is installed and bundled on the local machine (exactly like in the depot path) and only the resulting build context is uploaded. The remote build then runs just the container image build. ### Design - The uploaded artifact is the same build context classic deploys produce: bundled output, a synthesized package.json with the resolved externals, build.json, and the generated Containerfile. The bundle is secret-free: build.json is deliberately scrubbed because it is copied into the image, and build-arg values never enter the bundle at all. - Build-arg values are sent with the deployment initialization request instead, stored encrypted (aes-256-gcm) in a new `WorkerDeployment.buildEnvVars` column, and cleared on every terminal status transition. They exist at rest only for the active build window, always encrypted. - A dedicated `GET /api/v1/deployments/:id/build-env-vars` endpoint returns the decrypted values to the same principals that can already read the environment's variables. It answers with an empty record for deployments without stored values or in a terminal state, keeping secret access to a single auditable route. - Size limits are enforced server side and pre-checked client side. If the server does not acknowledge storing the values, the CLI fails fast instead of letting the remote build run without them. - A `--from-bundle <dir>` mode builds a deployment image straight from such a bundle directory, skipping config loading and bundling entirely. In attach mode it fetches the stored build-arg values through the new endpoint. - Env var syncing (the `syncEnvVars` extension) happens client side, before the deployment initializes, since the remote side never sees the unscrubbed manifest. - Bundle artifacts use a distinct type and storage prefix so the server can always distinguish them from source uploads.
1 parent cc69ff4 commit f866210

20 files changed

Lines changed: 1413 additions & 11 deletions

.changeset/local-bundle-deploy.md

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+
Add an experimental `--local-bundle` deploy flag that runs the install and bundling steps on your machine and uploads only the build output; the image is still built remotely. Useful when your project's install step needs tooling or credentials that only exist locally.

apps/webapp/app/env.server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,19 @@ const EnvironmentSchema = z
816816
.number()
817817
.int()
818818
.default(60 * 1000 * 15), // 15 minutes
819+
DEPLOYMENT_CONTEXT_ARTIFACT_SIZE_LIMIT_BYTES: z.coerce
820+
.number()
821+
.int()
822+
.default(100 * 1024 * 1024), // 100MB
823+
DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES: z.coerce
824+
.number()
825+
.int()
826+
.default(100 * 1024 * 1024), // 100MB
827+
DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES: z.coerce
828+
.number()
829+
.int()
830+
.default(128 * 1024), // 128KB
831+
DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS: z.coerce.number().int().default(400),
819832

820833
// When enabled, reject deploys made by v3 CLI versions (i.e. payloads that
821834
// omit the `type` field). v4 CLI versions always send `type` ("MANAGED" or "V1"),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export async function action({ request }: ActionFunctionArgs) {
6464
case "deployment_context":
6565
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
6666
break;
67+
case "deployment_bundle":
68+
errorMessage = `Bundle size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Reach out to us if you are seeing this error consistently.`;
69+
break;
6770
default:
6871
body.data.type satisfies never;
6972
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 { authenticateApiKeyWithScope } 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+
// Secret material, deliberately separate from the main GET deployment endpoint.
16+
export async function loader({ request, params }: LoaderFunctionArgs) {
17+
const parsedParams = ParamsSchema.safeParse(params);
18+
19+
if (!parsedParams.success) {
20+
return json({ error: "Invalid params" }, { status: 400 });
21+
}
22+
23+
try {
24+
const authResult = await authenticateApiKeyWithScope(request, {
25+
action: "read",
26+
resource: { type: "deployments" },
27+
});
28+
29+
if (!authResult.ok) {
30+
logger.info("Invalid or missing api key", { url: request.url });
31+
return json({ error: authResult.error }, { status: authResult.status });
32+
}
33+
34+
const authenticatedEnv = authResult.authentication.environment;
35+
36+
const { deploymentId } = parsedParams.data;
37+
38+
const deployment = await prisma.workerDeployment.findFirst({
39+
where: {
40+
friendlyId: deploymentId,
41+
environmentId: authenticatedEnv.id,
42+
},
43+
select: {
44+
id: true,
45+
status: true,
46+
buildEnvVars: true,
47+
},
48+
});
49+
50+
if (!deployment) {
51+
return json({ error: "Deployment not found" }, { status: 404 });
52+
}
53+
54+
logger.info("Build env vars read", {
55+
deploymentId,
56+
environmentId: authenticatedEnv.id,
57+
projectId: authenticatedEnv.projectId,
58+
status: deployment.status,
59+
hasVars: deployment.buildEnvVars !== null,
60+
});
61+
62+
// Never serve secrets for a build that is no longer active, even if a clear is still in flight
63+
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
64+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
65+
status: 200,
66+
});
67+
}
68+
69+
if (!deployment.buildEnvVars) {
70+
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
71+
status: 200,
72+
});
73+
}
74+
75+
// Present-but-unreadable must fail loud: an empty record would let the build run without its secrets
76+
const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars);
77+
78+
if (!envelope.success) {
79+
logger.error("Stored build env vars are not a valid encrypted envelope", {
80+
deploymentId,
81+
environmentId: authenticatedEnv.id,
82+
});
83+
return json(
84+
{ error: "The stored build environment variables could not be read. Retry the deploy." },
85+
{ status: 500 }
86+
);
87+
}
88+
89+
const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data);
90+
const variables = z.record(z.string()).parse(JSON.parse(decrypted));
91+
92+
return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 });
93+
} catch (error) {
94+
if (error instanceof Response) throw error;
95+
logger.error("Failed to load deployment build env vars", { error });
96+
return json({ error: "Internal Server Error" }, { status: 500 });
97+
}
98+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,7 @@ export async function enqueueBuild(
10951095
options: {
10961096
skipPromotion?: boolean;
10971097
configFilePath?: string;
1098+
fromBundle?: boolean;
10981099
}
10991100
) {
11001101
if (!client) return undefined;
@@ -1235,6 +1236,10 @@ export function isCloud(): boolean {
12351236
return true;
12361237
}
12371238

1239+
if (env.LOGIN_ORIGIN?.endsWith(".triggerlabs.dev")) {
1240+
return true;
1241+
}
1242+
12381243
if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") {
12391244
return true;
12401245
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,19 @@ const objectStoreClient =
2424

2525
const artifactKeyPrefixByType = {
2626
deployment_context: "deployments",
27+
// The key prefix is the one bundle signal that survives schema skew
28+
deployment_bundle: "bundles",
2729
} as const;
2830
const artifactBytesSizeLimitByType = {
29-
deployment_context: 100 * 1024 * 1024, // 100MB
31+
deployment_context: env.DEPLOYMENT_CONTEXT_ARTIFACT_SIZE_LIMIT_BYTES,
32+
deployment_bundle: env.DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES,
3033
} as const;
3134

3235
export class ArtifactsService extends BaseService {
3336
private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET;
3437

3538
public createArtifact(
36-
type: "deployment_context",
39+
type: "deployment_context" | "deployment_bundle",
3740
authenticatedEnv: AuthenticatedEnvironment,
3841
contentLength?: number
3942
) {

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

Lines changed: 6 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";
@@ -313,6 +314,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
313314
name: error.name,
314315
message: error.message,
315316
},
317+
buildEnvVars: Prisma.DbNull,
316318
},
317319
});
318320

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, boundedIn } from "@trigger.dev/database";
4+
import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database";
55
import {
66
BuildServerMetadata,
77
logger,
@@ -227,6 +227,7 @@ export class DeploymentService extends BaseService {
227227
status: "CANCELED",
228228
canceledAt: new Date(),
229229
canceledReason: data?.canceledReason,
230+
buildEnvVars: Prisma.DbNull,
230231
},
231232
}),
232233
(error) => ({
@@ -339,6 +340,7 @@ export class DeploymentService extends BaseService {
339340
options: {
340341
skipPromotion?: boolean;
341342
configFilePath?: string;
343+
fromBundle?: boolean;
342344
}
343345
) {
344346
return fromPromise(

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

Lines changed: 2 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,7 @@ export class FailDeploymentService extends BaseService {
4949
status: "FAILED",
5050
failedAt: new Date(),
5151
errorData: params.error,
52+
buildEnvVars: Prisma.DbNull,
5253
},
5354
});
5455

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

Lines changed: 2 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";
@@ -76,6 +77,7 @@ export class FinalizeDeploymentService extends BaseService {
7677
deployedAt: new Date(),
7778
// Only add the digest, if any
7879
imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined,
80+
buildEnvVars: Prisma.DbNull,
7981
},
8082
});
8183

0 commit comments

Comments
 (0)