Skip to content

@rpallas/outcrop-neon ​

A Neon Postgres branch per preview stack for services built with @rpallas/outcrop. Each preview deployment gets its own database branch created from a parent branch, the connection details land in a Secrets Manager secret owned by the stack, and the branch is deleted with the stack. Base environments (dev, prod, ...) import a shared connection secret instead, so the same code works everywhere.

sh
npm install @rpallas/outcrop-neon

Prerequisites ​

  • A Neon project. Note its project id (Project settings → General in the Neon console).

  • A Neon API key stored as the shared secret neon-api-key by the account baseline (@rpallas/outcrop-account, sharedSecrets: { secrets: { "neon-api-key": {} } }). The baseline generates a placeholder value; replace it out of band once:

    sh
    aws secretsmanager put-secret-value \
      --secret-id platform-dev-neon-api-key \
      --secret-string 'napi_...'

    Either a plain string or a JSON document { "apiKey": "napi_..." } is accepted.

  • For base environments, a shared secret neon-connection holding the connection details of the environment's own branch (the same JSON shape the construct writes, see below, or any shape your service expects). Previews never read this secret.

Usage ​

ts
import { PlatformFunction, PlatformStack } from "@rpallas/outcrop";
import { NeonBranch } from "@rpallas/outcrop-neon";

export class OrdersStack extends PlatformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const db = new NeonBranch(this, "Db", {
      projectId: "proud-lake-123456",
      parentBranch: "main", // default
      database: "neondb", // default
      role: "neondb_owner", // default
    });

    const api = new PlatformFunction(this, "Api", {
      entry: "src/api.ts",
      environment: { DB_SECRET_ARN: db.connectionSecret.secretArn },
    });
    db.grantRead(api);
  }
}

At runtime read the secret (for example with getSecretJson from @rpallas/outcrop-runtime) and use connectionString:

json
{
  "host": "ep-cool-river-123456.eu-central-1.aws.neon.tech",
  "pooledHost": "ep-cool-river-123456-pooler.eu-central-1.aws.neon.tech",
  "port": 5432,
  "database": "neondb",
  "user": "neondb_owner",
  "password": "...",
  "connectionString": "postgresql://neondb_owner:...@ep-cool-river-123456-pooler.eu-central-1.aws.neon.tech:5432/neondb?sslmode=require",
  "directConnectionString": "postgresql://...@ep-cool-river-123456.eu-central-1.aws.neon.tech:5432/neondb?sslmode=require",
  "pooledConnectionString": "postgresql://...@ep-cool-river-123456-pooler.eu-central-1.aws.neon.tech:5432/neondb?sslmode=require",
  "branchId": "br-new-000002",
  "projectId": "proud-lake-123456"
}

connectionString points at the connection pooler (right for Lambda) unless pooled: false. directConnectionString is always the direct endpoint, which is what migrations and anything that uses session-level features (advisory locks, LISTEN/NOTIFY, prepared statements) should use.

Props ​

PropDefaultNotes
projectIdrequiredNeon project id
apiKeySecretNameneon-api-keyShared secret name resolved through /platform/secrets/{name}/arn
apiKeySecret–Explicit ISecret instead of apiKeySecretName
parentBranchmainName or br-... id. main falls back to the project's default branch
branchName<previewId>-<service>-dbPlatform naming, kind generic, name db
databaseneondb
roleneondb_ownerRole whose password is stored
createInBaseEnvironmentfalseAlso create a branch (<service>-db) in non-preview stacks
baseConnectionSecretNameneon-connectionShared secret imported when no branch is created
pooledtrueconnectionString uses the pooler host
suspendTimeoutSecondsNeon defaultCompute auto-suspend timeout for the branch endpoint
apiBaseUrlhttps://console.neon.tech/api/v2

What the construct exposes ​

  • connectionSecret: ISecret – the stack's own secret in previews, the shared secret otherwise
  • isBranch: boolean – whether this deployment manages a branch
  • branchId, host, pooledHost – deploy-time tokens (undefined when no branch is created)
  • handler, resource – the custom resource pieces, for extra permissions or dependencies
  • grantRead(grantee) – read access to the connection secret
  • A stack output <Id>ConnectionSecretArn (for Db above: DbConnectionSecretArn) so CI can find the secret after a deploy

How previews map to branches ​

StackBranchSecret
Preview pr-42 of orderspr-42-orders-db created from mainpr-42-orders-neon-connection (created)
dev / prod (default)noneshared neon-connection (imported)
dev with createInBaseEnvironmentorders-db created from mainorders-neon-connection (created)

