diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts
new file mode 100644
index 0000000000..a82d452383
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts
@@ -0,0 +1,71 @@
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { StartDeploymentRequestBody } from "@trigger.dev/core/v3";
+import { z } from "zod";
+import { authenticateRequest } from "~/services/apiAuth.server";
+import { logger } from "~/services/logger.server";
+import { DeploymentService } from "~/v3/services/deployment.server";
+
+const ParamsSchema = z.object({
+ deploymentId: z.string(),
+});
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ if (request.method.toUpperCase() !== "POST") {
+ return json({ error: "Method Not Allowed" }, { status: 405 });
+ }
+
+ const parsedParams = ParamsSchema.safeParse(params);
+
+ if (!parsedParams.success) {
+ return json({ error: "Invalid params" }, { status: 400 });
+ }
+
+ const authenticationResult = await authenticateRequest(request, {
+ apiKey: true,
+ organizationAccessToken: false,
+ personalAccessToken: false,
+ });
+
+ if (!authenticationResult || !authenticationResult.result.ok) {
+ logger.info("Invalid or missing api key", { url: request.url });
+ return json({ error: "Invalid or Missing API key" }, { status: 401 });
+ }
+
+ const { environment: authenticatedEnv } = authenticationResult.result;
+ const { deploymentId } = parsedParams.data;
+
+ const rawBody = await request.json();
+ const body = StartDeploymentRequestBody.safeParse(rawBody);
+
+ if (!body.success) {
+ return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
+ }
+
+ const deploymentService = new DeploymentService();
+
+ return await deploymentService
+ .startDeployment(authenticatedEnv, deploymentId, {
+ contentHash: body.data.contentHash,
+ git: body.data.gitMeta,
+ runtime: body.data.runtime,
+ })
+ .match(
+ () => {
+ return json(null, { status: 204 });
+ },
+ (error) => {
+ switch (error.type) {
+ case "failed_to_extend_deployment_timeout":
+ return json(null, { status: 204 }); // ignore these errors for now
+ case "deployment_not_found":
+ return json({ error: "Deployment not found" }, { status: 404 });
+ case "deployment_not_pending":
+ return json({ error: "Deployment is not pending" }, { status: 409 });
+ case "other":
+ default:
+ error.type satisfies "other";
+ return json({ error: "Internal server error" }, { status: 500 });
+ }
+ }
+ );
+}
diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts
index c80e180d89..8b3280cbb2 100644
--- a/apps/webapp/app/routes/api.v1.deployments.ts
+++ b/apps/webapp/app/routes/api.v1.deployments.ts
@@ -1,8 +1,8 @@
-import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
ApiDeploymentListSearchParams,
InitializeDeploymentRequestBody,
- InitializeDeploymentResponseBody,
+ type InitializeDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { $replica } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts
new file mode 100644
index 0000000000..9789cfc07e
--- /dev/null
+++ b/apps/webapp/app/v3/services/deployment.server.ts
@@ -0,0 +1,85 @@
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { BaseService } from "./baseService.server";
+import { errAsync, fromPromise, okAsync } from "neverthrow";
+import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database";
+import { logger, type GitMeta } from "@trigger.dev/core/v3";
+import { TimeoutDeploymentService } from "./timeoutDeployment.server";
+import { env } from "~/env.server";
+
+export class DeploymentService extends BaseService {
+ public startDeployment(
+ authenticatedEnv: AuthenticatedEnvironment,
+ friendlyId: string,
+ updates: Partial
& { git: GitMeta }>
+ ) {
+ const getDeployment = () =>
+ fromPromise(
+ this._prisma.workerDeployment.findFirst({
+ where: {
+ friendlyId,
+ environmentId: authenticatedEnv.id,
+ },
+ select: {
+ status: true,
+ id: true,
+ },
+ }),
+ (error) => ({
+ type: "other" as const,
+ cause: error,
+ })
+ ).andThen((deployment) => {
+ if (!deployment) {
+ return errAsync({ type: "deployment_not_found" as const });
+ }
+ return okAsync(deployment);
+ });
+
+ const validateDeployment = (deployment: Pick) => {
+ if (deployment.status !== "PENDING") {
+ logger.warn("Attempted starting deployment that is not in PENDING status", {
+ deployment,
+ });
+ return errAsync({ type: "deployment_not_pending" as const });
+ }
+
+ return okAsync(deployment);
+ };
+
+ const updateDeployment = (deployment: Pick) =>
+ fromPromise(
+ this._prisma.workerDeployment.updateMany({
+ where: { id: deployment.id, status: "PENDING" }, // status could've changed in the meantime, we're not locking the row
+ data: { ...updates, status: "BUILDING", startedAt: new Date() },
+ }),
+ (error) => ({
+ type: "other" as const,
+ cause: error,
+ })
+ ).andThen((result) => {
+ if (result.count === 0) {
+ return errAsync({ type: "deployment_not_pending" as const });
+ }
+ return okAsync({ id: deployment.id });
+ });
+
+ const extendTimeout = (deployment: Pick) =>
+ fromPromise(
+ TimeoutDeploymentService.enqueue(
+ deployment.id,
+ "BUILDING" satisfies WorkerDeploymentStatus,
+ "Building timed out",
+ new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
+ ),
+ (error) => ({
+ type: "failed_to_extend_deployment_timeout" as const,
+ cause: error,
+ })
+ ).map(() => undefined);
+
+ return getDeployment()
+ .andThen(validateDeployment)
+ .andThen(updateDeployment)
+ .andThen(extendTimeout);
+ }
+}
diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts
index 234cfa1270..fe11451bde 100644
--- a/apps/webapp/app/v3/services/initializeDeployment.server.ts
+++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts
@@ -99,6 +99,10 @@ export class InitializeDeploymentService extends BaseService {
const { imageRef, isEcr, repoCreated } = imageRefResult;
+ // we keep using `BUILDING` as the initial status if not explicitly set
+ // to avoid changing the behavior for deployments not created in the build server
+ const initialStatus = payload.initialStatus ?? "BUILDING";
+
logger.debug("Creating deployment", {
environmentId: environment.id,
projectId: environment.projectId,
@@ -108,6 +112,7 @@ export class InitializeDeploymentService extends BaseService {
imageRef,
isEcr,
repoCreated,
+ initialStatus,
});
const deployment = await this._prisma.workerDeployment.create({
@@ -116,7 +121,7 @@ export class InitializeDeploymentService extends BaseService {
contentHash: payload.contentHash,
shortCode: deploymentShortCode,
version: nextVersion,
- status: "BUILDING",
+ status: initialStatus,
environmentId: environment.id,
projectId: environment.projectId,
externalBuildData,
@@ -126,14 +131,18 @@ export class InitializeDeploymentService extends BaseService {
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
git: payload.gitMeta ?? undefined,
runtime: payload.runtime ?? undefined,
+ startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
},
});
+ const timeoutMs =
+ deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS;
+
await TimeoutDeploymentService.enqueue(
deployment.id,
- "BUILDING",
+ deployment.status,
"Building timed out",
- new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
+ new Date(Date.now() + timeoutMs)
);
return {
diff --git a/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql b/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql
new file mode 100644
index 0000000000..83a251cd12
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "startedAt" TIMESTAMP(3);
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index d15b2692d0..041a7a0cc4 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -1764,6 +1764,7 @@ model WorkerDeployment {
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?
+ startedAt DateTime?
builtAt DateTime?
deployedAt DateTime?
diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts
index e4c71bb341..7bfd4e23d3 100644
--- a/packages/core/src/v3/schemas/api.ts
+++ b/packages/core/src/v3/schemas/api.ts
@@ -377,6 +377,14 @@ export const FinalizeDeploymentRequestBody = z.object({
export type FinalizeDeploymentRequestBody = z.infer;
+export const StartDeploymentRequestBody = z.object({
+ contentHash: z.string().optional(),
+ gitMeta: GitMeta.optional(),
+ runtime: z.string().optional(),
+});
+
+export type StartDeploymentRequestBody = z.infer;
+
export const ExternalBuildData = z.object({
buildId: z.string(),
buildToken: z.string(),
@@ -419,6 +427,7 @@ export const InitializeDeploymentRequestBody = z.object({
gitMeta: GitMeta.optional(),
type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(),
runtime: z.string().optional(),
+ initialStatus: z.enum(["PENDING", "BUILDING"]).optional(),
});
export type InitializeDeploymentRequestBody = z.infer;