Object Storage seamless migration (#3275)

This allows seamless migration to different object storage.

Existing runs that have offloaded payloads/outputs will continue to use
the default object store (configured using `OBJECT_STORE_*` env vars).

You can add additional stores by setting new env vars:
- `OBJECT_STORE_DEFAULT_PROTOCOL` this determines where new run large
payloads will get stored.
- If you set that you need to set new env vars for that protocol.
  
Example:

```
OBJECT_STORE_DEFAULT_PROTOCOL=“s3"
OBJECT_STORE_S3_BASE_URL=https://s3.us-east-1.amazonaws.com
OBJECT_STORE_S3_ACCESS_KEY_ID=<val>
OBJECT_STORE_S3_SECRET_ACCESS_KEY=<val>
OBJECT_STORE_S3_REGION=us-east-1
OBJECT_STORE_S3_SERVICE=s3
```

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
This commit is contained in:
Matt Aitken
2026-04-01 10:06:12 +01:00
committed by GitHub
parent 2637e47195
commit 68e88d0d71
32 changed files with 1915 additions and 437 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Large run outputs can use the new API which allows switching object storage providers.
+22 -3
View File
@@ -77,9 +77,28 @@ POSTHOG_PROJECT_KEY=
# DEPOT_TOKEN=<Depot org token>
# DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://0.0.0.0:4318"
# These are needed for the object store (for handling large payloads/outputs)
# OBJECT_STORE_BASE_URL="https://{bucket}.{accountId}.r2.cloudflarestorage.com"
# OBJECT_STORE_ACCESS_KEY_ID=
# OBJECT_STORE_SECRET_ACCESS_KEY=
#
# Default provider
# OBJECT_STORE_BASE_URL=http://localhost:9005
# OBJECT_STORE_BUCKET=packets
# OBJECT_STORE_ACCESS_KEY_ID=minioadmin
# OBJECT_STORE_SECRET_ACCESS_KEY=minioadmin
# OBJECT_STORE_REGION=us-east-1
# OBJECT_STORE_SERVICE=s3
#
# OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Only specify this if you're going to migrate object storage and set protocol values below
# Named providers (protocol-prefixed data) - optional for multi-provider support
# OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
# OBJECT_STORE_S3_ACCESS_KEY_ID=
# OBJECT_STORE_S3_SECRET_ACCESS_KEY=
# OBJECT_STORE_S3_REGION=us-east-1
# OBJECT_STORE_S3_SERVICE=s3
#
# OBJECT_STORE_R2_BASE_URL=https://{bucket}.{accountId}.r2.cloudflarestorage.com
# OBJECT_STORE_R2_ACCESS_KEY_ID=
# OBJECT_STORE_R2_SECRET_ACCESS_KEY=
# OBJECT_STORE_R2_REGION=auto
# OBJECT_STORE_R2_SERVICE=s3
# CHECKPOINT_THRESHOLD_IN_MS=10000
# These control the server-side internal telemetry
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Multi-provider object storage with protocol-based routing for zero-downtime migration
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add IAM role-based auth support for object stores (no access keys required).
+7
View File
@@ -349,11 +349,18 @@ const EnvironmentSchema = z
.default(60 * 1000 * 15), // 15 minutes
OBJECT_STORE_BASE_URL: z.string().optional(),
OBJECT_STORE_BUCKET: z.string().optional(),
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
OBJECT_STORE_REGION: z.string().optional(),
OBJECT_STORE_SERVICE: z.string().default("s3"),
// Protocol to use for new uploads (e.g., "s3", "r2"). Data without protocol uses default provider above.
// If specified, you must configure the corresponding provider using OBJECT_STORE_{PROTOCOL}_* env vars.
// Example: OBJECT_STORE_DEFAULT_PROTOCOL=s3 requires OBJECT_STORE_S3_BASE_URL, OBJECT_STORE_S3_ACCESS_KEY_ID, etc.
// Enables zero-downtime migration between providers (old data keeps working, new data uses new provider).
OBJECT_STORE_DEFAULT_PROTOCOL: z.string().regex(/^[a-z0-9]+$/).optional(),
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
@@ -15,7 +15,7 @@ import assertNever from "assert-never";
import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions";
import { $replica, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { generatePresignedUrl } from "~/v3/r2.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
import { tracer } from "~/v3/tracer.server";
import { startSpanWithEnv } from "~/v3/tracing.server";
+3 -2
View File
@@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { generatePresignedUrl } from "~/v3/r2.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
const ParamsSchema = z.object({
"*": z.string(),
@@ -29,7 +29,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"PUT"
"PUT",
{ forceNoPrefix: true }
);
if (!signed.success) {
@@ -1,13 +1,11 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
type CompleteWaitpointTokenResponseBody,
conditionallyExportPacket,
stringifyIO,
} from "@trigger.dev/core/v3";
import { type CompleteWaitpointTokenResponseBody, stringifyIO } from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { verifyHttpCallbackHash } from "~/services/httpCallback.server";
import { logger } from "~/services/logger.server";
import { engine } from "~/v3/runEngine.server";
@@ -41,8 +39,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
},
include: {
environment: {
select: {
apiKey: true,
include: {
project: true,
organization: true,
orgMember: true,
parentEnvironment: {
select: {
apiKey: true,
@@ -77,9 +77,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
const body = await request.json().catch(() => ({}));
const stringifiedData = await stringifyIO(body);
const finalData = await conditionallyExportPacket(
const finalData = await processWaitpointCompletionPacket(
stringifiedData,
`${waitpointId}/waitpoint/http-callback`
waitpoint.environment,
`${WaitpointId.toFriendlyId(waitpointId)}/http-callback`
);
const result = await engine.completeWaitpoint({
@@ -2,7 +2,6 @@ import { json } from "@remix-run/server-runtime";
import {
CompleteWaitpointTokenRequestBody,
type CompleteWaitpointTokenResponseBody,
conditionallyExportPacket,
stringifyIO,
} from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
@@ -10,6 +9,7 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { engine } from "~/v3/runEngine.server";
@@ -52,9 +52,10 @@ const { action, loader } = createActionApiRoute(
}
const stringifiedData = await stringifyIO(body.data);
const finalData = await conditionallyExportPacket(
const finalData = await processWaitpointCompletionPacket(
stringifiedData,
`${waitpointId}/waitpoint/token`
authentication.environment,
`${WaitpointId.toFriendlyId(waitpointId)}/token`
);
const result = await engine.completeWaitpoint({
@@ -0,0 +1,45 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
const ParamsSchema = z.object({
"*": z.string(),
});
/**
* PUT-only presign for packet uploads (SDK offload). Uses OBJECT_STORE_DEFAULT_PROTOCOL for
* unprefixed keys; returns canonical storagePath for IOPacket.data. GET presigns use v1.
*/
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "PUT") {
return { status: 405, body: "Method Not Allowed" };
}
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const parsedParams = ParamsSchema.parse(params);
const filename = parsedParams["*"];
const signed = await generatePresignedUrl(
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"PUT"
);
if (!signed.success) {
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
}
if (signed.storagePath === undefined) {
return json({ error: "Failed to resolve storage path for packet upload" }, { status: 500 });
}
return json({ presignedUrl: signed.url, storagePath: signed.storagePath });
}
@@ -2,13 +2,7 @@ import { env } from "~/env.server";
import { parse } from "@conform-to/zod";
import { Form, useLocation, useNavigation, useSubmit } from "@remix-run/react";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
conditionallyExportPacket,
IOPacket,
stringifyIO,
timeoutError,
WaitpointTokenStatus,
} from "@trigger.dev/core/v3";
import { stringifyIO, timeoutError, WaitpointTokenStatus } from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import type { Waitpoint } from "@trigger.dev/database";
import { useCallback, useRef } from "react";
@@ -24,6 +18,8 @@ import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, ProjectParamSchema, v3RunsPath } from "~/utils/pathBuilder";
@@ -86,6 +82,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const waitpoint = await $replica.waitpoint.findFirst({
select: {
projectId: true,
environmentId: true,
},
where: {
id: waitpointId,
@@ -150,11 +147,29 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
);
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return redirectWithErrorMessage(
submission.value.failureRedirect,
request,
"Environment not found"
);
}
if (environment.id !== waitpoint.environmentId) {
return redirectWithErrorMessage(
submission.value.failureRedirect,
request,
"No waitpoint found"
);
}
const data = submission.value.payload ? JSON.parse(submission.value.payload) : {};
const stringifiedData = await stringifyIO(data);
const finalData = await conditionallyExportPacket(
const finalData = await processWaitpointCompletionPacket(
stringifiedData,
`${waitpointId}/waitpoint/token`
environment,
`${WaitpointId.toFriendlyId(waitpointId)}/token`
);
const result = await engine.completeWaitpoint({
@@ -3,7 +3,7 @@ import { basename } from "node:path";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { generatePresignedRequest } from "~/v3/r2.server";
import { generatePresignedRequest } from "~/v3/objectStore.server";
const ParamSchema = z.object({
environmentId: z.string(),
@@ -1,9 +1,9 @@
import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
import { type IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { uploadPacketToObjectStore, r2 } from "~/v3/r2.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { hasObjectStoreClient, uploadPacketToObjectStore } from "~/v3/objectStore.server";
import { startActiveSpan } from "~/v3/tracer.server";
export type BatchPayloadProcessResult = {
/** The processed payload - either the original or an R2 path */
@@ -31,7 +31,7 @@ export class BatchPayloadProcessor {
* If not available, large payloads will be stored inline (which may fail for very large payloads).
*/
isObjectStoreAvailable(): boolean {
return r2 !== undefined && env.OBJECT_STORE_BASE_URL !== undefined;
return hasObjectStoreClient();
}
/**
@@ -103,11 +103,17 @@ export class BatchPayloadProcessor {
};
}
// Upload to R2
// Upload to object store
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
const [uploadError] = await tryCatch(
uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment)
const [uploadError, uploadedFilename] = await tryCatch(
uploadPacketToObjectStore(
filename,
packet.data,
packet.dataType,
environment,
env.OBJECT_STORE_DEFAULT_PROTOCOL
)
);
if (uploadError) {
@@ -125,18 +131,18 @@ export class BatchPayloadProcessor {
);
}
logger.debug("Batch item payload offloaded to R2", {
logger.debug("Batch item payload offloaded to object store", {
batchId,
itemIndex,
filename,
filename: uploadedFilename,
size,
});
span.setAttribute("wasOffloaded", true);
span.setAttribute("offloadPath", filename);
span.setAttribute("offloadPath", uploadedFilename);
return {
payload: filename,
payload: uploadedFilename!,
payloadType: "application/store",
wasOffloaded: true,
size,
@@ -2,7 +2,7 @@ import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/
import { PayloadProcessor, TriggerTaskRequest } from "../types";
import { env } from "~/env.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { uploadPacketToObjectStore } from "~/v3/r2.server";
import { uploadPacketToObjectStore } from "~/v3/objectStore.server";
import { ServiceValidationError } from "~/v3/services/common.server";
export class DefaultPayloadProcessor implements PayloadProcessor {
@@ -31,8 +31,8 @@ export class DefaultPayloadProcessor implements PayloadProcessor {
const filename = `${request.friendlyId}/payload.json`;
const [uploadError] = await tryCatch(
uploadPacketToObjectStore(filename, packet.data, packet.dataType, request.environment)
const [uploadError, uploadedFilename] = await tryCatch(
uploadPacketToObjectStore(filename, packet.data, packet.dataType, request.environment, env.OBJECT_STORE_DEFAULT_PROTOCOL)
);
if (uploadError) {
@@ -40,7 +40,7 @@ export class DefaultPayloadProcessor implements PayloadProcessor {
}
return {
data: filename,
data: uploadedFilename!,
dataType: "application/store",
};
});
@@ -0,0 +1,63 @@
import { type IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { env } from "~/env.server";
import { uploadPacketToObjectStore } from "~/v3/objectStore.server";
import { ServiceValidationError } from "~/v3/services/common.server";
function packetExtensionForDataType(dataType: string): string {
switch (dataType) {
case "application/json":
case "application/super+json":
return "json";
case "text/plain":
return "txt";
default:
return "txt";
}
}
/**
* Offloads large waitpoint completion payloads to object store (same threshold and
* upload path pattern as DefaultPayloadProcessor). Object key prefix should use the
* waitpoint friendly id folder, e.g. `${WaitpointId.toFriendlyId(internalId)}/token`.
* Replaces no-op conditionallyExportPacket usage in webapp routes where apiClientManager is unset.
*/
export async function processWaitpointCompletionPacket(
packet: IOPacket,
environment: AuthenticatedEnvironment,
pathPrefix: string
): Promise<IOPacket> {
if (!packet.data) {
return packet;
}
const { needsOffloading, size } = packetRequiresOffloading(
packet,
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
);
if (!needsOffloading) {
return packet;
}
const filename = `${pathPrefix}.${packetExtensionForDataType(packet.dataType)}`;
const [uploadError, uploadedFilename] = await tryCatch(
uploadPacketToObjectStore(
filename,
packet.data,
packet.dataType,
environment,
env.OBJECT_STORE_DEFAULT_PROTOCOL
)
);
if (uploadError) {
throw new ServiceValidationError("Failed to upload large waitpoint to object store", 500);
}
return {
data: uploadedFilename!,
dataType: "application/store",
};
}
@@ -16,7 +16,7 @@ import { env } from "~/env.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { batchTriggerWorker } from "~/v3/batchTriggerWorker.server";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../../v3/r2.server";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../../v3/objectStore.server";
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
import { TriggerTaskService } from "../../v3/services/triggerTask.server";
import { startActiveSpan } from "../../v3/tracer.server";
@@ -716,10 +716,10 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
const filename = `${pathPrefix}/payload.json`;
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
const uploadedFilename = await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
return {
data: filename,
data: uploadedFilename,
dataType: "application/store",
};
});
+317
View File
@@ -0,0 +1,317 @@
import { type IOPacket } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { ObjectStoreClient, type ObjectStoreClientConfig } from "./objectStoreClient.server";
/**
* Parsed storage URI with optional protocol prefix
* @example { protocol: "s3", path: "run_abc/payload.json" }
* @example { protocol: undefined, path: "batch_123/item_0/payload.json" } // legacy, uses default
*/
export type ParsedStorageUri = {
protocol?: string;
path: string;
};
/**
* Parse a storage URI into protocol and path components
* @param uri Storage URI, optionally prefixed with protocol (e.g., "s3://path" or "path")
* @returns Parsed components { protocol?, path }
*/
export function parseStorageUri(uri: string): ParsedStorageUri {
const match = uri.match(/^([a-z0-9]+):\/\/(.+)$/);
if (match) {
return {
protocol: match[1],
path: match[2],
};
}
return {
protocol: undefined,
path: uri,
};
}
/**
* Format a storage URI with optional protocol prefix
* @param path Storage path
* @param protocol Optional protocol to prefix (e.g., "s3", "r2")
* @returns Formatted URI (e.g., "s3://path" or "path")
*/
export function formatStorageUri(path: string, protocol?: string): string {
if (protocol) {
return `${protocol}://${path}`;
}
return path;
}
/**
* Get object storage configuration for a given protocol.
* Returns a config if baseUrl is set, even without explicit credentials —
* in that case the AWS credential chain (ECS task role, EC2 IMDS, etc.) is used,
* and OBJECT_STORE_BUCKET must also be set.
*/
function getObjectStoreConfig(protocol?: string): ObjectStoreClientConfig | undefined {
if (protocol) {
// Named provider (e.g., OBJECT_STORE_S3_*)
const prefix = `OBJECT_STORE_${protocol.toUpperCase()}_`;
const baseUrl = process.env[`${prefix}BASE_URL`];
if (!baseUrl) return undefined;
return {
baseUrl,
bucket: process.env[`${prefix}BUCKET`] || undefined,
accessKeyId: process.env[`${prefix}ACCESS_KEY_ID`] || undefined,
secretAccessKey: process.env[`${prefix}SECRET_ACCESS_KEY`] || undefined,
region: process.env[`${prefix}REGION`] || undefined,
service: process.env[`${prefix}SERVICE`] || undefined,
};
}
// Default provider (backward compatible)
if (!env.OBJECT_STORE_BASE_URL) {
return undefined;
}
return {
baseUrl: env.OBJECT_STORE_BASE_URL,
bucket: env.OBJECT_STORE_BUCKET || undefined,
accessKeyId: env.OBJECT_STORE_ACCESS_KEY_ID || undefined,
secretAccessKey: env.OBJECT_STORE_SECRET_ACCESS_KEY || undefined,
region: env.OBJECT_STORE_REGION || undefined,
service: env.OBJECT_STORE_SERVICE || undefined,
};
}
/**
* Object storage client registry. Maps protocol name to ObjectStoreClient singleton.
* ObjectStoreClient internally uses either aws4fetch (static credentials) or the
* AWS SDK S3Client (IAM credential chain), selected at creation time.
*/
const objectStoreClients = singleton(
"objectStoreClients",
() => new Map<string, ObjectStoreClient>()
);
function getObjectStoreClient(protocol?: string): ObjectStoreClient | undefined {
const config = getObjectStoreConfig(protocol);
if (!config) return undefined;
// Key includes baseUrl so that config changes (e.g. different containers in tests)
// always produce a fresh client while production usage (stable env) is effectively
// a per-protocol singleton.
const cacheKey = `${protocol ?? "default"}:${config.baseUrl}`;
if (objectStoreClients.has(cacheKey)) {
return objectStoreClients.get(cacheKey);
}
const client = ObjectStoreClient.create(config);
objectStoreClients.set(cacheKey, client);
return client;
}
export function hasObjectStoreClient(): boolean {
const defaultConfig = getObjectStoreConfig();
const protocolConfig = env.OBJECT_STORE_DEFAULT_PROTOCOL
? getObjectStoreConfig(env.OBJECT_STORE_DEFAULT_PROTOCOL)
: undefined;
return !!(defaultConfig || protocolConfig);
}
export async function uploadPacketToObjectStore(
filename: string,
data: ReadableStream | string,
contentType: string,
environment: AuthenticatedEnvironment,
storageProtocol?: string
): Promise<string> {
const protocol = storageProtocol || env.OBJECT_STORE_DEFAULT_PROTOCOL;
const client = getObjectStoreClient(protocol);
if (!client) {
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
}
const key = `packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
logger.debug("Uploading to object store", { key, protocol: protocol || "default" });
await client.putObject(key, data, contentType);
// Return filename with protocol prefix if specified
return formatStorageUri(filename, protocol);
}
export async function downloadPacketFromObjectStore(
packet: IOPacket,
environment: AuthenticatedEnvironment
): Promise<IOPacket> {
if (packet.dataType !== "application/store") {
return packet;
}
// There shouldn't be an offloaded packet with undefined data…
if (!packet.data) {
logger.error("Object store packet has undefined data", { packet, environment });
return {
dataType: "application/json",
data: undefined,
};
}
const { protocol, path } = parseStorageUri(packet.data);
const client = getObjectStoreClient(protocol);
if (!client) {
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
}
const key = `packets/${environment.project.externalRef}/${environment.slug}/${path}`;
logger.debug("Downloading from object store", { key, protocol: protocol || "default" });
const data = await client.getObject(key);
return { data, dataType: "application/json" };
}
export type GeneratePacketPresignOptions = {
/**
* When true (v1 packet PUT only), unprefixed keys use the legacy default object store only.
* When false/omitted (v2 packet PUT), unprefixed keys also use OBJECT_STORE_DEFAULT_PROTOCOL.
* Ignored for GET — reads never infer protocol from env for unprefixed keys.
*/
forceNoPrefix?: boolean;
};
/**
* Resolve object-store protocol for packet presigns.
* GET: never apply OBJECT_STORE_DEFAULT_PROTOCOL to unprefixed keys.
* PUT: optional forceNoPrefix for v1 legacy upload behavior.
*/
export function resolveStoreProtocolForPacketPresign(
filename: string,
method: "PUT" | "GET",
forceNoPrefix?: boolean
): { path: string; storeProtocol: string | undefined } {
const { protocol: explicitProtocol, path } = parseStorageUri(filename);
if (method === "GET") {
return { path, storeProtocol: explicitProtocol };
}
if (explicitProtocol !== undefined) {
return { path, storeProtocol: explicitProtocol };
}
if (forceNoPrefix) {
return { path, storeProtocol: undefined };
}
return { path, storeProtocol: env.OBJECT_STORE_DEFAULT_PROTOCOL };
}
export async function generatePresignedRequest(
projectRef: string,
envSlug: string,
filename: string,
method: "PUT" | "GET" = "PUT",
options?: GeneratePacketPresignOptions
): Promise<
| {
success: false;
error: string;
}
| {
success: true;
request: Request;
/** Canonical pointer for IOPacket.data (PUT only). */
storagePath?: string;
}
> {
const { path, storeProtocol } = resolveStoreProtocolForPacketPresign(
filename,
method,
options?.forceNoPrefix
);
const config = getObjectStoreConfig(storeProtocol);
if (!config?.baseUrl) {
return {
success: false,
error: `Object store is not configured for protocol: ${storeProtocol || "default"}`,
};
}
const client = getObjectStoreClient(storeProtocol);
if (!client) {
return {
success: false,
error: `Object store is not configured for protocol: ${storeProtocol || "default"}`,
};
}
const key = `packets/${projectRef}/${envSlug}/${path}`;
try {
const url = await client.presign(key, method, 300); // 5 minutes
logger.debug("Generated presigned URL", {
url,
projectRef,
envSlug,
filename,
protocol: storeProtocol || "default",
});
const storagePath = method === "PUT" ? formatStorageUri(path, storeProtocol) : undefined;
return {
success: true,
request: new Request(url, { method }),
storagePath,
};
} catch (error) {
return {
success: false,
error: `Failed to generate presigned URL: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
}
export async function generatePresignedUrl(
projectRef: string,
envSlug: string,
filename: string,
method: "PUT" | "GET" = "PUT",
options?: GeneratePacketPresignOptions
): Promise<
| {
success: false;
error: string;
}
| {
success: true;
url: string;
storagePath?: string;
}
> {
const signed = await generatePresignedRequest(projectRef, envSlug, filename, method, options);
if (!signed.success) {
return {
success: false,
error: signed.error,
};
}
return {
success: true,
url: signed.request.url,
storagePath: signed.storagePath,
};
}
@@ -0,0 +1,200 @@
import { AwsClient } from "aws4fetch";
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
interface IObjectStoreClient {
putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string>;
getObject(key: string): Promise<string>;
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string>;
}
type Aws4FetchConfig = {
baseUrl: string;
accessKeyId: string;
secretAccessKey: string;
region?: string;
service?: string;
};
class Aws4FetchClient implements IObjectStoreClient {
private readonly awsClient: AwsClient;
constructor(private readonly config: Aws4FetchConfig) {
this.awsClient = new AwsClient({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
region: config.region,
// We set the default value to "s3" in the schema to enhance interoperability with various
// S3-compatible services. Setting this env var to an empty string restores the old behaviour.
service: config.service || undefined,
});
}
private buildUrl(key: string): string {
const url = new URL(this.config.baseUrl);
url.pathname = `/${key}`;
return url.toString();
}
async putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string> {
const objectUrl = this.buildUrl(key);
const response = await this.awsClient.fetch(objectUrl, {
method: "PUT",
headers: { "Content-Type": contentType },
body,
});
if (!response.ok) {
throw new Error(`Failed to upload to object store: ${response.statusText}`);
}
return objectUrl;
}
async getObject(key: string): Promise<string> {
const response = await this.awsClient.fetch(this.buildUrl(key));
if (!response.ok) {
throw new Error(`Failed to download from object store: ${response.statusText}`);
}
return response.text();
}
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
const url = new URL(this.config.baseUrl);
url.pathname = `/${key}`;
url.searchParams.set("X-Amz-Expires", String(expiresIn));
const signed = await this.awsClient.sign(new Request(url, { method }), {
aws: { signQuery: true },
});
return signed.url;
}
}
type AwsSdkConfig = {
bucket: string;
baseUrl: string;
region?: string;
};
class AwsSdkClient implements IObjectStoreClient {
private readonly s3Client: S3Client;
constructor(private readonly config: AwsSdkConfig) {
this.s3Client = new S3Client({
endpoint: config.baseUrl,
forcePathStyle: true,
...(config.region ? { region: config.region } : {}),
});
}
/**
* Callers use a single logical key (same as aws4fetch path: `bucket/object/...`).
* S3 APIs take Bucket + Key where Key must not repeat the bucket name.
*/
private toS3ObjectKey(logicalKey: string): string {
const prefix = `${this.config.bucket}/`;
if (logicalKey.startsWith(prefix)) {
return logicalKey.slice(prefix.length);
}
return logicalKey;
}
private logicalObjectUrl(logicalKey: string): string {
const url = new URL(this.config.baseUrl);
url.pathname = `/${logicalKey}`;
return url.href;
}
async putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string> {
const s3Key = this.toS3ObjectKey(key);
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.config.bucket,
Key: s3Key,
Body: body,
ContentType: contentType,
})
);
return this.logicalObjectUrl(key);
}
async getObject(key: string): Promise<string> {
const s3Key = this.toS3ObjectKey(key);
const response = await this.s3Client.send(
new GetObjectCommand({ Bucket: this.config.bucket, Key: s3Key })
);
if (!response.Body) {
throw new Error(`Empty response body from object store for key: ${key}`);
}
return response.Body.transformToString();
}
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
const s3Key = this.toS3ObjectKey(key);
const command =
method === "PUT"
? new PutObjectCommand({ Bucket: this.config.bucket, Key: s3Key })
: new GetObjectCommand({ Bucket: this.config.bucket, Key: s3Key });
return getSignedUrl(this.s3Client, command, { expiresIn });
}
}
export type ObjectStoreClientConfig = {
baseUrl: string;
bucket?: string;
accessKeyId?: string;
secretAccessKey?: string;
region?: string;
service?: string;
};
export class ObjectStoreClient implements IObjectStoreClient {
private constructor(
private readonly impl: IObjectStoreClient,
/** When set, logical keys may start with `${bucket}/…`; AwsSdkClient strips that prefix for S3 APIs. */
readonly bucket: string | undefined
) {}
static create(config: ObjectStoreClientConfig): ObjectStoreClient {
if (config.accessKeyId && config.secretAccessKey) {
return new ObjectStoreClient(
new Aws4FetchClient({
baseUrl: config.baseUrl,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
region: config.region,
service: config.service,
}),
config.bucket
);
}
// IAM credential chain — AWS SDK S3Client handles credential refresh automatically
if (!config.bucket) {
throw new Error(
"OBJECT_STORE_BUCKET is required when not using access key credentials (IAM mode)"
);
}
return new ObjectStoreClient(
new AwsSdkClient({
bucket: config.bucket,
baseUrl: config.baseUrl,
region: config.region,
}),
config.bucket
);
}
putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string> {
return this.impl.putObject(key, body, contentType);
}
getObject(key: string): Promise<string> {
return this.impl.getObject(key);
}
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
return this.impl.presign(key, method, expiresIn);
}
}
-240
View File
@@ -1,240 +0,0 @@
import { AwsClient } from "aws4fetch";
import { env } from "~/env.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { startActiveSpan } from "./tracer.server";
import { IOPacket } from "@trigger.dev/core/v3";
export const r2 = singleton("r2", initializeR2);
function initializeR2() {
if (!env.OBJECT_STORE_ACCESS_KEY_ID || !env.OBJECT_STORE_SECRET_ACCESS_KEY) {
return;
}
return new AwsClient({
accessKeyId: env.OBJECT_STORE_ACCESS_KEY_ID,
secretAccessKey: env.OBJECT_STORE_SECRET_ACCESS_KEY,
region: env.OBJECT_STORE_REGION,
// We now set the default value to "s3" in the schema to enhance interoperability with various S3-compatible services.
// Setting this env var to an empty string will restore the previous behavior of not setting a service.
service: env.OBJECT_STORE_SERVICE ? env.OBJECT_STORE_SERVICE : undefined,
});
}
export async function uploadPacketToObjectStore(
filename: string,
data: ReadableStream | string,
contentType: string,
environment: AuthenticatedEnvironment
): Promise<string> {
return await startActiveSpan("uploadPacketToObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
if (!env.OBJECT_STORE_BASE_URL) {
throw new Error("Object store base URL is not set");
}
span.setAttributes({
projectRef: environment.project.externalRef,
environmentSlug: environment.slug,
filename: filename,
});
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
logger.debug("Uploading to object store", { url: url.href });
const response = await r2.fetch(url.toString(), {
method: "PUT",
headers: {
"Content-Type": contentType,
},
body: data,
});
if (!response.ok) {
throw new Error(`Failed to upload output to ${url}: ${response.statusText}`);
}
return url.href;
});
}
export async function downloadPacketFromObjectStore(
packet: IOPacket,
environment: AuthenticatedEnvironment
): Promise<IOPacket> {
if (packet.dataType !== "application/store") {
return packet;
}
return await startActiveSpan("downloadPacketFromObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
if (!env.OBJECT_STORE_BASE_URL) {
throw new Error("Object store base URL is not set");
}
span.setAttributes({
projectRef: environment.project.externalRef,
environmentSlug: environment.slug,
filename: packet.data,
});
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${packet.data}`;
logger.debug("Downloading from object store", { url: url.href });
const response = await r2.fetch(url.toString());
if (!response.ok) {
throw new Error(`Failed to download input from ${url}: ${response.statusText}`);
}
const data = await response.text();
const rawPacket = {
data,
dataType: "application/json",
};
return rawPacket;
});
}
export async function uploadDataToObjectStore(
filename: string,
data: string,
contentType: string,
prefix?: string
): Promise<string> {
return await startActiveSpan("uploadDataToObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
if (!env.OBJECT_STORE_BASE_URL) {
throw new Error("Object store base URL is not set");
}
span.setAttributes({
prefix,
filename,
});
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `${prefix}/${filename}`;
logger.debug("Uploading to object store", { url: url.href });
const response = await r2.fetch(url.toString(), {
method: "PUT",
headers: {
"Content-Type": contentType,
},
body: data,
});
if (!response.ok) {
throw new Error(`Failed to upload data to ${url}: ${response.statusText}`);
}
return url.href;
});
}
export async function generatePresignedRequest(
projectRef: string,
envSlug: string,
filename: string,
method: "PUT" | "GET" = "PUT"
): Promise<
| {
success: false;
error: string;
}
| {
success: true;
request: Request;
}
> {
if (!env.OBJECT_STORE_BASE_URL) {
return {
success: false,
error: "Object store base URL is not set",
};
}
if (!r2) {
return {
success: false,
error: "Object store client is not initialized",
};
}
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${projectRef}/${envSlug}/${filename}`;
url.searchParams.set("X-Amz-Expires", "300"); // 5 minutes
const signed = await r2.sign(
new Request(url, {
method,
}),
{
aws: { signQuery: true },
}
);
logger.debug("Generated presigned URL", {
url: signed.url,
headers: Object.fromEntries(signed.headers),
projectRef,
envSlug,
filename,
});
return {
success: true,
request: signed,
};
}
export async function generatePresignedUrl(
projectRef: string,
envSlug: string,
filename: string,
method: "PUT" | "GET" = "PUT"
): Promise<
| {
success: false;
error: string;
}
| {
success: true;
url: string;
}
> {
const signed = await generatePresignedRequest(projectRef, envSlug, filename, method);
if (!signed.success) {
return {
success: false,
error: signed.error,
};
}
signed;
return {
success: true,
url: signed.request.url,
};
}
@@ -25,7 +25,7 @@ import { generateFriendlyId } from "../friendlyIdentifiers";
import { legacyRunEngineWorker } from "../legacyRunEngineWorker.server";
import { marqs } from "../marqs/index.server";
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../r2.server";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../objectStore.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { startActiveSpan } from "../tracer.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
@@ -930,10 +930,10 @@ export class BatchTriggerV3Service extends BaseService {
const filename = `${pathPrefix}/payload.json`;
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
const uploadedFilename = await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
return {
data: filename,
data: uploadedFilename,
dataType: "application/store",
};
});
@@ -26,7 +26,7 @@ import { getV3EventRepository } from "../eventRepository/index.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { findCurrentWorkerFromEnvironment } from "../models/workerDeployment.server";
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
import { uploadPacketToObjectStore } from "../r2.server";
import { uploadPacketToObjectStore } from "../objectStore.server";
import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { startActiveSpan } from "../tracer.server";
@@ -756,10 +756,10 @@ export class TriggerTaskServiceV1 extends BaseService {
const filename = `${pathPrefix}/payload.json`;
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
const uploadedFilename = await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
return {
data: filename,
data: uploadedFilename,
dataType: "application/store",
};
});
+649
View File
@@ -0,0 +1,649 @@
import { postgresAndMinioTest } from "@internal/testcontainers";
import { type IOPacket } from "@trigger.dev/core/v3";
import { type PrismaClient } from "@trigger.dev/database";
import { afterAll, describe, expect, it, vi } from "vitest";
import { env } from "~/env.server";
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
import {
downloadPacketFromObjectStore,
formatStorageUri,
generatePresignedRequest,
generatePresignedUrl,
hasObjectStoreClient,
parseStorageUri,
resolveStoreProtocolForPacketPresign,
uploadPacketToObjectStore,
} from "~/v3/objectStore.server";
// Extend the timeout for container tests
vi.setConfig({ testTimeout: 60_000 });
// Helper to create a test environment
async function createTestEnvironment(prisma: PrismaClient) {
const suffix = Date.now().toString(36);
const org = await prisma.organization.create({
data: {
title: `Test Org ${suffix}`,
slug: `test-org-${suffix}`,
},
});
const project = await prisma.project.create({
data: {
name: `Test Project ${suffix}`,
slug: `test-project-${suffix}`,
externalRef: `proj_test${suffix}`,
organizationId: org.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "dev",
type: "DEVELOPMENT",
organizationId: org.id,
projectId: project.id,
apiKey: `test_key_${suffix}`,
pkApiKey: `test_pk_key_${suffix}`,
shortcode: suffix.slice(0, 4),
},
include: {
project: true,
organization: true,
},
});
return environment;
}
// Save original env values for restoration in afterAll
const originalEnv = process.env;
const originalEnvObj = {
OBJECT_STORE_BASE_URL: env.OBJECT_STORE_BASE_URL,
OBJECT_STORE_BUCKET: env.OBJECT_STORE_BUCKET,
OBJECT_STORE_ACCESS_KEY_ID: env.OBJECT_STORE_ACCESS_KEY_ID,
OBJECT_STORE_SECRET_ACCESS_KEY: env.OBJECT_STORE_SECRET_ACCESS_KEY,
OBJECT_STORE_REGION: env.OBJECT_STORE_REGION,
OBJECT_STORE_DEFAULT_PROTOCOL: env.OBJECT_STORE_DEFAULT_PROTOCOL,
TASK_PAYLOAD_OFFLOAD_THRESHOLD: env.TASK_PAYLOAD_OFFLOAD_THRESHOLD,
};
describe("Object Storage", () => {
describe("URI parsing functions", () => {
it("should parse URI with protocol", () => {
const result = parseStorageUri("s3://run_abc123/payload.json");
expect(result).toEqual({
protocol: "s3",
path: "run_abc123/payload.json",
});
});
it("should parse URI with R2 protocol", () => {
const result = parseStorageUri("r2://batch_xyz/item_0/payload.json");
expect(result).toEqual({
protocol: "r2",
path: "batch_xyz/item_0/payload.json",
});
});
it("should parse legacy URI without protocol", () => {
const result = parseStorageUri("run_abc123/payload.json");
expect(result).toEqual({
protocol: undefined,
path: "run_abc123/payload.json",
});
});
it("should format URI with protocol", () => {
const result = formatStorageUri("run_abc123/payload.json", "s3");
expect(result).toBe("s3://run_abc123/payload.json");
});
it("should format URI without protocol", () => {
const result = formatStorageUri("run_abc123/payload.json");
expect(result).toBe("run_abc123/payload.json");
});
});
describe("resolveStoreProtocolForPacketPresign", () => {
afterEach(() => {
env.OBJECT_STORE_DEFAULT_PROTOCOL = originalEnvObj.OBJECT_STORE_DEFAULT_PROTOCOL;
});
it("GET uses legacy default for unprefixed keys even when OBJECT_STORE_DEFAULT_PROTOCOL is set", () => {
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
expect(resolveStoreProtocolForPacketPresign("a/b.json", "GET").storeProtocol).toBeUndefined();
});
it("PUT without forceNoPrefix uses OBJECT_STORE_DEFAULT_PROTOCOL for unprefixed keys", () => {
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
expect(resolveStoreProtocolForPacketPresign("a/b.json", "PUT", false).storeProtocol).toBe(
"s3"
);
});
it("PUT with forceNoPrefix skips OBJECT_STORE_DEFAULT_PROTOCOL for unprefixed keys", () => {
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
expect(resolveStoreProtocolForPacketPresign("a/b.json", "PUT", true).storeProtocol).toBeUndefined();
});
it("explicit protocol in key wins for PUT with forceNoPrefix", () => {
env.OBJECT_STORE_DEFAULT_PROTOCOL = "r2";
expect(resolveStoreProtocolForPacketPresign("s3://x/y.json", "PUT", true).storeProtocol).toBe(
"s3"
);
});
});
postgresAndMinioTest(
"should upload and download data without protocol (legacy)",
async ({ minioConfig, prisma }) => {
// Override env directly for the default provider
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
const environment = await createTestEnvironment(prisma);
const testData = JSON.stringify({ test: "data", value: 123 });
const filename = "test_run/payload.json";
// Upload
const uploadedFilename = await uploadPacketToObjectStore(
filename,
testData,
"application/json",
environment as any
);
// Should return filename without protocol (legacy)
expect(uploadedFilename).toBe(filename);
// Download
const packet: IOPacket = {
data: uploadedFilename,
dataType: "application/store",
};
const downloadedPacket = await downloadPacketFromObjectStore(packet, environment as any);
expect(downloadedPacket.dataType).toBe("application/json");
expect(downloadedPacket.data).toBe(testData);
// Cleanup
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
await prisma.project.delete({ where: { id: environment.projectId } });
await prisma.organization.delete({ where: { id: environment.organizationId } });
}
);
postgresAndMinioTest(
"should upload and download data with protocol prefix",
async ({ minioConfig, prisma }) => {
// Named provider — controlled via process.env (read dynamically)
process.env.OBJECT_STORE_S3_BASE_URL = minioConfig.baseUrl;
process.env.OBJECT_STORE_S3_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.OBJECT_STORE_S3_REGION = minioConfig.region;
process.env.OBJECT_STORE_S3_SERVICE = "s3";
const environment = await createTestEnvironment(prisma);
const testData = JSON.stringify({ test: "protocol-data", value: 456 });
const filename = "test_run2/payload.json";
// Upload with explicit protocol — bypasses env.OBJECT_STORE_DEFAULT_PROTOCOL
const uploadedFilename = await uploadPacketToObjectStore(
filename,
testData,
"application/json",
environment as any,
"s3"
);
// Should return filename with s3:// protocol
expect(uploadedFilename).toBe("s3://test_run2/payload.json");
// Download
const packet: IOPacket = {
data: uploadedFilename,
dataType: "application/store",
};
const downloadedPacket = await downloadPacketFromObjectStore(packet, environment as any);
expect(downloadedPacket.dataType).toBe("application/json");
expect(downloadedPacket.data).toBe(testData);
// Cleanup
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
await prisma.project.delete({ where: { id: environment.projectId } });
await prisma.organization.delete({ where: { id: environment.organizationId } });
}
);
postgresAndMinioTest(
"should support migration from default provider to named provider",
async ({ minioConfig, prisma }) => {
const environment = await createTestEnvironment(prisma);
// Step 1: Upload old data without protocol (using default provider)
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
const oldData = JSON.stringify({ legacy: true });
const oldFilename = "old_run/payload.json";
const uploadedOldFilename = await uploadPacketToObjectStore(
oldFilename,
oldData,
"application/json",
environment as any
);
expect(uploadedOldFilename).toBe(oldFilename); // No protocol
// Step 2: Configure new provider (S3) and set default protocol
process.env.OBJECT_STORE_S3_BASE_URL = minioConfig.baseUrl; // Same MinIO for testing
process.env.OBJECT_STORE_S3_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.OBJECT_STORE_S3_REGION = minioConfig.region;
process.env.OBJECT_STORE_S3_SERVICE = "s3";
// Step 3: Upload new data with explicit protocol
const newData = JSON.stringify({ new: true });
const newFilename = "new_run/payload.json";
const uploadedNewFilename = await uploadPacketToObjectStore(
newFilename,
newData,
"application/json",
environment as any,
"s3"
);
expect(uploadedNewFilename).toBe("s3://new_run/payload.json"); // Has protocol
// Step 4: Verify both can be downloaded
// Old data (no protocol, uses default provider)
const oldPacket: IOPacket = {
data: uploadedOldFilename,
dataType: "application/store",
};
const downloadedOld = await downloadPacketFromObjectStore(oldPacket, environment as any);
expect(downloadedOld.data).toBe(oldData);
// New data (with protocol, uses named provider)
const newPacket: IOPacket = {
data: uploadedNewFilename,
dataType: "application/store",
};
const downloadedNew = await downloadPacketFromObjectStore(newPacket, environment as any);
expect(downloadedNew.data).toBe(newData);
// Cleanup
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
await prisma.project.delete({ where: { id: environment.projectId } });
await prisma.organization.delete({ where: { id: environment.organizationId } });
}
);
postgresAndMinioTest(
"should upload and download using IAM credential chain (AWS SDK path)",
async ({ minioConfig, prisma }) => {
// IAM mode: override env with bucket but no access keys.
// We put the credentials in AWS_* env vars so the S3Client credential
// chain picks them up (same as it would from an ECS task role in production).
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_BUCKET = "packets";
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_ACCESS_KEY_ID = undefined;
env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
process.env.AWS_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.AWS_REGION = minioConfig.region;
const environment = await createTestEnvironment(prisma);
const testData = JSON.stringify({ iam: true, value: 789 });
const filename = "iam_test_run/payload.json";
const uploadedFilename = await uploadPacketToObjectStore(
filename,
testData,
"application/json",
environment as any
);
expect(uploadedFilename).toBe(filename);
const packet: IOPacket = {
data: uploadedFilename,
dataType: "application/store",
};
const downloadedPacket = await downloadPacketFromObjectStore(packet, environment as any);
expect(downloadedPacket.dataType).toBe("application/json");
expect(downloadedPacket.data).toBe(testData);
// Cleanup
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_REGION;
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
await prisma.project.delete({ where: { id: environment.projectId } });
await prisma.organization.delete({ where: { id: environment.organizationId } });
}
);
postgresAndMinioTest(
"processWaitpointCompletionPacket offloads above threshold and round-trips download",
async ({ minioConfig, prisma }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
const savedThreshold = env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD = 256;
const environment = await createTestEnvironment(prisma);
const pathPrefix = `waitpoint_completiontest/token`;
try {
const smallPacket: IOPacket = {
data: JSON.stringify({ ok: true }),
dataType: "application/json",
};
const smallResult = await processWaitpointCompletionPacket(
smallPacket,
environment as any,
pathPrefix
);
expect(smallResult).toEqual(smallPacket);
const largeBody = "x".repeat(400);
const largePacket: IOPacket = {
data: largeBody,
dataType: "text/plain",
};
const largeResult = await processWaitpointCompletionPacket(
largePacket,
environment as any,
pathPrefix
);
expect(largeResult.dataType).toBe("application/store");
expect(largeResult.data).toBe(`${pathPrefix}.txt`);
const downloadedPacket = await downloadPacketFromObjectStore(
{
data: largeResult.data,
dataType: "application/store",
},
environment as any
);
expect(downloadedPacket.data).toBe(largeBody);
} finally {
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD = savedThreshold;
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
await prisma.project.delete({ where: { id: environment.projectId } });
await prisma.organization.delete({ where: { id: environment.organizationId } });
}
}
);
describe("hasObjectStoreClient", () => {
it("returns false when no store is configured", () => {
env.OBJECT_STORE_BASE_URL = undefined;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
delete process.env.OBJECT_STORE_NOTCONFIGURED_BASE_URL;
expect(hasObjectStoreClient()).toBe(false);
});
it("returns true when default provider base URL is set", () => {
env.OBJECT_STORE_BASE_URL = "http://localhost:9000";
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
expect(hasObjectStoreClient()).toBe(true);
env.OBJECT_STORE_BASE_URL = undefined;
});
it("returns true when named protocol base URL is set", () => {
env.OBJECT_STORE_BASE_URL = undefined;
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
process.env.OBJECT_STORE_S3_BASE_URL = "http://localhost:9000";
expect(hasObjectStoreClient()).toBe(true);
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
delete process.env.OBJECT_STORE_S3_BASE_URL;
});
});
postgresAndMinioTest(
"generatePresignedUrl - PUT then GET round-trip (static credentials / aws4fetch path)",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
const projectRef = "proj_presign_test";
const envSlug = "dev";
const filename = "presigned-static/payload.json";
const data = JSON.stringify({ presigned: "static" });
// Upload via presigned PUT
const putResult = await generatePresignedUrl(projectRef, envSlug, filename, "PUT");
expect(putResult.success).toBe(true);
if (!putResult.success) throw new Error(putResult.error);
expect(putResult.storagePath).toBe(filename);
const putResponse = await fetch(putResult.url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: data,
});
expect(putResponse.ok).toBe(true);
// Download via presigned GET
const getResult = await generatePresignedUrl(projectRef, envSlug, filename, "GET");
expect(getResult.success).toBe(true);
if (!getResult.success) throw new Error(getResult.error);
expect(getResult.storagePath).toBeUndefined();
const getResponse = await fetch(getResult.url);
expect(getResponse.ok).toBe(true);
expect(await getResponse.text()).toBe(data);
}
);
postgresAndMinioTest(
"generatePresignedUrl - PUT unprefixed with OBJECT_STORE_DEFAULT_PROTOCOL returns s3 storagePath",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = undefined;
env.OBJECT_STORE_ACCESS_KEY_ID = undefined;
env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined;
env.OBJECT_STORE_REGION = undefined;
process.env.OBJECT_STORE_S3_BASE_URL = minioConfig.baseUrl;
process.env.OBJECT_STORE_S3_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.OBJECT_STORE_S3_REGION = minioConfig.region;
process.env.OBJECT_STORE_S3_SERVICE = "s3";
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
const projectRef = "proj_presign_v2_style";
const envSlug = "dev";
const filename = "v2-style/payload.json";
const data = JSON.stringify({ v2: true });
const putResult = await generatePresignedUrl(projectRef, envSlug, filename, "PUT");
expect(putResult.success).toBe(true);
if (!putResult.success) throw new Error(putResult.error);
expect(putResult.storagePath).toBe(`s3://${filename}`);
const putResponse = await fetch(putResult.url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: data,
});
expect(putResponse.ok).toBe(true);
const getResult = await generatePresignedUrl(projectRef, envSlug, putResult.storagePath!, "GET");
expect(getResult.success).toBe(true);
if (!getResult.success) throw new Error(getResult.error);
const getResponse = await fetch(getResult.url);
expect(getResponse.ok).toBe(true);
expect(await getResponse.text()).toBe(data);
delete process.env.OBJECT_STORE_S3_BASE_URL;
delete process.env.OBJECT_STORE_S3_ACCESS_KEY_ID;
delete process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY;
delete process.env.OBJECT_STORE_S3_REGION;
delete process.env.OBJECT_STORE_S3_SERVICE;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
}
);
postgresAndMinioTest(
"generatePresignedUrl - forceNoPrefix PUT fails without legacy default when only S3 named",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = undefined;
env.OBJECT_STORE_ACCESS_KEY_ID = undefined;
env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined;
env.OBJECT_STORE_REGION = undefined;
process.env.OBJECT_STORE_S3_BASE_URL = minioConfig.baseUrl;
process.env.OBJECT_STORE_S3_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.OBJECT_STORE_S3_REGION = minioConfig.region;
process.env.OBJECT_STORE_S3_SERVICE = "s3";
env.OBJECT_STORE_DEFAULT_PROTOCOL = "s3";
const putLegacy = await generatePresignedUrl(
"proj_force_noprefix",
"dev",
"only-legacy/payload.json",
"PUT",
{ forceNoPrefix: true }
);
expect(putLegacy.success).toBe(false);
const getUnprefixed = await generatePresignedUrl(
"proj_force_noprefix",
"dev",
"any.json",
"GET"
);
expect(getUnprefixed.success).toBe(false);
delete process.env.OBJECT_STORE_S3_BASE_URL;
delete process.env.OBJECT_STORE_S3_ACCESS_KEY_ID;
delete process.env.OBJECT_STORE_S3_SECRET_ACCESS_KEY;
delete process.env.OBJECT_STORE_S3_REGION;
delete process.env.OBJECT_STORE_S3_SERVICE;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
}
);
postgresAndMinioTest(
"generatePresignedUrl - PUT then GET round-trip (IAM credential chain / AWS SDK path)",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_BUCKET = "packets";
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_ACCESS_KEY_ID = undefined;
env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
process.env.AWS_ACCESS_KEY_ID = minioConfig.accessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
process.env.AWS_REGION = minioConfig.region;
const projectRef = "proj_presign_iam";
const envSlug = "dev";
const filename = "presigned-iam/payload.json";
const data = JSON.stringify({ presigned: "iam" });
// Upload via presigned PUT
const putResult = await generatePresignedUrl(projectRef, envSlug, filename, "PUT");
expect(putResult.success).toBe(true);
if (!putResult.success) throw new Error(putResult.error);
expect(putResult.storagePath).toBe(filename);
const putResponse = await fetch(putResult.url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: data,
});
expect(putResponse.ok).toBe(true);
// Download via presigned GET
const getResult = await generatePresignedUrl(projectRef, envSlug, filename, "GET");
expect(getResult.success).toBe(true);
if (!getResult.success) throw new Error(getResult.error);
const getResponse = await fetch(getResult.url);
expect(getResponse.ok).toBe(true);
expect(await getResponse.text()).toBe(data);
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_REGION;
}
);
postgresAndMinioTest(
"generatePresignedRequest - returns a signed Request object",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
const result = await generatePresignedRequest(
"proj_req_test",
"dev",
"req-test/file.json",
"GET"
);
expect(result.success).toBe(true);
if (!result.success) throw new Error(result.error);
// URL should point at the right key and contain SigV4 query params
expect(result.request.url).toContain("packets/proj_req_test/dev/req-test/file.json");
expect(result.request.url).toContain("X-Amz-");
expect(result.request.method).toBe("GET");
}
);
// Restore env after all tests
afterAll(() => {
process.env = originalEnv;
env.OBJECT_STORE_BASE_URL = originalEnvObj.OBJECT_STORE_BASE_URL;
env.OBJECT_STORE_BUCKET = originalEnvObj.OBJECT_STORE_BUCKET;
env.OBJECT_STORE_ACCESS_KEY_ID = originalEnvObj.OBJECT_STORE_ACCESS_KEY_ID;
env.OBJECT_STORE_SECRET_ACCESS_KEY = originalEnvObj.OBJECT_STORE_SECRET_ACCESS_KEY;
env.OBJECT_STORE_REGION = originalEnvObj.OBJECT_STORE_REGION;
env.OBJECT_STORE_DEFAULT_PROTOCOL = originalEnvObj.OBJECT_STORE_DEFAULT_PROTOCOL;
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD = originalEnvObj.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
});
});
+40
View File
@@ -4,6 +4,7 @@ volumes:
database-data:
database-data-alt:
redis-data:
minio-data:
clickhouse-data:
clickhouse-logs:
prometheus-data:
@@ -49,6 +50,45 @@ services:
ports:
- 6379:6379
# S3-compatible API for local object store (large payloads / packet offload).
# Host :9005 = S3 API, :9006 = web console (ClickHouse uses host :9000).
minio:
container_name: minio
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio-data:/data
ports:
- "9005:9000"
- "9006:9001"
networks:
- app_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
networks:
- app_network
entrypoint: /bin/sh
command:
- -c
- |
mc alias set local http://minio:9000 minioadmin minioadmin
mc mb -p local/packets || true
restart: "no"
electric:
container_name: electric
image: electricsql/electric:1.2.4@sha256:20da3d0b0e74926c5623392db67fd56698b9e374c4aeb6cb5cadeb8fea171c36
+247 -141
View File
@@ -5,145 +5,251 @@ sidebarTitle: "Webapp"
mode: "wide"
---
| Name | Required | Default | Description |
| :----------------------------------------------- | :------- | :-------------------- | :-------------------------------------------------------------------------------------------------------- |
| **Secrets** | | | |
| `SESSION_SECRET` | Yes | — | Session encryption secret. Run: `openssl rand -hex 16` |
| `MAGIC_LINK_SECRET` | Yes | — | Magic link encryption secret. Run: `openssl rand -hex 16` |
| `ENCRYPTION_KEY` | Yes | — | Secret store encryption key. Run: `openssl rand -hex 16` |
| `MANAGED_WORKER_SECRET` | No | managed-secret | Managed worker secret. Should be changed and match supervisor. |
| **Domains & ports** | | | |
| `REMIX_APP_PORT` | No | 3030 | Remix app port. |
| `APP_ORIGIN` | Yes | http://localhost:3030 | App origin URL. |
| `LOGIN_ORIGIN` | Yes | http://localhost:3030 | Login origin URL. Most likely the same as `APP_ORIGIN`. |
| `API_ORIGIN` | No | `APP_ORIGIN` | API origin URL. |
| `STREAM_ORIGIN` | No | `APP_ORIGIN` | Realtime stream origin URL. |
| `ELECTRIC_ORIGIN` | No | http://localhost:3060 | Electric origin URL. |
| **Postgres** | | | |
| `DATABASE_URL` | Yes | — | PostgreSQL connection string. |
| `DIRECT_URL` | Yes | — | Direct DB connection string used for migrations etc. |
| `DATABASE_CONNECTION_LIMIT` | No | 10 | Max DB connections. |
| `DATABASE_POOL_TIMEOUT` | No | 60 | DB pool timeout (s). |
| `DATABASE_CONNECTION_TIMEOUT` | No | 20 | DB connect timeout (s). |
| `DATABASE_READ_REPLICA_URL` | No | `DATABASE_URL` | Read-replica DB string. |
| **Redis** | | | |
| `REDIS_HOST` | Yes | — | Redis host. |
| `REDIS_PORT` | Yes | — | Redis port. |
| `REDIS_READER_HOST` | No | `REDIS_HOST` | Redis reader host. |
| `REDIS_READER_PORT` | No | `REDIS_PORT` | Redis reader port. |
| `REDIS_USERNAME` | No | — | Redis username. |
| `REDIS_PASSWORD` | No | — | Redis password. |
| `REDIS_TLS_DISABLED` | No | — | Disable Redis TLS. |
| **Auth** | | | |
| `WHITELISTED_EMAILS` | No | — | Whitelisted emails regex. |
| `AUTH_GITHUB_CLIENT_ID` | No | — | GitHub client ID. |
| `AUTH_GITHUB_CLIENT_SECRET` | No | — | GitHub client secret. |
| **Email** | | | |
| `EMAIL_TRANSPORT` | No | — | Email transport type. One of `resend`, `smtp`, `aws-ses`. |
| `FROM_EMAIL` | No | — | From email address. |
| `REPLY_TO_EMAIL` | No | — | Reply-to email address. |
| `RESEND_API_KEY` | No | — | Resend API key. |
| `SMTP_HOST` | No | — | SMTP host. |
| `SMTP_PORT` | No | — | SMTP port. |
| `SMTP_SECURE` | No | — | SMTP secure flag. |
| `SMTP_USER` | No | — | SMTP user. |
| `SMTP_PASSWORD` | No | — | SMTP password. |
| `AWS_REGION` | No | — | AWS region for SES. |
| `AWS_ACCESS_KEY_ID` | No | — | AWS access key ID for SES. |
| `AWS_SECRET_ACCESS_KEY` | No | — | AWS secret access key for SES. |
| **Graphile & Redis worker** | | | |
| `WORKER_CONCURRENCY` | No | 10 | Redis worker concurrency. |
| `WORKER_POLL_INTERVAL` | No | 1000 | Redis worker poll interval (ms). |
| `WORKER_SCHEMA` | No | graphile_worker | Graphile worker schema. |
| `GRACEFUL_SHUTDOWN_TIMEOUT` | No | 60000 (1m) | Graphile graceful shutdown timeout (ms). Affects shutdown time. |
| **Concurrency limits** | | | |
| `DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT` | No | 100 | Default env execution concurrency. |
| `DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT` | No | 300 | Default org execution concurrency, needs to be 3x env concurrency. |
| **Dev** | | | |
| `DEV_MAX_CONCURRENT_RUNS` | No | 25 | Sets the max concurrency for dev runs via the CLI. |
| `DEV_OTEL_EXPORTER_OTLP_ENDPOINT` | No | `APP_ORIGIN/otel` | OTel endpoint for dev runs. |
| **Rate limiting** | | | |
| `API_RATE_LIMIT_REFILL_INTERVAL` | No | 10s | API rate limit refill interval. |
| `API_RATE_LIMIT_MAX` | No | 750 | API rate limit max. |
| `API_RATE_LIMIT_REFILL_RATE` | No | 250 | API rate limit refill rate. |
| `API_RATE_LIMIT_REQUEST_LOGS_ENABLED` | No | 0 | API rate limit request logs. |
| `API_RATE_LIMIT_REJECTION_LOGS_ENABLED` | No | 1 | API rate limit rejection logs. |
| `API_RATE_LIMIT_LIMITER_LOGS_ENABLED` | No | 0 | API rate limit limiter logs. |
| `API_RATE_LIMIT_JWT_WINDOW` | No | 1m | API rate limit JWT window. |
| `API_RATE_LIMIT_JWT_TOKENS` | No | 60 | API rate limit JWT tokens. |
| **Deploy & Registry** | | | |
| `DEPLOY_REGISTRY_HOST` | Yes | — | Deploy registry host. |
| `DEPLOY_REGISTRY_USERNAME` | No | — | Deploy registry username. |
| `DEPLOY_REGISTRY_PASSWORD` | No | — | Deploy registry password. |
| `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. |
| `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. |
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
| **Object store (S3)** | | | |
| `OBJECT_STORE_BASE_URL` | No | — | Object store base URL. |
| `OBJECT_STORE_ACCESS_KEY_ID` | No | — | Object store access key. |
| `OBJECT_STORE_SECRET_ACCESS_KEY` | No | — | Object store secret key. |
| `OBJECT_STORE_REGION` | No | — | Object store region. |
| `OBJECT_STORE_SERVICE` | No | s3 | Object store service. |
| **Alerts** | | | |
| `ORG_SLACK_INTEGRATION_CLIENT_ID` | No | — | Slack client ID. Required for Slack alerts. |
| `ORG_SLACK_INTEGRATION_CLIENT_SECRET` | No | — | Slack client secret. Required for Slack alerts. |
| `ALERT_EMAIL_TRANSPORT` | No | — | Alert email transport. |
| `ALERT_FROM_EMAIL` | No | — | Alert from email. |
| `ALERT_REPLY_TO_EMAIL` | No | — | Alert reply-to email. |
| `ALERT_RESEND_API_KEY` | No | | Alert Resend API key. |
| `ALERT_SMTP_HOST` | No | — | Alert SMTP host. |
| `ALERT_SMTP_PORT` | No | — | Alert SMTP port. |
| `ALERT_SMTP_SECURE` | No | — | Alert SMTP secure. |
| `ALERT_SMTP_USER` | No | — | Alert SMTP user. |
| `ALERT_SMTP_PASSWORD` | No | — | Alert SMTP password. |
| **Limits** | | | |
| `TASK_PAYLOAD_OFFLOAD_THRESHOLD` | No | 524288 (512KB) | Max task payload size before offloading to S3. |
| `TASK_PAYLOAD_MAXIMUM_SIZE` | No | 3145728 (3MB) | Max task payload size. |
| `BATCH_TASK_PAYLOAD_MAXIMUM_SIZE` | No | 1000000 (1MB) | Max batch payload size. |
| `TASK_RUN_METADATA_MAXIMUM_SIZE` | No | 262144 (256KB) | Max metadata size. |
| `MAX_BATCH_V2_TRIGGER_ITEMS` | No | 500 | Max batch size (legacy v2 API). |
| `STREAMING_BATCH_MAX_ITEMS` | No | 1000 | Max items in streaming batch (v3 API, requires SDK 4.3.1+). |
| `STREAMING_BATCH_ITEM_MAXIMUM_SIZE` | No | 3145728 (3MB) | Max size per item in streaming batch. |
| `MAXIMUM_DEV_QUEUE_SIZE` | No | | Max dev queue size. |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE` | No | | Max deployed queue size. |
| **OTel limits** | | | |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel span attribute count limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel log attribute count limit. |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel span attribute value length limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel log attribute value length limit. |
| `TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT` | No | 10 | OTel span event count limit. |
| `TRIGGER_OTEL_LINK_COUNT_LIMIT` | No | 2 | OTel link count limit. |
| `TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT` | No | 10 | OTel attribute per link count limit. |
| `TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT` | No | 10 | OTel attribute per event count limit. |
| `SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 8192 | OTel span attribute value length limit. |
| **Realtime** | | | |
| `REALTIME_STREAM_MAX_LENGTH` | No | 1000 | Realtime stream max length. |
| `REALTIME_STREAM_TTL` | No | 86400 (1d) | Realtime stream TTL (s). |
| **Bootstrap** | | | |
| `TRIGGER_BOOTSTRAP_ENABLED` | No | 0 | Trigger bootstrap enabled. |
| `TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME` | No | | Trigger bootstrap worker group name. |
| `TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH` | No | | Trigger bootstrap worker token path. |
| **Run engine** | | | |
| `RUN_ENGINE_WORKER_COUNT` | No | 4 | Run engine worker count. |
| `RUN_ENGINE_TASKS_PER_WORKER` | No | 10 | Run engine tasks per worker. |
| `RUN_ENGINE_WORKER_CONCURRENCY_LIMIT` | No | 10 | Run engine worker concurrency limit. |
| `RUN_ENGINE_WORKER_POLL_INTERVAL` | No | 100 | Run engine worker poll interval (ms). |
| `RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL` | No | 100 | Run engine worker immediate poll interval (ms). |
| `RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS` | No | 60000 (1m) | Run engine worker shutdown timeout (ms). |
| `RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL` | No | 10s | Run engine rate limit refill interval. |
| `RUN_ENGINE_RATE_LIMIT_MAX` | No | 1200 | Run engine rate limit max. |
| `RUN_ENGINE_RATE_LIMIT_REFILL_RATE` | No | 400 | Run engine rate limit refill rate. |
| `RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED` | No | 0 | Run engine rate limit request logs. |
| `RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED` | No | 1 | Run engine rate limit rejection logs. |
| `RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED` | No | 0 | Run engine rate limit limiter logs. |
| Name | Required | Default | Description |
| :----------------------------------------------- | :------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------- |
| **Secrets** | | | |
| `SESSION_SECRET` | Yes | — | Session encryption secret. Run: `openssl rand -hex 16` |
| `MAGIC_LINK_SECRET` | Yes | — | Magic link encryption secret. Run: `openssl rand -hex 16` |
| `ENCRYPTION_KEY` | Yes | — | Secret store encryption key. Run: `openssl rand -hex 16` |
| `MANAGED_WORKER_SECRET` | No | managed-secret | Managed worker secret. Should be changed and match supervisor. |
| **Domains & ports** | | | |
| `REMIX_APP_PORT` | No | 3030 | Remix app port. |
| `APP_ORIGIN` | Yes | http://localhost:3030 | App origin URL. |
| `LOGIN_ORIGIN` | Yes | http://localhost:3030 | Login origin URL. Most likely the same as `APP_ORIGIN`. |
| `API_ORIGIN` | No | `APP_ORIGIN` | API origin URL. |
| `STREAM_ORIGIN` | No | `APP_ORIGIN` | Realtime stream origin URL. |
| `ELECTRIC_ORIGIN` | No | http://localhost:3060 | Electric origin URL. |
| **Postgres** | | | |
| `DATABASE_URL` | Yes | — | PostgreSQL connection string. |
| `DIRECT_URL` | Yes | — | Direct DB connection string used for migrations etc. |
| `DATABASE_CONNECTION_LIMIT` | No | 10 | Max DB connections. |
| `DATABASE_POOL_TIMEOUT` | No | 60 | DB pool timeout (s). |
| `DATABASE_CONNECTION_TIMEOUT` | No | 20 | DB connect timeout (s). |
| `DATABASE_READ_REPLICA_URL` | No | `DATABASE_URL` | Read-replica DB string. |
| **Redis** | | | |
| `REDIS_HOST` | Yes | — | Redis host. |
| `REDIS_PORT` | Yes | — | Redis port. |
| `REDIS_READER_HOST` | No | `REDIS_HOST` | Redis reader host. |
| `REDIS_READER_PORT` | No | `REDIS_PORT` | Redis reader port. |
| `REDIS_USERNAME` | No | — | Redis username. |
| `REDIS_PASSWORD` | No | — | Redis password. |
| `REDIS_TLS_DISABLED` | No | — | Disable Redis TLS. |
| **Auth** | | | |
| `WHITELISTED_EMAILS` | No | — | Whitelisted emails regex. |
| `AUTH_GITHUB_CLIENT_ID` | No | — | GitHub client ID. |
| `AUTH_GITHUB_CLIENT_SECRET` | No | — | GitHub client secret. |
| **Email** | | | |
| `EMAIL_TRANSPORT` | No | — | Email transport type. One of `resend`, `smtp`, `aws-ses`. |
| `FROM_EMAIL` | No | — | From email address. |
| `REPLY_TO_EMAIL` | No | — | Reply-to email address. |
| `RESEND_API_KEY` | No | — | Resend API key. |
| `SMTP_HOST` | No | — | SMTP host. |
| `SMTP_PORT` | No | — | SMTP port. |
| `SMTP_SECURE` | No | — | SMTP secure flag. |
| `SMTP_USER` | No | — | SMTP user. |
| `SMTP_PASSWORD` | No | — | SMTP password. |
| `AWS_REGION` | No | — | AWS region for SES. |
| `AWS_ACCESS_KEY_ID` | No | — | AWS access key ID for SES. |
| `AWS_SECRET_ACCESS_KEY` | No | — | AWS secret access key for SES. |
| **Graphile & Redis worker** | | | |
| `WORKER_CONCURRENCY` | No | 10 | Redis worker concurrency. |
| `WORKER_POLL_INTERVAL` | No | 1000 | Redis worker poll interval (ms). |
| `WORKER_SCHEMA` | No | graphile_worker | Graphile worker schema. |
| `GRACEFUL_SHUTDOWN_TIMEOUT` | No | 60000 (1m) | Graphile graceful shutdown timeout (ms). Affects shutdown time. |
| **Concurrency limits** | | | |
| `DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT` | No | 100 | Default env execution concurrency. |
| `DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT` | No | 300 | Default org execution concurrency, needs to be 3x env concurrency. |
| **Dev** | | | |
| `DEV_MAX_CONCURRENT_RUNS` | No | 25 | Sets the max concurrency for dev runs via the CLI. |
| `DEV_OTEL_EXPORTER_OTLP_ENDPOINT` | No | `APP_ORIGIN/otel` | OTel endpoint for dev runs. |
| **Rate limiting** | | | |
| `API_RATE_LIMIT_REFILL_INTERVAL` | No | 10s | API rate limit refill interval. |
| `API_RATE_LIMIT_MAX` | No | 750 | API rate limit max. |
| `API_RATE_LIMIT_REFILL_RATE` | No | 250 | API rate limit refill rate. |
| `API_RATE_LIMIT_REQUEST_LOGS_ENABLED` | No | 0 | API rate limit request logs. |
| `API_RATE_LIMIT_REJECTION_LOGS_ENABLED` | No | 1 | API rate limit rejection logs. |
| `API_RATE_LIMIT_LIMITER_LOGS_ENABLED` | No | 0 | API rate limit limiter logs. |
| `API_RATE_LIMIT_JWT_WINDOW` | No | 1m | API rate limit JWT window. |
| `API_RATE_LIMIT_JWT_TOKENS` | No | 60 | API rate limit JWT tokens. |
| **Deploy & Registry** | | | |
| `DEPLOY_REGISTRY_HOST` | Yes | — | Deploy registry host. |
| `DEPLOY_REGISTRY_USERNAME` | No | — | Deploy registry username. |
| `DEPLOY_REGISTRY_PASSWORD` | No | — | Deploy registry password. |
| `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. |
| `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. |
| `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). |
| **Object store (S3)** | | | |
| `OBJECT_STORE_BASE_URL` | No | — | Object store base URL (default provider). |
| `OBJECT_STORE_ACCESS_KEY_ID` | No | — | Object store access key (default provider). |
| `OBJECT_STORE_SECRET_ACCESS_KEY` | No | — | Object store secret key (default provider). |
| `OBJECT_STORE_REGION` | No | — | Object store region (default provider). |
| `OBJECT_STORE_SERVICE` | No | s3 | Object store service (default provider). |
| `OBJECT_STORE_DEFAULT_PROTOCOL` | No | | Protocol to use for new uploads (e.g., `s3`, `r2`). Enables protocol-prefixed storage. See migration guide below. |
| `OBJECT_STORE_{PROTOCOL}_BASE_URL` | No | — | Named provider base URL (replace `{PROTOCOL}` with protocol name, e.g., `OBJECT_STORE_S3_BASE_URL`). |
| `OBJECT_STORE_{PROTOCOL}_ACCESS_KEY_ID` | No | — | Named provider access key. |
| `OBJECT_STORE_{PROTOCOL}_SECRET_ACCESS_KEY` | No | — | Named provider secret key. |
| `OBJECT_STORE_{PROTOCOL}_REGION` | No | — | Named provider region. |
| `OBJECT_STORE_{PROTOCOL}_SERVICE` | No | — | Named provider service. |
| **Alerts** | | | |
| `ORG_SLACK_INTEGRATION_CLIENT_ID` | No | — | Slack client ID. Required for Slack alerts. |
| `ORG_SLACK_INTEGRATION_CLIENT_SECRET` | No | — | Slack client secret. Required for Slack alerts. |
| `ALERT_EMAIL_TRANSPORT` | No | — | Alert email transport. |
| `ALERT_FROM_EMAIL` | No | — | Alert from email. |
| `ALERT_REPLY_TO_EMAIL` | No | — | Alert reply-to email. |
| `ALERT_RESEND_API_KEY` | No | | Alert Resend API key. |
| `ALERT_SMTP_HOST` | No | — | Alert SMTP host. |
| `ALERT_SMTP_PORT` | No | — | Alert SMTP port. |
| `ALERT_SMTP_SECURE` | No | — | Alert SMTP secure. |
| `ALERT_SMTP_USER` | No | — | Alert SMTP user. |
| `ALERT_SMTP_PASSWORD` | No | | Alert SMTP password. |
| **Limits** | | | |
| `TASK_PAYLOAD_OFFLOAD_THRESHOLD` | No | 524288 (512KB) | Max task payload size before offloading to S3. |
| `TASK_PAYLOAD_MAXIMUM_SIZE` | No | 3145728 (3MB) | Max task payload size. |
| `BATCH_TASK_PAYLOAD_MAXIMUM_SIZE` | No | 1000000 (1MB) | Max batch payload size. |
| `TASK_RUN_METADATA_MAXIMUM_SIZE` | No | 262144 (256KB) | Max metadata size. |
| `MAX_BATCH_V2_TRIGGER_ITEMS` | No | 500 | Max batch size (legacy v2 API). |
| `STREAMING_BATCH_MAX_ITEMS` | No | 1000 | Max items in streaming batch (v3 API, requires SDK 4.3.1+). |
| `STREAMING_BATCH_ITEM_MAXIMUM_SIZE` | No | 3145728 (3MB) | Max size per item in streaming batch. |
| `MAXIMUM_DEV_QUEUE_SIZE` | No | | Max dev queue size. |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE` | No | | Max deployed queue size. |
| **OTel limits** | | | |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel span attribute count limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel log attribute count limit. |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel span attribute value length limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel log attribute value length limit. |
| `TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT` | No | 10 | OTel span event count limit. |
| `TRIGGER_OTEL_LINK_COUNT_LIMIT` | No | 2 | OTel link count limit. |
| `TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT` | No | 10 | OTel attribute per link count limit. |
| `TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT` | No | 10 | OTel attribute per event count limit. |
| `SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 8192 | OTel span attribute value length limit. |
| **Realtime** | | | |
| `REALTIME_STREAM_MAX_LENGTH` | No | 1000 | Realtime stream max length. |
| `REALTIME_STREAM_TTL` | No | 86400 (1d) | Realtime stream TTL (s). |
| **Bootstrap** | | | |
| `TRIGGER_BOOTSTRAP_ENABLED` | No | 0 | Trigger bootstrap enabled. |
| `TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME` | No | | Trigger bootstrap worker group name. |
| `TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH` | No | | Trigger bootstrap worker token path. |
| **Run engine** | | | |
| `RUN_ENGINE_WORKER_COUNT` | No | 4 | Run engine worker count. |
| `RUN_ENGINE_TASKS_PER_WORKER` | No | 10 | Run engine tasks per worker. |
| `RUN_ENGINE_WORKER_CONCURRENCY_LIMIT` | No | 10 | Run engine worker concurrency limit. |
| `RUN_ENGINE_WORKER_POLL_INTERVAL` | No | 100 | Run engine worker poll interval (ms). |
| `RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL` | No | 100 | Run engine worker immediate poll interval (ms). |
| `RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS` | No | 60000 (1m) | Run engine worker shutdown timeout (ms). |
| `RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL` | No | 10s | Run engine rate limit refill interval. |
| `RUN_ENGINE_RATE_LIMIT_MAX` | No | 1200 | Run engine rate limit max. |
| `RUN_ENGINE_RATE_LIMIT_REFILL_RATE` | No | 400 | Run engine rate limit refill rate. |
| `RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED` | No | 0 | Run engine rate limit request logs. |
| `RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED` | No | 1 | Run engine rate limit rejection logs. |
| `RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED` | No | 0 | Run engine rate limit limiter logs. |
| `RUN_ENGINE_DEFAULT_MAX_TTL` | No | — | Maximum TTL for all runs (e.g. "14d"). Runs without a TTL use this as default; runs with a larger TTL are clamped. |
| `MAXIMUM_DEV_QUEUE_SIZE` | No | — | Maximum queued runs per queue in development environments. |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE` | No | — | Maximum queued runs per queue in deployed (staging/prod) environments. |
| **Misc** | | | |
| `TRIGGER_TELEMETRY_DISABLED` | No | — | Disable telemetry. |
| `NODE_MAX_OLD_SPACE_SIZE` | No | 8192 | Maximum memory allocation for Node.js heap in MiB (e.g. "4096" for 4GB). |
| `OPENAI_API_KEY` | No | — | OpenAI API key. |
| `MACHINE_PRESETS_OVERRIDE_PATH` | No | — | Path to machine presets override file. See [machine overrides](/self-hosting/overview#machine-overrides). |
| `APP_ENV` | No | `NODE_ENV` | App environment. Used for things like the title tag. |
| `ADMIN_EMAILS` | No | — | Regex of user emails to automatically promote to admin on signup. Does not apply to existing users. |
| `EVENT_LOOP_MONITOR_ENABLED` | No | 1 | Node.js event loop lag monitor. |
| `MAXIMUM_DEV_QUEUE_SIZE` | No | — | Maximum queued runs per queue in development environments. |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE` | No | — | Maximum queued runs per queue in deployed (staging/prod) environments. |
| **Misc** | | | |
| `TRIGGER_TELEMETRY_DISABLED` | No | — | Disable telemetry. |
| `NODE_MAX_OLD_SPACE_SIZE` | No | 8192 | Maximum memory allocation for Node.js heap in MiB (e.g. "4096" for 4GB). |
| `OPENAI_API_KEY` | No | — | OpenAI API key. |
| `MACHINE_PRESETS_OVERRIDE_PATH` | No | — | Path to machine presets override file. See [machine overrides](/self-hosting/overview#machine-overrides). |
| `APP_ENV` | No | `NODE_ENV` | App environment. Used for things like the title tag. |
| `ADMIN_EMAILS` | No | — | Regex of user emails to automatically promote to admin on signup. Does not apply to existing users. |
| `EVENT_LOOP_MONITOR_ENABLED` | No | 1 | Node.js event loop lag monitor. |
## Multi-Provider Object Storage
The object storage system supports multiple S3-compatible providers (R2, S3, GCS, MinIO, etc.) using protocol prefixes. This enables migrating between providers without breaking existing runs.
### How It Works
When data exceeds the configured threshold (`TASK_PAYLOAD_OFFLOAD_THRESHOLD`), it's uploaded to object storage. The storage location is saved in the database with an optional protocol prefix:
- **With protocol**: `s3://run_abc/payload.json` or `r2://batch_123/item_0/payload.json`
- **Without protocol** (legacy): `batch_123/item_0/payload.json` (uses default provider)
### Configuration
#### Default Provider (Backward Compatible)
The default provider is used for data without a protocol prefix:
```bash
# Default provider (backward compatible - no protocol prefix)
OBJECT_STORE_BASE_URL=https://r2.example.com
OBJECT_STORE_ACCESS_KEY_ID=...
OBJECT_STORE_SECRET_ACCESS_KEY=...
OBJECT_STORE_REGION=auto
OBJECT_STORE_SERVICE=s3
```
#### Named Providers
Named providers are accessed via protocol-prefixed URIs. Configure them using `OBJECT_STORE_{PROTOCOL}_*` variables:
```bash
# S3 provider (accessed via s3:// prefix)
OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
OBJECT_STORE_S3_ACCESS_KEY_ID=...
OBJECT_STORE_S3_SECRET_ACCESS_KEY=...
OBJECT_STORE_S3_REGION=us-east-1
OBJECT_STORE_S3_SERVICE=s3
# R2 provider (accessed via r2:// prefix)
OBJECT_STORE_R2_BASE_URL=https://...r2.cloudflarestorage.com
OBJECT_STORE_R2_ACCESS_KEY_ID=...
OBJECT_STORE_R2_SECRET_ACCESS_KEY=...
OBJECT_STORE_R2_REGION=auto
OBJECT_STORE_R2_SERVICE=s3
```
#### Default Protocol for New Uploads
Set `OBJECT_STORE_DEFAULT_PROTOCOL` to specify which provider to use for new uploads:
```bash
# Use S3 for new uploads (old data without prefix still uses default provider)
OBJECT_STORE_DEFAULT_PROTOCOL=s3
```
### Migration Guide
To migrate from R2 to S3 without breaking existing runs:
<Steps>
<Step title="Configure S3 provider">
Add S3 credentials as a named provider:
```bash
OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
OBJECT_STORE_S3_ACCESS_KEY_ID=...
OBJECT_STORE_S3_SECRET_ACCESS_KEY=...
OBJECT_STORE_S3_REGION=us-east-1
```
Keep your existing `OBJECT_STORE_*` variables (R2) as the default provider.
</Step>
<Step title="Test the configuration">
Restart the webapp and verify both providers work:
- Old runs (no prefix) should still access R2
- New runs with `s3://` prefix should use S3
</Step>
<Step title="Switch to S3 for new uploads">
Set the default protocol to use S3 for new uploads:
```bash
OBJECT_STORE_DEFAULT_PROTOCOL=s3
```
After this change:
- New data uses `s3://` prefix and goes to S3
- Old data (no prefix) still uses R2
- Data with explicit protocol uses the corresponding provider
</Step>
<Step title="Optionally decommission R2">
Once all active runs using R2 data have completed (check your data retention policies), you can remove the R2 credentials. Keep `OBJECT_STORE_DEFAULT_PROTOCOL=s3` to ensure new data continues using S3.
</Step>
</Steps>
+18 -1
View File
@@ -77,12 +77,29 @@ DOCKER_REGISTRY_PASSWORD=very-secure-indeed
DOCKER_REGISTRY_NAMESPACE=trigger
# Object store
# - You need to log into the Minio dashboard and create a bucket called "packets"
# - You need to log into the Minio dashboard and create a bucket called "packets"
# - See the docs for more information: https://trigger.dev/docs/self-hosting/docker#object-storage
# Default provider (backward compatible - no protocol prefix)
OBJECT_STORE_ACCESS_KEY_ID=admin
OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
# You will have to uncomment and configure this for production
# OBJECT_STORE_BASE_URL=http://localhost:9000
# OBJECT_STORE_REGION=auto
# OBJECT_STORE_SERVICE=s3
# OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Optional: protocol to use for new uploads (e.g., "s3", "r2")
#
# Named providers (protocol-prefixed data) - optional for multi-provider support
# OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
# OBJECT_STORE_S3_ACCESS_KEY_ID=
# OBJECT_STORE_S3_SECRET_ACCESS_KEY=
# OBJECT_STORE_S3_REGION=us-east-1
# OBJECT_STORE_S3_SERVICE=s3
#
# OBJECT_STORE_R2_BASE_URL=https://{bucket}.{accountId}.r2.cloudflarestorage.com
# OBJECT_STORE_R2_ACCESS_KEY_ID=
# OBJECT_STORE_R2_SECRET_ACCESS_KEY=
# OBJECT_STORE_R2_REGION=auto
# OBJECT_STORE_R2_SERVICE=s3
# Credentials to access the Minio dashboard at http://localhost:9001
# - You should change these credentials and not use them for the `OBJECT_STORE_` env vars above
# - Instead, setup a non-root user with access the "packets" bucket
+12
View File
@@ -645,6 +645,18 @@ s3:
existingSecret: "" # Name of existing secret containing S3 credentials
existingSecretAccessKeyIdKey: "access-key-id" # Key in existing secret containing access key ID
existingSecretSecretAccessKeyKey: "secret-access-key" # Key in existing secret containing secret access key
#
# Multi-provider support (optional)
# To use multiple S3-compatible providers (e.g., S3 + R2), set defaultProtocol and configure named providers
# via extraEnvVars. For example:
# - OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Protocol to use for new uploads
# - OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
# - OBJECT_STORE_S3_ACCESS_KEY_ID=...
# - OBJECT_STORE_S3_SECRET_ACCESS_KEY=...
# - OBJECT_STORE_R2_BASE_URL=https://...r2.cloudflarestorage.com
# - OBJECT_STORE_R2_ACCESS_KEY_ID=...
# - OBJECT_STORE_R2_SECRET_ACCESS_KEY=...
# See documentation for migration guide: https://trigger.dev/docs/self-hosting/env/webapp
# Docker Registry configuration
registry:
@@ -9,15 +9,18 @@ import {
createElectricContainer,
createPostgresContainer,
createRedisContainer,
createMinIOContainer,
useContainer,
withContainerSetup,
} from "./utils";
import { getTaskMetadata, logCleanup, logSetup } from "./logs";
import { StartedClickHouseContainer } from "./clickhouse";
import { StartedMinIOContainer, type MinIOConnectionConfig } from "./minio";
import { ClickHouseClient, createClient } from "@clickhouse/client";
export { assertNonNullable } from "./utils";
export { logCleanup };
export type { MinIOConnectionConfig };
type NetworkContext = { network: StartedNetwork };
@@ -40,11 +43,17 @@ export type PostgresAndRedisContext = NetworkContext & PostgresContext & RedisCo
export type ContainerWithElectricAndRedisContext = ContainerContext & ElectricContext;
export type ContainerWithElectricContext = NetworkContext & PostgresContext & ElectricContext;
type MinIOContext = NetworkContext & {
minioContainer: StartedMinIOContainer;
minioConfig: MinIOConnectionConfig;
};
export type {
StartedNetwork,
StartedPostgreSqlContainer,
StartedRedisContainer,
StartedClickHouseContainer,
StartedMinIOContainer,
};
type Use<T> = (value: T) => Promise<void>;
@@ -257,3 +266,39 @@ export const containerWithElectricAndRedisTest = test.extend<ContainerWithElectr
clickhouseContainer,
clickhouseClient,
});
const minioContainer = async (
{ network, task }: { network: StartedNetwork } & TaskContext,
use: Use<StartedMinIOContainer>
) => {
const { container, metadata } = await withContainerSetup({
name: "minioContainer",
task,
setup: createMinIOContainer(network),
});
await useContainer("minioContainer", { container, task, use: () => use(container) });
};
const minioConfig = async (
{ minioContainer }: { minioContainer: StartedMinIOContainer },
use: Use<MinIOConnectionConfig>
) => {
await use(minioContainer.getConnectionConfig());
};
export const minioTest = test.extend<MinIOContext>({
network,
minioContainer,
minioConfig,
});
type PostgresAndMinIOContext = NetworkContext & PostgresContext & MinIOContext;
export const postgresAndMinioTest = test.extend<PostgresAndMinIOContext>({
network,
postgresContainer,
prisma,
minioContainer,
minioConfig,
});
@@ -0,0 +1,134 @@
import {
AbstractStartedContainer,
GenericContainer,
StartedTestContainer,
Wait,
} from "testcontainers";
import { x } from "tinyexec";
const MINIO_PORT = 9000;
export type MinIOConnectionConfig = {
baseUrl: string;
accessKeyId: string;
secretAccessKey: string;
region: string;
};
export class MinIOContainer extends GenericContainer {
private accessKeyId = "minioadmin";
private secretAccessKey = "minioadmin";
private region = "us-east-1";
constructor(image = "minio/minio:latest") {
super(image);
this.withExposedPorts(MINIO_PORT);
this.withCommand(["server", "/data"]);
this.withWaitStrategy(Wait.forLogMessage(/API:/));
this.withStartupTimeout(120_000);
}
public withAccessKeyId(accessKeyId: string): this {
this.accessKeyId = accessKeyId;
return this;
}
public withSecretAccessKey(secretAccessKey: string): this {
this.secretAccessKey = secretAccessKey;
return this;
}
public withRegion(region: string): this {
this.region = region;
return this;
}
public override async start(): Promise<StartedMinIOContainer> {
this.withEnvironment({
MINIO_ROOT_USER: this.accessKeyId,
MINIO_ROOT_PASSWORD: this.secretAccessKey,
});
const startedContainer = await super.start();
// Create the "packets" bucket using MinIO client
await x(
"docker",
[
"exec",
startedContainer.getId(),
"mc",
"alias",
"set",
"local",
"http://localhost:9000",
this.accessKeyId,
this.secretAccessKey,
],
{ throwOnError: true }
);
await x(
"docker",
["exec", startedContainer.getId(), "mc", "mb", "local/packets"],
{ throwOnError: true }
);
return new StartedMinIOContainer(
startedContainer,
this.accessKeyId,
this.secretAccessKey,
this.region
);
}
}
export class StartedMinIOContainer extends AbstractStartedContainer {
constructor(
startedTestContainer: StartedTestContainer,
private readonly accessKeyId: string,
private readonly secretAccessKey: string,
private readonly region: string
) {
super(startedTestContainer);
}
public getPort(): number {
return super.getMappedPort(MINIO_PORT);
}
public getAccessKeyId(): string {
return this.accessKeyId;
}
public getSecretAccessKey(): string {
return this.secretAccessKey;
}
public getRegion(): string {
return this.region;
}
/**
* Gets the base URL (protocol, host and mapped port) for the MinIO container.
* Example: `http://localhost:32768`
*/
public getBaseUrl(): string {
const protocol = "http";
const host = this.getHost();
const port = this.getPort();
return `${protocol}://${host}:${port}`;
}
/**
* Gets connection configuration suitable for object storage clients.
*/
public getConnectionConfig(): MinIOConnectionConfig {
return {
baseUrl: this.getBaseUrl(),
accessKeyId: this.getAccessKeyId(),
secretAccessKey: this.getSecretAccessKey(),
region: this.getRegion(),
};
}
}
@@ -9,6 +9,7 @@ import { GenericContainer, StartedNetwork, StartedTestContainer, Wait } from "te
import { x } from "tinyexec";
import { expect, TaskContext } from "vitest";
import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse";
import { MinIOContainer } from "./minio";
import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs";
export async function createPostgresContainer(network: StartedNetwork) {
@@ -170,6 +171,18 @@ export async function createElectricContainer(
};
}
export async function createMinIOContainer(network: StartedNetwork) {
const container = await new MinIOContainer()
.withNetwork(network)
.withNetworkAliases("minio")
.start();
return {
container,
network,
};
}
export function assertNonNullable<T>(value: T): asserts value is NonNullable<T> {
expect(value).toBeDefined();
expect(value).not.toBeNull();
+4 -2
View File
@@ -559,9 +559,10 @@ export class ApiClient {
}
createUploadPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) {
const encoded = encodeURIComponent(filename);
return zodfetch(
CreateUploadPayloadUrlResponseBody,
`${this.baseUrl}/api/v1/packets/${filename}`,
`${this.baseUrl}/api/v2/packets/${encoded}`,
{
method: "PUT",
headers: this.#getHeaders(false),
@@ -571,9 +572,10 @@ export class ApiClient {
}
getPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) {
const encoded = encodeURIComponent(filename);
return zodfetch(
CreateUploadPayloadUrlResponseBody,
`${this.baseUrl}/api/v1/packets/${filename}`,
`${this.baseUrl}/api/v1/packets/${encoded}`,
{
method: "GET",
headers: this.#getHeaders(false),
+2
View File
@@ -788,6 +788,8 @@ export const DeploymentEventFromString = z
export const CreateUploadPayloadUrlResponseBody = z.object({
presignedUrl: z.string(),
/** Present on `/api/v2/packets` PUT (upload handshake); omitted on v1 GET download presign. */
storagePath: z.string().optional(),
});
export const WorkersListResponseBody = z
@@ -169,6 +169,12 @@ async function exportPacket(packet: IOPacket, pathPrefix: string): Promise<IOPac
const presignedResponse = await apiClientManager.client!.createUploadPayloadUrl(filename);
if (!presignedResponse.storagePath) {
throw new Error(
"Packet upload presign response missing storagePath; ensure the server supports /api/v2/packets"
);
}
const uploadResponse = await zodfetch(
z.any(),
presignedResponse.presignedUrl,
@@ -191,7 +197,7 @@ async function exportPacket(packet: IOPacket, pathPrefix: string): Promise<IOPac
}
return {
data: filename,
data: presignedResponse.storagePath,
dataType: "application/store",
};
}