The custom resource:

  1. reads the API key, resolves the parent branch id and creates the branch with a read_write endpoint (POST /projects/{id}/branches), polling the returned operations until they finish;
  2. reads the role password (reveal_password, falling back to reset_password when revealing is disabled for the project) and writes the JSON above with PutSecretValue. The secret value never appears in a CloudFormation template or in the custom resource response;
  3. deletes the branch when the stack is destroyed (a branch that is already gone is not an error).

A Create that finds a branch with the same name adopts it instead of failing, so a retried deploy after a timeout converges. Changing branchName (or projectId) creates a new branch and returns a new physical id; CloudFormation then deletes the old branch. A branch's parent is immutable in Neon, so changing only parentBranch is ignored with a warning – rename the branch as well.

Cost notes ​

Neon bills compute time and storage. Preview branches share storage with their parent (copy-on-write) and their compute auto-suspends when idle, so an idle preview costs close to nothing. Use suspendTimeoutSeconds to suspend sooner, and make sure preview stacks are destroyed when pull requests close (the platform workflows do this) so branches do not accumulate. Every branch counts towards the project's branch limit.

Running migrations after a preview deploy ​

The deploy job exposes stack outputs; use the connection secret output to run migrations with the direct endpoint:

yaml
- name: Deploy preview
  id: deploy
  run: npx cdk deploy --require-approval never --outputs-file cdk-outputs.json

- name: Run migrations
  env:
    STACK_NAME: ${{ steps.deploy.outputs.stack-name }}
  run: |
    SECRET_ARN=$(jq -r ".[\"$STACK_NAME\"].DbConnectionSecretArn" cdk-outputs.json)
    export DATABASE_URL=$(aws secretsmanager get-secret-value --secret-id "$SECRET_ARN" \
      --query SecretString --output text | jq -r .directConnectionString)
    npm run migrate

The deploy role needs secretsmanager:GetSecretValue on the stack's secrets for this step.

Limitations ​

  • One branch per construct; a stack can create several by using different branchNames.
  • The handler requires outbound internet access to the Neon API. If the function runs inside a VPC it needs a NAT gateway or equivalent.
  • Only the password of role is stored. Additional roles or databases are not created.
  • parentBranch is resolved by name at create time; renaming the parent later has no effect.
  • Neon API rate limits apply; the handler retries nothing beyond polling operations, and a failed API call fails the deployment so CloudFormation can roll back.

Classes ​

NeonBranch ​

Defined in: neon-branch.ts:62

A Neon Postgres branch per preview stack. The branch is created from parentBranch by a custom resource, its connection details are written to a Secrets Manager secret owned by the stack and the branch is deleted with the stack. Base environments import the shared connection secret instead unless createInBaseEnvironment is set.

Extends ​

  • Construct

Constructors ​

Constructor ​
ts
new NeonBranch(
   scope, 
   id, 
   props
): NeonBranch;

Defined in: neon-branch.ts:86

Parameters ​
ParameterType
scopeConstruct
idstring
propsNeonBranchProps
Returns ​

NeonBranch

Overrides ​
ts
Construct.constructor

Properties ​

PropertyModifierTypeDescriptionDefined in
branchIdreadonlystring | undefinedNeon branch id (deploy-time token); undefined when no branch is created.neon-branch.ts:70
branchNamereadonlystring | undefinedEffective branch name (only when a branch is created).neon-branch.ts:78
connectionSecretreadonlyISecretSecret holding { host, pooledHost, port, database, user, password, connectionString, directConnectionString, pooledConnectionString, branchId, projectId }. connectionString uses the pooler host when pooled is true (the default).neon-branch.ts:68
handlerreadonlyPlatformFunction | undefinedCustom resource handler (only when a branch is created).neon-branch.ts:80
hostreadonlystring | undefinedEndpoint host of the branch (deploy-time token); undefined when no branch is created.neon-branch.ts:72
isBranchreadonlybooleanTrue when this construct manages a branch, false when it imports the shared secret.neon-branch.ts:76
pooledreadonlybooleanWhether connectionString consumers should use the pooler host.neon-branch.ts:84
pooledHostreadonlystring | undefinedPooler host of the branch (deploy-time token); undefined when no branch is created.neon-branch.ts:74
resourcereadonly| PlatformCustomResource<NeonBranchResourceProperties> | undefinedThe custom resource (only when a branch is created).neon-branch.ts:82

Methods ​

grantRead() ​
ts
grantRead(grantee): Grant;

Defined in: neon-branch.ts:174

Allow grantee to read the connection secret.

Parameters ​
ParameterType
granteeIGrantable
Returns ​

Grant

Interfaces ​

NeonBranchProps ​

Defined in: neon-branch.ts:19

Properties ​

