Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/config/extensions/syncEnvVars.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ The `syncVercelEnvVars` build extension syncs environment variables from your Ve
the project with the environment variables you want to sync.
</Note>

<Note>
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
environment variable values will be read from `process.env` instead of fetching them from the
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
API is still used to determine which environment variables are configured for your project, but
the actual values come from the local environment.
</Note>

```ts
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";
Expand Down Expand Up @@ -114,3 +122,71 @@ export default defineConfig({
},
});
```

### syncNeonEnvVars

The `syncNeonEnvVars` build extension syncs environment variables from your Neon database project to Trigger.dev. It automatically detects branches and builds the appropriate database connection strings for your environment.

<Note>
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
as arguments to the `syncNeonEnvVars` build extension. You can generate a `NEON_ACCESS_TOKEN` in
your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
</Note>

<Note>
When running the build from a Vercel environment (determined by checking if the `VERCEL`
environment variable is present), this extension is skipped entirely. This is because Neon's
Vercel integration already handles environment variable synchronization in Vercel environments.
</Note>

<Note>
This extension is skipped for `prod` environments. It is designed to sync branch-specific
database connections for preview/staging/dev environments.
</Note>

```ts
import { defineConfig } from "@trigger.dev/sdk";
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
project: "<project ref>",
// Your other config settings...
build: {
// This will automatically use the NEON_ACCESS_TOKEN and NEON_PROJECT_ID environment variables
extensions: [syncNeonEnvVars()],
},
});
```

Or you can pass in the token and project ID as arguments:

```ts
import { defineConfig } from "@trigger.dev/sdk";
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
project: "<project ref>",
// Your other config settings...
build: {
extensions: [
syncNeonEnvVars({
projectId: "your-neon-project-id",
neonAccessToken: "your-neon-access-token",
branch: "your-branch-name", // optional, defaults to ctx.branch
databaseName: "your-database-name", // optional, defaults to the first database
roleName: "your-role-name", // optional, defaults to the database owner
envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars
}),
],
},
});
```

The extension syncs the following environment variables (with optional prefix):

- `DATABASE_URL` - Pooled connection string
- `DATABASE_URL_UNPOOLED` - Direct connection string
- `POSTGRES_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`
- `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma
- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`
- `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`
8 changes: 8 additions & 0 deletions docs/guides/examples/vercel-sync-env-vars.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ To sync environment variables, you just need to add our build extension to your
the project with the environment variables you want to sync.
</Note>

<Note>
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
environment variable values will be read from `process.env` instead of fetching them from the
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
API is still used to determine which environment variables are configured for your project, but
the actual values come from the local environment.
</Note>

```ts trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";
Expand Down
1 change: 1 addition & 0 deletions packages/build/src/extensions/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from "./core/additionalPackages.js";
export * from "./core/syncEnvVars.js";
export * from "./core/aptGet.js";
export * from "./core/ffmpeg.js";
export * from "./core/neonSyncEnvVars.js";
export * from "./core/vercelSyncEnvVars.js";
289 changes: 289 additions & 0 deletions packages/build/src/extensions/core/neonSyncEnvVars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { BuildExtension } from "@trigger.dev/core/v3/build";
import { syncEnvVars } from "../core.js";

type EnvVar = { name: string; value: string; isParentEnv?: boolean };

type NeonBranch = {
id: string;
name: string;
};

type NeonEndpoint = {
id: string;
host: string;
type: string;
};

type NeonDatabase = {
id: number;
name: string;
owner_name: string;
};

type NeonRole = {
name: string;
password?: string;
};

// List of Neon DB related environment variables to sync
export const NEON_ENV_VARS = [
"PGUSER",
"POSTGRES_URL_NO_SSL",
"POSTGRES_HOST",
"POSTGRES_URL",
"POSTGRES_PRISMA_URL",
"DATABASE_URL_UNPOOLED",
"POSTGRES_URL_NON_POOLING",
"PGHOST",
"POSTGRES_USER",
"DATABASE_URL",
"POSTGRES_PASSWORD",
"POSTGRES_DATABASE",
"PGPASSWORD",
"PGDATABASE",
"PGHOST_UNPOOLED",
];

function buildNeonEnvVarMappings(options: {
user: string;
password: string;
database: string;
host: string;
poolerHost: string;
}): Record<string, string> {
const { user, password, database, host, poolerHost } = options;

return {
PGUSER: user,
PGPASSWORD: password,
PGDATABASE: database,
PGHOST: poolerHost,
PGHOST_UNPOOLED: host,
POSTGRES_USER: user,
POSTGRES_PASSWORD: password,
POSTGRES_DATABASE: database,
POSTGRES_HOST: poolerHost,
DATABASE_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
DATABASE_URL_UNPOOLED: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
POSTGRES_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
POSTGRES_URL_NO_SSL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}`,
POSTGRES_URL_NON_POOLING: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
POSTGRES_PRISMA_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`,
};
}

export function syncNeonEnvVars(options?: {
projectId?: string;
neonAccessToken?: string;
branch?: string;
databaseName?: string;
roleName?: string;
envVarPrefix?: string;
}): BuildExtension {
const sync = syncEnvVars(async (ctx) => {
const projectId =
options?.projectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID;
const neonAccessToken =
options?.neonAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN;
const branch = options?.branch ?? ctx.branch;
const envVarPrefix = options?.envVarPrefix ?? "";
const outputEnvVars = NEON_ENV_VARS;

// Skip the whole process for Vercel environments
if (ctx.env.VERCEL) {
return [];
}

if (!projectId) {
throw new Error(
"syncNeonEnvVars: you did not pass in a projectId or set the NEON_PROJECT_ID env var."
);
}

if (!neonAccessToken) {
throw new Error(
"syncNeonEnvVars: you did not pass in an neonAccessToken or set the NEON_ACCESS_TOKEN env var."
);
}

// Skip branch-specific logic for production environment
if (ctx.environment === "prod") {
return [];
}

if (!branch) {
throw new Error(
"syncNeonEnvVars: you did not pass in a branch and no branch was detected from context."
);
}

const environmentMap = {
prod: "production",
staging: "preview",
dev: "development",
preview: "preview",
} as const;

const environment = environmentMap[ctx.environment as keyof typeof environmentMap];

if (!environment) {
throw new Error(
`Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', 'dev', or 'preview'.`
);
}

