Skip to content

Commit 53f21e1

Browse files
committed
feat(build): Add syncNeonEnvVars extension and improve Vercel env var syncing
Add a new `syncNeonEnvVars` build extension for syncing environment variables from Neon database projects to Trigger.dev. The extension automatically detects branches and builds appropriate PostgreSQL connection strings for non-production environments (staging, dev, preview). Features of `syncNeonEnvVars`: - Fetches branch-specific database credentials from Neon API - Generates all standard Postgres connection strings (DATABASE_URL, POSTGRES_URL, POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants - Supports custom database name, role name, and env var prefix options - Skips automatically in Vercel environments (Neon's Vercel integration handles this) - Skips for production environments (designed for preview/staging/dev branches) Improvements to `syncVercelEnvVars`: - When running in a Vercel build environment (detected via VERCEL env var), values are now read from process.env instead of the Vercel API response - This ensures the build uses the actual runtime values Vercel provides - Removed embedded Neon-specific logic (now handled by separate extension) - Simplified and cleaned up the extension code Documentation updates for both extensions with usage examples and configuration options.
1 parent 49b2f68 commit 53f21e1

File tree

5 files changed

+382
-238
lines changed

5 files changed

+382
-238
lines changed

docs/config/extensions/syncEnvVars.mdx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ The `syncVercelEnvVars` build extension syncs environment variables from your Ve
8080
the project with the environment variables you want to sync.
8181
</Note>
8282

83+
<Note>
84+
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
85+
environment variable values will be read from `process.env` instead of fetching them from the
86+
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
87+
API is still used to determine which environment variables are configured for your project, but
88+
the actual values come from the local environment.
89+
</Note>
90+
8391
```ts
8492
import { defineConfig } from "@trigger.dev/sdk";
8593
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";
@@ -114,3 +122,71 @@ export default defineConfig({
114122
},
115123
});
116124
```
125+
126+
### syncNeonEnvVars
127+
128+
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.
129+
130+
<Note>
131+
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
132+
as arguments to the `syncNeonEnvVars` build extension. You can generate a `NEON_ACCESS_TOKEN` in
133+
your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
134+
</Note>
135+
136+
<Note>
137+
When running the build from a Vercel environment (determined by checking if the `VERCEL`
138+
environment variable is present), this extension is skipped entirely. This is because Neon's
139+
Vercel integration already handles environment variable synchronization in Vercel environments.
140+
</Note>
141+
142+
<Note>
143+
This extension is skipped for `prod` environments. It is designed to sync branch-specific
144+
database connections for preview/staging/dev environments.
145+
</Note>
146+
147+
```ts
148+
import { defineConfig } from "@trigger.dev/sdk";
149+
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";
150+
151+
export default defineConfig({
152+
project: "<project ref>",
153+
// Your other config settings...
154+
build: {
155+
// This will automatically use the NEON_ACCESS_TOKEN and NEON_PROJECT_ID environment variables
156+
extensions: [syncNeonEnvVars()],
157+
},
158+
});
159+
```
160+
161+
Or you can pass in the token and project ID as arguments:
162+
163+
```ts
164+
import { defineConfig } from "@trigger.dev/sdk";
165+
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";
166+
167+
export default defineConfig({
168+
project: "<project ref>",
169+
// Your other config settings...
170+
build: {
171+
extensions: [
172+
syncNeonEnvVars({
173+
projectId: "your-neon-project-id",
174+
neonAccessToken: "your-neon-access-token",
175+
branch: "your-branch-name", // optional, defaults to ctx.branch
176+
databaseName: "your-database-name", // optional, defaults to the first database
177+
roleName: "your-role-name", // optional, defaults to the database owner
178+
envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars
179+
}),
180+
],
181+
},
182+
});
183+
```
184+
185+
The extension syncs the following environment variables (with optional prefix):
186+
187+
- `DATABASE_URL` - Pooled connection string
188+
- `DATABASE_URL_UNPOOLED` - Direct connection string
189+
- `POSTGRES_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`
190+
- `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma
191+
- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`
192+
- `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`

docs/guides/examples/vercel-sync-env-vars.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ To sync environment variables, you just need to add our build extension to your
1919
the project with the environment variables you want to sync.
2020
</Note>
2121

22+
<Note>
23+
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
24+
environment variable values will be read from `process.env` instead of fetching them from the
25+
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
26+
API is still used to determine which environment variables are configured for your project, but
27+
the actual values come from the local environment.
28+
</Note>
29+
2230
```ts trigger.config.ts
2331
import { defineConfig } from "@trigger.dev/sdk";
2432
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

packages/build/src/extensions/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from "./core/additionalPackages.js";
33
export * from "./core/syncEnvVars.js";
44
export * from "./core/aptGet.js";
55
export * from "./core/ffmpeg.js";
6+
export * from "./core/neonSyncEnvVars.js";
67
export * from "./core/vercelSyncEnvVars.js";
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { BuildExtension } from "@trigger.dev/core/v3/build";
2+
import { syncEnvVars } from "../core.js";
3+
4+
type EnvVar = { name: string; value: string; isParentEnv?: boolean };
5+
6+
type NeonBranch = {
7+
id: string;
8+
name: string;
9+
};
10+
11+
type NeonEndpoint = {
12+
id: string;
13+
host: string;
14+
type: string;
15+
};
16+
17+
type NeonDatabase = {
18+
id: number;
19+
name: string;
20+
owner_name: string;
21+
};
22+
23+
type NeonRole = {
24+
name: string;
25+
password?: string;
26+
};
27+
28+
// List of Neon DB related environment variables to sync
29+
export const NEON_ENV_VARS = [
30+
"PGUSER",
31+
"POSTGRES_URL_NO_SSL",
32+
"POSTGRES_HOST",
33+
"POSTGRES_URL",
34+
"POSTGRES_PRISMA_URL",
35+
"DATABASE_URL_UNPOOLED",
36+
"POSTGRES_URL_NON_POOLING",
37+
"PGHOST",
38+
"POSTGRES_USER",
39+
"DATABASE_URL",
40+
"POSTGRES_PASSWORD",
41+
"POSTGRES_DATABASE",
42+
"PGPASSWORD",
43+
"PGDATABASE",
44+
"PGHOST_UNPOOLED",
45+
];
46+
47+
function buildNeonEnvVarMappings(options: {
48+
user: string;
49+
password: string;
50+
database: string;
51+
host: string;
52+
poolerHost: string;
53+
}): Record<string, string> {
54+
const { user, password, database, host, poolerHost } = options;
55+
56+
return {
57+
PGUSER: user,
58+
PGPASSWORD: password,
59+
PGDATABASE: database,
60+
PGHOST: poolerHost,
61+
PGHOST_UNPOOLED: host,
62+
POSTGRES_USER: user,
63+
POSTGRES_PASSWORD: password,
64+
POSTGRES_DATABASE: database,
65+
POSTGRES_HOST: poolerHost,
66+
DATABASE_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
67+
DATABASE_URL_UNPOOLED: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
68+
POSTGRES_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
69+
POSTGRES_URL_NO_SSL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}`,
70+
POSTGRES_URL_NON_POOLING: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
71+
POSTGRES_PRISMA_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`,
72+
};
73+
}
74+
75+
export function syncNeonEnvVars(options?: {
76+
projectId?: string;
77+
neonAccessToken?: string;
78+
branch?: string;
79+
databaseName?: string;
80+
roleName?: string;
81+
envVarPrefix?: string;
82+
}): BuildExtension {
83+
const sync = syncEnvVars(async (ctx) => {
84+
const projectId =
85+
options?.projectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID;
86+
const neonAccessToken =
87+
options?.neonAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN;
88+
const branch = options?.branch ?? ctx.branch;
89+
const envVarPrefix = options?.envVarPrefix ?? "";
90+
const outputEnvVars = NEON_ENV_VARS;
91+
92+
// Skip the whole process for Vercel environments
93+
if (ctx.env.VERCEL) {
94+
return [];
95+
}
96+
97+
if (!projectId) {
98+
throw new Error(
99+
"syncNeonEnvVars: you did not pass in a projectId or set the NEON_PROJECT_ID env var."
100+
);
101+
}
102+
103+
if (!neonAccessToken) {
104+
throw new Error(
105+
"syncNeonEnvVars: you did not pass in an neonAccessToken or set the NEON_ACCESS_TOKEN env var."
106+
);
107+
}
108+
109+
// Skip branch-specific logic for production environment
110+
if (ctx.environment === "prod") {
111+
return [];
112+
}
113+
114+
if (!branch) {
115+
throw new Error(
116+
"syncNeonEnvVars: you did not pass in a branch and no branch was detected from context."
117+
);
118+
}
119+
120+
const environmentMap = {
121+
prod: "production",
122+
staging: "preview",
123+
dev: "development",
124+
preview: "preview",
125+
} as const;
126+
127+
const environment = environmentMap[ctx.environment as keyof typeof environmentMap];
128+
129+
if (!environment) {
130+
throw new Error(
131+
`Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', 'dev', or 'preview'.`
132+
);
133+
}
134+
135+
try {
136+
// Step 1: Search for the branch in Neon
137+
const branchSearchParams = new URLSearchParams({ search: branch });
138+
const branchesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches?${branchSearchParams}`;
139+
const branchesResponse = await fetch(branchesUrl, {
140+
headers: {
141+
Authorization: `Bearer ${neonAccessToken}`,
142+
},
143+
});
144+
145+
if (!branchesResponse.ok) {
146+
throw new Error(`Failed to fetch Neon branches: ${branchesResponse.status}`);
147+
}
148+
149+
const branchesData = await branchesResponse.json();
150+
const branches: NeonBranch[] = branchesData.branches || [];
151+
152+
if (branches.length === 0) {
153+
// No matching branch found
154+
return [];
155+
}
156+
157+
// Neon branch names are prefixed with environment (e.g., "preview/branch-name")
158+
const expectedBranchName = `${environment}/${branch}`;
159+
const matchingBranch = branches.find(
160+
(b) => b.name === expectedBranchName || b.name === branch
161+
);
162+
163+
if (!matchingBranch) {
164+
// No exact match found
165+
return [];
166+
}
167+
168+
const neonBranchId = matchingBranch.id;
169+
170+
// Step 2: Get endpoints for the branch
171+
const endpointsUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/endpoints`;
172+
const endpointsResponse = await fetch(endpointsUrl, {
173+
headers: {
174+
Authorization: `Bearer ${neonAccessToken}`,
175+
},
176+
});
177+
178+
if (!endpointsResponse.ok) {
179+
throw new Error(`Failed to fetch Neon branch endpoints: ${endpointsResponse.status}`);
180+
}
181+
182+
const endpointsData = await endpointsResponse.json();
183+
const endpoints: NeonEndpoint[] = endpointsData.endpoints || [];
184+
185+
if (endpoints.length === 0) {
186+
return [];
187+
}
188+
189+
// Find an endpoint with type containing 'write', or take the first one
190+
const writeEndpoint = endpoints.find((ep) => ep.type.includes("write"));
191+
const endpoint = writeEndpoint || endpoints[0];
192+
193+
if (!endpoint) {
194+
return [];
195+
}
196+
197+
// Step 3: Get databases for the branch
198+
const databasesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/databases`;
199+
const databasesResponse = await fetch(databasesUrl, {
200+
headers: {
201+
Authorization: `Bearer ${neonAccessToken}`,
202+
},
203+
});
204+
205+
if (!databasesResponse.ok) {
206+
throw new Error(`Failed to fetch Neon branch databases: ${databasesResponse.status}`);
207+
}
208+
209+
const databasesData = await databasesResponse.json();
210+
const databases: NeonDatabase[] = databasesData.databases || [];
211+
212+
if (databases.length === 0) {
213+
return [];
214+
}
215+
216+
// Find the specified database or use the first one
217+
const targetDatabase = options?.databaseName
218+
? databases.find((db) => db.name === options.databaseName)
219+
: databases[0];
220+
221+
if (!targetDatabase) {
222+
throw new Error(
223+
`syncNeonEnvVars: Database '${options?.databaseName}' not found in branch.`
224+
);
225+
}
226+
227+
// Step 4: Get the role (user) and password
228+
const targetRoleName = options?.roleName ?? targetDatabase.owner_name;
229+
const rolePasswordUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/roles/${targetRoleName}/reveal_password`;
230+
const rolePasswordResponse = await fetch(rolePasswordUrl, {
231+
headers: {
232+
Authorization: `Bearer ${neonAccessToken}`,
233+
},
234+
});
235+
236+
if (!rolePasswordResponse.ok) {
237+
throw new Error(
238+
`Failed to fetch Neon role password: ${rolePasswordResponse.status}. Make sure the role '${targetRoleName}' exists and has a password.`
239+
);
240+
}
241+
242+
const rolePasswordData: NeonRole = await rolePasswordResponse.json();
243+
const password = rolePasswordData.password;
244+
245+
if (!password) {
246+
throw new Error(
247+
`syncNeonEnvVars: No password found for role '${targetRoleName}'. The role may not have a password set.`
248+
);
249+
}
250+
251+
// Step 5: Build new environment variables based on the endpoint host
252+
const newHost = endpoint.host;
253+
const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler.");
254+
255+
const envVarMappings = buildNeonEnvVarMappings({
256+
user: targetRoleName,
257+
password,
258+
database: targetDatabase.name,
259+
host: newHost,
260+
poolerHost,
261+
});
262+
263+
// Build output env vars
264+
const newEnvVars: EnvVar[] = [];
265+
266+
for (const neonEnvVar of outputEnvVars) {
267+
const prefixedKey = `${envVarPrefix}${neonEnvVar}`;
268+
if (envVarMappings[neonEnvVar]) {
269+
newEnvVars.push({
270+
name: prefixedKey,
271+
value: envVarMappings[neonEnvVar],
272+
});
273+
}
274+
}
275+
276+
return newEnvVars;
277+
} catch (error) {
278+
console.error("Error fetching Neon branch environment variables:", error);
279+
throw error;
280+
}
281+
});
282+
283+
return {
284+
name: "SyncNeonEnvVarsExtension",
285+
async onBuildComplete(context, manifest) {
286+
await sync.onBuildComplete?.(context, manifest);
287+
},
288+
};
289+
}

0 commit comments

Comments
 (0)