PropertyModifierTypeDescriptionDefined in
apiBaseUrl?readonlystringNeon API base URL. Default https://console.neon.tech/api/v2.neon-branch.ts:52
apiKeySecret?readonlyISecretExplicit API key secret instead of apiKeySecretName.neon-branch.ts:28
apiKeySecretName?readonlystringName of the shared secret holding the Neon API key, published by the account baseline under /platform/secrets/{name}/arn. Default neon-api-key.neon-branch.ts:26
baseConnectionSecretName?readonlystringShared secret with the base environment connection details, used when no branch is created. Default neon-connection.neon-branch.ts:46
branchName?readonlystringBranch name. Default <previewId>-<service>-db (platform naming, kind generic, name db).neon-branch.ts:32
createInBaseEnvironment?readonlybooleanCreate a branch in non-preview stacks too. Default false: base environments import the shared baseConnectionSecretName secret instead.neon-branch.ts:41
database?readonlystringDatabase name inside the branch. Default neondb.neon-branch.ts:34
parentBranch?readonlystringBranch the preview branch is created from (name or br-... id). Default main.neon-branch.ts:30
pooled?readonlybooleanPrefer the connection pooler host in connectionString. Default true.neon-branch.ts:48
projectIdreadonlystringNeon project id (Project settings in the Neon console).neon-branch.ts:21
role?readonlystringRole whose password is stored in the connection secret. Default neondb_owner.neon-branch.ts:36
suspendTimeoutSeconds?readonlynumberCompute auto-suspend timeout for the branch endpoint, in seconds.neon-branch.ts:50

NeonBranchResourceData ​

Defined in: handlers/neon-branch.ts:30

Attributes returned to CloudFormation (Fn::GetAtt). Never contains credentials.

Extends ​

  • Record<string, unknown>

Indexable ​

ts
[key: string]: unknown

Properties ​

PropertyModifierTypeDefined in
BranchIdreadonlystringhandlers/neon-branch.ts:31
EndpointIdreadonlystringhandlers/neon-branch.ts:32
Hostreadonlystringhandlers/neon-branch.ts:33
PooledHostreadonlystringhandlers/neon-branch.ts:34

NeonBranchResourceProperties ​

Defined in: handlers/neon-branch.ts:12

Properties the NeonBranch construct passes to the custom resource. CloudFormation delivers every scalar as a string, so numbers are strings here.

Extends ​

  • Record<string, unknown>

Indexable ​

ts
[key: string]: unknown

Properties ​

PropertyModifierTypeDescriptionDefined in
apiBaseUrlreadonlystring-handlers/neon-branch.ts:23
apiKeySecretArnreadonlystringSecrets Manager ARN holding the Neon API key.handlers/neon-branch.ts:22
branchNamereadonlystring-handlers/neon-branch.ts:16
connectionSecretArnreadonlystringSecrets Manager ARN that receives the connection details.handlers/neon-branch.ts:20
databasereadonlystring-handlers/neon-branch.ts:17
parentBranchreadonlystringParent branch name or id (br-...).handlers/neon-branch.ts:15
pooled?readonlystring"true" when connectionString should use the pooler host.handlers/neon-branch.ts:25
projectIdreadonlystring-handlers/neon-branch.ts:13
rolereadonlystring-handlers/neon-branch.ts:18
suspendTimeoutSeconds?readonlystring-handlers/neon-branch.ts:26

NeonConnectionSecret ​

Defined in: handlers/neon-branch.ts:38

JSON document written to the connection secret.

Properties ​

PropertyModifierTypeDescriptionDefined in
branchIdreadonlystring-handlers/neon-branch.ts:49
connectionStringreadonlystringPooled or direct connection string depending on the construct's pooled option.handlers/neon-branch.ts:46
databasereadonlystring-handlers/neon-branch.ts:42
directConnectionStringreadonlystring-handlers/neon-branch.ts:47
hostreadonlystring-handlers/neon-branch.ts:39
passwordreadonlystring-handlers/neon-branch.ts:44
pooledConnectionStringreadonlystring-handlers/neon-branch.ts:48
pooledHostreadonlystring-handlers/neon-branch.ts:40
portreadonlynumber-handlers/neon-branch.ts:41
projectIdreadonlystring-handlers/neon-branch.ts:50
userreadonlystring-handlers/neon-branch.ts:43

Variables ​

NEON_DEFAULT_API_BASE_URL ​

ts
const NEON_DEFAULT_API_BASE_URL: "https://console.neon.tech/api/v2" = "https://console.neon.tech/api/v2";

Defined in: neon-branch.ts:15


NEON_DEFAULT_API_KEY_SECRET ​

ts
const NEON_DEFAULT_API_KEY_SECRET: "neon-api-key" = "neon-api-key";

Defined in: neon-branch.ts:16


NEON_DEFAULT_CONNECTION_SECRET ​

ts
const NEON_DEFAULT_CONNECTION_SECRET: "neon-connection" = "neon-connection";

Defined in: neon-branch.ts:17

Released under the MIT License.