try {
// Step 1: Search for the branch in Neon
const branchSearchParams = new URLSearchParams({ search: branch });
const branchesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches?${branchSearchParams}`;
const branchesResponse = await fetch(branchesUrl, {
headers: {
Authorization: `Bearer ${neonAccessToken}`,
},
});

if (!branchesResponse.ok) {
throw new Error(`Failed to fetch Neon branches: ${branchesResponse.status}`);
}

const branchesData = await branchesResponse.json();
const branches: NeonBranch[] = branchesData.branches || [];

if (branches.length === 0) {
// No matching branch found
return [];
}

// Neon branch names are prefixed with environment (e.g., "preview/branch-name")
const expectedBranchName = `${environment}/${branch}`;
const matchingBranch = branches.find(
(b) => b.name === expectedBranchName || b.name === branch
);

if (!matchingBranch) {
// No exact match found
return [];
}

const neonBranchId = matchingBranch.id;

// Step 2: Get endpoints for the branch
const endpointsUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/endpoints`;
const endpointsResponse = await fetch(endpointsUrl, {
headers: {
Authorization: `Bearer ${neonAccessToken}`,
},
});

if (!endpointsResponse.ok) {
throw new Error(`Failed to fetch Neon branch endpoints: ${endpointsResponse.status}`);
}

const endpointsData = await endpointsResponse.json();
const endpoints: NeonEndpoint[] = endpointsData.endpoints || [];

if (endpoints.length === 0) {
return [];
}

// Find an endpoint with type containing 'write', or take the first one
const writeEndpoint = endpoints.find((ep) => ep.type.includes("write"));
const endpoint = writeEndpoint || endpoints[0];

if (!endpoint) {
return [];
}

// Step 3: Get databases for the branch
const databasesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/databases`;
const databasesResponse = await fetch(databasesUrl, {
headers: {
Authorization: `Bearer ${neonAccessToken}`,
},
});

if (!databasesResponse.ok) {
throw new Error(`Failed to fetch Neon branch databases: ${databasesResponse.status}`);
}

const databasesData = await databasesResponse.json();
const databases: NeonDatabase[] = databasesData.databases || [];

if (databases.length === 0) {
return [];
}

// Find the specified database or use the first one
const targetDatabase = options?.databaseName
? databases.find((db) => db.name === options.databaseName)
: databases[0];

if (!targetDatabase) {
throw new Error(
`syncNeonEnvVars: Database '${options?.databaseName}' not found in branch.`
);
}

// Step 4: Get the role (user) and password
const targetRoleName = options?.roleName ?? targetDatabase.owner_name;
const rolePasswordUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/roles/${targetRoleName}/reveal_password`;
const rolePasswordResponse = await fetch(rolePasswordUrl, {
headers: {
Authorization: `Bearer ${neonAccessToken}`,
},
});

if (!rolePasswordResponse.ok) {
throw new Error(
`Failed to fetch Neon role password: ${rolePasswordResponse.status}. Make sure the role '${targetRoleName}' exists and has a password.`
);
}

const rolePasswordData: NeonRole = await rolePasswordResponse.json();
const password = rolePasswordData.password;

if (!password) {
throw new Error(
`syncNeonEnvVars: No password found for role '${targetRoleName}'. The role may not have a password set.`
);
}

// Step 5: Build new environment variables based on the endpoint host
const newHost = endpoint.host;
const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler.");

const envVarMappings = buildNeonEnvVarMappings({
user: targetRoleName,
password,
database: targetDatabase.name,
host: newHost,
poolerHost,
});

// Build output env vars
const newEnvVars: EnvVar[] = [];

for (const neonEnvVar of outputEnvVars) {
const prefixedKey = `${envVarPrefix}${neonEnvVar}`;
if (envVarMappings[neonEnvVar]) {
newEnvVars.push({
name: prefixedKey,
value: envVarMappings[neonEnvVar],
});
}
}

return newEnvVars;
} catch (error) {
console.error("Error fetching Neon branch environment variables:", error);
throw error;
}
});

return {
name: "SyncNeonEnvVarsExtension",
async onBuildComplete(context, manifest) {
await sync.onBuildComplete?.(context, manifest);
},
};
}
Loading