neon
v3.2.0
Published
CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres
Readme
Neon CLI
The neon package is a command-line interface that lets you manage Neon — Lakebase Postgres, Object Storage, Functions, Managed Better Auth, and the AI Gateway — directly from the terminal. For the complete documentation, see Neon CLI.
The legacy neonctl package is a lightweight compatibility package that depends
on this package and invokes the same CLI entry point. The implementation and
build artifacts live only here.
Install the Neon CLI
npm
npm i -g neonRequires Node.js 20.19 or higher.
Howebrew
brew install neonctlBinary (macOS, Linux, Windows)
Download a neon-<platform> binary from the releases page, which carries one asset per platform for each neon@<version> tag.
Upgrade
npm
npm update -g neonRequires Node.js 20.19 or higher.
Howebrew
brew upgrade neonctlBinary (macOS, Linux, Windows)
To upgrade a binary version, download the latest binary file, as described above, and replace your old binary with the new one.
Connect
Run the following command to authenticate a connection to Neon:
neon authThe auth command launches a browser window where you can authorize the Neon CLI to access your Neon account. Running a Neon CLI command without authenticating with neon auth automatically launches the browser authentication process.
Alternatively, you can authenticate a connection with a Neon API key using the --api-key option when running a Neon CLI command. For example, an API key is used with the following neon projects list command:
neon projects list --api-key <neon_api_key>For information about obtaining an Neon API key, see Authentication, in the Neon API Reference.
Project and branch creation
Choose the PostgreSQL version when creating a project:
neon projects create --name my-project --pg-version 18Supported versions are 14 through 19; version 19 is available only in
regions where it has been enabled.
Create a protected branch when it should not be modified or deleted by routine automation:
neon branches create --project-id <project-id> --name production --protectedEnable logical replication
Enable logical replication for every endpoint in an existing project:
neon projects update <project-id> --enable-logical-replicationThe CLI asks for confirmation because enabling logical replication suspends
active endpoints and cannot be undone. For non-interactive automation, pass
--yes explicitly:
neon projects update <project-id> --enable-logical-replication --yesConnect with psql
The psql command
neon psql [branch] opens a psql session against a branch. It builds the connection string for the branch and launches psql — a shortcut for neon connection-string --psql. See Neon CLI commands — psql for the full reference.
neon psql # default branch
neon psql main # a specific branch
neon psql main@2024-01-01T00:00:00Z # point-in-time (branch@timestamp or branch@lsn)
neon psql --pooled # use the pooled connectionArguments after -- are forwarded to psql:
neon psql main -- -c "SELECT version()"
neon psql main -- -f script.sql --csvOptions: --project-id, --role-name, --database-name, --pooled, --endpoint-type (read_only | read_write), --ssl, plus the global options.
The --psql flag
Several other commands accept a --psql flag that opens a psql session against the resolved endpoint:
neon connection-string --psql --project-id <id>
neon projects create --psql
neon branches create --psqlAny arguments after -- are forwarded to psql, for example:
neon cs --psql --project-id <id> -- -c "SELECT version()"
neon cs --psql --project-id <id> -- -f script.sql --csvEmbedded psql fallback
If the system has psql installed on $PATH, --psql continues to spawn the native binary — there is no behavior change for existing users.
If psql is not found on $PATH, neon now falls back to an embedded TypeScript implementation. There is nothing to install or configure; it ships with neon. This removes the "no psql binary" trap on machines (and CI runners) that don't have PostgreSQL client tools installed.
Automatic fallback is the intended path — there is normally no flag to set. The embedded implementation can also be force-selected (primarily for tests and CI, e.g. to exercise it even when a native psql is present):
--fallback— force the embedded implementation onconnection-string,projects create, andbranches create. Intentionally hidden from--help: it's a test/CI knob, not a user-facing option (the automatic fallback above is the supported behavior).NEONCTL_PSQL_FALLBACK=1— environment variable with the same effect as--fallback. Convenient for scripts and CI.
The embedded implementation is verified against a conformance suite that diffs its behavior against real PostgreSQL (14–18) and the upstream psql regression + TAP tests.
What works
REPL & scripting
- Interactive REPL with a hand-rolled VT100 line editor (no native bindings); vi and emacs edit modes (
VI_MODEpsql variable) - Persistent command history (
~/.psql_history, libreadline format) ~/.psqlrcautoload (including$PGSYSCONFDIR/psqlrcand version-suffixed variants)- Scripted modes:
-c "SQL",-f script.sql, and stdin;--single-transaction,ON_ERROR_STOP,ECHO,--echo-all SINGLELINE(-S),\timing,\watch(named flagsc=/i=/m=, unbounded continuous mode)
Backslash commands
- All output formats: aligned, unaligned, wrapped, csv, json, html, asciidoc, latex, latex-longtable, troff-ms (
\a \H \t \x \pset \f \C…) - All
\d*describe commands with full upstream parity (columns, indexes, foreign keys, triggers, view definitions, sequences, RLS, replica identity, partitions, tablespaces, access methods, inheritance, FDW, stats objects, publications, subscriptions, per-column FDW options, TOAST owner) \copyto/from file,PROGRAM,STDIN,STDOUT(incl. the\.EOF marker);\g/\gx/\gset/\gdesc/\gexecand\g | programpipes- Extended query + pipeline mode (
\bind,\bind_named,\startpipeline,\parse,\sendpipeline) \crosstabview,\lo_*large objects,\e/\edit(external editor),\s(history),\?/\hhelp,\if/\elif/\else/\endif,\set/\unset,\connect,\encoding(liveSET client_encoding),\!,\cd,\prompt(incl. no-echo-),\password- Tab completion (~88 rules incl. live
pg_settingsGUC lookup, deepALTERsub-actions,JOINclauses, windowOVER)
Connection & authentication
- libpq-equivalent lookup precedence: argv flags > URI >
PG*env vars >~/.pgpass>pg_service.conf> libpq defaults - SCRAM-SHA-256 / SCRAM-SHA-256-PLUS with
tls-server-end-pointchannel binding (channel_binding); MD5 and cleartext;require_auth - Multi-host failover & load balancing:
target_session_attrs(any / read-write / read-only / primary / standby / prefer-standby),load_balance_hosts, DNS fan-out,hostaddr - Unix-domain sockets (host beginning with
/); TCP keepalives (keepalives,keepalives_idle)
TLS
sslmodedisable → verify-full; client certs in PEM or DER viasslcert/sslkey(+sslpasswordfor encrypted keys, with the libpq group/world-readable-key check)- Trust config:
sslrootcert(incl.=systemwithSSL_CERT_FILE/SSL_CERT_DIR), default client-cert discovery (~/.postgresql/postgresql.{crt,key}),sslcertmode - CRL:
sslcrlandsslcrldir;ssl_min_protocol_version/ssl_max_protocol_version;sslsni - Direct-SSL negotiation (
sslnegotiation=direct, PostgreSQL 17+, via ALPN)
What's not supported
- GSSAPI / SSPI (
gssencmode, Kerberos/SSPI auth,requirepeer). GSS transport encryption needs a native Kerberos binding, which the embedded psql deliberately avoids (pure TypeScript, zero native dependencies — the same reason the line editor is hand-rolled).node-postgresdoesn't support it either, and Neon doesn't use it.gssencmode=disable/preferare accepted;gssencmode=requireis rejected with a clear error.requirepeeris parsed but a Unix-socket connection that sets it is refused (Node exposes no peer-credential API — it is not silently ignored). keepalives_interval/keepalives_count— Node's socket API exposes only keepalive enable + initial delay, so these are accepted but not applied.
Known limitations
- TLS cipher is runtime-dependent. The negotiated TLS 1.3 ciphersuite is chosen by the host runtime's TLS library from an offer byte-identical to libpq's. Under Node (OpenSSL) that is
TLS_AES_256_GCM_SHA384, matching vanilla psql; under Bun (BoringSSL) it isTLS_AES_128_GCM_SHA256. Both are TLS 1.3 AEAD suites with no practical security difference, and neither runtime exposes a client-side knob to steer the selection.
Configure autocompletion
The Neon CLI supports autocompletion, which you can configure in a few easy steps. See Neon CLI commands — completion for instructions.
Linking a project
neon link is a Vercel-style command that binds the current directory to a Neon project. It picks (or creates) an organization and a project and writes a .neon file ({ "orgId", "projectId", "branch" }) that subsequent commands run in this directory (or any sub-directory) pick up automatically.
link resolves what it can and verifies every identifier you pass before writing, so a .neon is never left half-written or pointing at something that doesn't exist:
- org is inferred from the project (so
--project-idalone is enough); it's omitted only when the project has no organization (personal account). - project is taken from
--project-id(or chosen interactively / via--agent). - branch is left to an explicit
neon checkout <branch>—linknever silently pins a project's default branch (that would make later commands quietly target, say, production). It only records a branch when you pass--branch, when one is already pinned for the same project (preserved), when you pick one in the interactive picker, or for a freshly created project (whose single branch is unambiguous).
When a branch ends up pinned, link also runs env pull so the branch's Neon env vars (DATABASE_URL, …) land in a local .env. With no branch pinned there is nothing to pull, so link instead nudges you to run neon checkout. Pass --no-env-pull to skip the pull (for example when injecting env at runtime with neon-env run or neon dev).
Migrating from
set-context?set-contextis deprecated in favor oflink(see below). It still works exactly as before for now (a raw write), it just prints a deprecation warning. The.neonbranchIdfield is also superseded bybranch(which stores the branch name when known); oldbranchIdfiles are still read and are upgraded tobranchthe next timelink/checkoutwrites the context.
There are three modes:
Interactive (default) — guided prompts for humans:
$ neon link
? Which organization would you like to link? › Personal Org (org-abc123)
? Which project would you like to link? › + Create new project…
? Name for the new project: › my-app
? Which region should the new project run in? › AWS US East (Ohio) (aws-us-east-2)
Created project polished-snowflake-12345678 ("my-app") in aws-us-east-2.
Linked .neon:
orgId: org-abc123
projectId: polished-snowflake-12345678
branch: mainWhen you link an existing project that has more than one branch, the interactive flow adds a
final step to pick which branch to pin — the same + Create a new branch… + list selector used by
neon checkout (a single-branch project is pinned automatically, no prompt). Non-interactive
link --project-id … does not prompt or default a branch; it links org + project and leaves
branch selection to neon checkout:
? Which organization would you like to link? › Personal Org (org-abc123)
? Which project would you like to link? › my-app (polished-snowflake-12345678)
? Which branch would you like to link? › [default] main (br-main-branch-87654321)Non-interactive (flags or --params JSON) — for scripts and CI:
# Link to an existing project (org is inferred from the project; no branch pinned)
neon link --project-id polished-snowflake-12345678
# Same, but also pin a branch (name or id — resolved and stored as its name)
neon link --project-id polished-snowflake-12345678 --branch main
# Pin/switch the branch in the already-linked project
neon link --branch main # alias: --branch-id
# Create a new project and link it (pins the new project's default branch)
neon link --org-id org-abc123 --project-name my-app --region-id aws-us-east-2
# Same payload, one JSON blob
neon link --params '{"orgId":"org-abc123","projectName":"my-app","regionId":"aws-us-east-2"}'
# Record just the default org (preserves any existing project/branch)
neon link --org-id org-abc123
# Forget the current context
neon link --clear
# Offline write — no API calls, no verification (see --no-checks below)
neon link --no-checks --org-id org-abc123 --project-id polished-snowflake-12345678Every supplied identifier is checked before anything is written, with actionable errors — e.g. Project '…' not found, You don't have access to project '…', Organization '…' not found, or your API key doesn't have access to it, Project '…' belongs to organization 'A', not 'B', or Branch '…' not found in project '…'. Available branches: ….
Agent mode (--agent) — a JSON state machine designed for AI coding assistants. Each invocation returns a single JSON object with a status discriminator describing the next step, the available options, and the exact follow-up command to run.
$ neon link --agent
{
"status": "needs_org",
"instruction": "Ask the user which of these 2 organizations they want to link the current directory to. After they pick one, re-run the next_command_template with the chosen --org-id value.",
"options": [
{ "id": "org-abc123", "name": "Personal Org" },
{ "id": "org-team", "name": "Team Org" }
],
"next_command_template": "neon link --agent --org-id <org_id>"
}
$ neon link --agent --org-id org-abc123
{
"status": "needs_project",
"instruction": "Ask the user whether to link to one of these 1 existing projects (use next_command_template with --project-id) or create a new project (use create_option.next_command_template).",
"options": [
{ "id": "polished-snowflake-12345678", "name": "my-app" }
],
"create_option": {
"instruction": "To create a new project, ask the user for a project name. The region can be omitted to receive a follow-up needs_project_details response that lists available regions.",
"next_command_template": "neon link --agent --org-id org-abc123 --project-name <name> --region-id <region_id>"
},
"next_command_template": "neon link --agent --org-id org-abc123 --project-id <project_id>"
}
$ neon link --agent --org-id org-abc123 --project-id polished-snowflake-12345678
{
"status": "linked",
"context_file": "/path/to/cwd/.neon",
"context": {
"orgId": "org-abc123",
"projectId": "polished-snowflake-12345678"
},
"project": { "id": "polished-snowflake-12345678" },
"message": "Linked /path/to/cwd/.neon to project polished-snowflake-12345678 (org org-abc123). No branch pinned — run `neon checkout <branch>` (omit the branch to list options) to pin one and pull its env vars."
}The linked response omits branch unless one was pinned (via --branch, an existing pin, or project creation); pass --branch <name|id> to include it. The agent flow also handles project creation: if the agent sends --project-name without --region-id, the next response is needs_project_details with the list of supported regions.
Organization-scoped API keys (those created at the organization level rather than the user level) cannot list user organizations or call the regions endpoint. link handles this transparently:
- If the API key is org-scoped and at least one project already exists in the org, the CLI auto-detects the
org_idfrom the first project. In interactive mode it prints an informational message; in--agentmode it skips straight toneeds_project. - If the API key is org-scoped and no projects exist yet,
--agentreturns aneeds_orgresponse withoptions: []and an instruction telling the user to find their org ID in the Neon Console. Interactive mode prints an error pointing to--org-id. - When the regions endpoint is not allowed,
linkfalls back to a built-in static region list.
Agent error contract: any unexpected failure in --agent mode is reported as JSON to stdout with exit code 1, so agents can always parse the response:
{
"status": "error",
"code": "CLIENT_ERROR",
"message": "user has no access to projects"
}Offline writes (--no-checks) — write the .neon with no API calls at all: no org inference, no existence/access verification, no env pull. Because nothing can be resolved offline, it requires both --org-id and --project-id (--branch optional, stored verbatim). Handy for scripted/CI setups or re-creating a .neon from values you already trust:
neon link --no-checks --org-id org-abc123 --project-id polished-snowflake-12345678 --branch mainset-context is deprecated
set-context is deprecated in favor of link and prints a deprecation warning (to stderr, so it never pollutes stdout or scripts). For backward compatibility its behavior is unchanged: it's still a raw, offline write of exactly the fields you pass (no org inference, no verification, no env pull), and bare set-context still clears the file. Nothing breaks today — but new work should use link, and set-context will be removed in a future major release.
How today's set-context uses map onto link:
| set-context (deprecated) | Recommended link equivalent |
| --------------------------------------- | ----------------------------------------------------------------------------- |
| neon set-context --project-id <id> | neon link --project-id <id> (infers org + verifies; branch via checkout) |
| neon set-context --org-id <id> | neon link --org-id <id> |
| neon set-context --branch-id <id> | neon link --branch <name\|id> |
| neon set-context (clear) | neon link --clear |
| a raw local write (no network) | neon link --no-checks --org-id <id> --project-id <id> |
The key difference: link resolves and verifies before writing (so you never get a half-written or stale .neon), whereas set-context writes whatever you give it verbatim. The closest like-for-like replacement for the old raw write is link --no-checks.
open
open launches the linked project's page in the Neon Console. It reads the closest .neon file without authenticating or calling the Neon API, so it works from any sub-directory of a linked project.
neon open
# Open a project without changing the linked context
neon open --project-id polished-snowflake-12345678A branch pinned in .neon does not change the destination. .neon stores the branch as a name, while the Console route requires its ID; resolving it would turn this local command into an authenticated API call.
checkout
checkout [id|name] pins a branch in the local context so subsequent commands target it — it's the focused companion to link for the common "switch the branch I'm working on" case (link resolves org + project; checkout pins the branch). It resolves the branch (by name or id) against the project, then heals the .neon file: it always (re)writes projectId, branch, and orgId (when the project has one), so a .neon that was missing fields or drifted ends up complete and consistent. The branch is stored as its name when known (matching link). When orgId isn't already known (from --org-id or the existing .neon), it's looked up from the project itself.
The branch argument is optional: run neon checkout with no branch in an interactive terminal to fetch the project's branches and pick one from a list. In a non-interactive context (CI or no TTY), a branch must be passed explicitly.
Branch id vs name is detected automatically (a br-… value is treated as an id):
- id — matched strictly by id. A non-existent id is a hard "not found" error (ids are server-assigned, so checkout never creates one).
- name — matched by name. If the name doesn't exist, in an interactive terminal
checkoutoffers to create it (equivalent toneon branch create --name <name>: branched from the project's default branch with a read-write compute), then checks it out. In a non-interactive context a missing name is the usual "not found" error.
The project is resolved through the standard neon chain, each entry winning over the next:
--project-id <id>flagprojectIdfrom the closest.neonfile (found by walking up from the current directory — see "Where.neonlives" below)- If still unresolved and the API key maps to exactly one project, that project is auto-detected (same behaviour as
branchesandconnection-string)
If none of those resolve a project, checkout prints a telling error explaining the chain above. In an interactive terminal it then offers to run neon link in the current folder so you can pick (or create) a project on the spot; once linked, it continues and pins the requested branch. In non-interactive contexts (CI or no TTY) it exits with a non-zero code and the same guidance instead of prompting.
The resolved branch is then written (by name) to the same .neon file link uses:
$ neon checkout main --project-id polished-snowflake-12345678
INFO: Checked out branch br-main-branch-87654321 on project polished-snowflake-12345678. Updated /path/to/cwd/.neon.
$ cat .neon
{
"orgId": "org-abc123",
"projectId": "polished-snowflake-12345678",
"branch": "main"
}After pinning the branch, checkout also runs env pull by default, so the branch's Neon env vars are written to your local .env and you can start building right away — the branch-first loop is just link + checkout. Pass --no-env-pull to skip it (for example when env is injected at runtime via neon-env run / neon dev, or to keep secrets out of the working tree). A pull failure never undoes the checkout: the branch stays pinned and the failure is surfaced as a warning pointing you at neon env pull (or neon deploy if a neon.ts-declared service is missing).
diff
diff [compare-branch] prints a git-style schema diff between the branch you're on and another branch — the top-level companion to checkout. It reads the branch pinned in .neon as the side under review (the +++ side) and compares it against the branch you name (the ---, reference side), so + lines are what your current branch adds on top of the reference:
# On feature/add-comments (pinned in .neon), see how it differs from main:
$ neon diff main
→ Comparing schema main → feature/add-comments
diff --neon database neondb
--- main (br-crimson-snow-12345678)
+++ feature/add-comments (br-dry-salad-87654321)
@@ -63,7 +64,8 @@
CREATE TABLE public.users (
id integer NOT NULL,
email text NOT NULL,
- created_at timestamp with time zone DEFAULT now()
+ created_at timestamp with time zone DEFAULT now(),
+ display_name text
);compare-branchis optional. Omit it to compare the current branch against its parent (neon diffanswers "what did I change since branching?"). It accepts a branch name orbr-…id.--branch, -b <name|id>overrides the side under review instead of reading.neon— e.g.neon diff main --branch feature/checkoutdiffs an explicit branch againstmain.--database, --db <name>limits the diff to one database; by default every database on the current branch is compared (each rendered as its owndiff --neon database <name>block). A database missing on the reference side shows as fully added.--output json|yamlemits a structured result per database ({ database, base_branch, compare_branch, has_changes, diff }) for scripting; the default renders the colorized git-style diff (respecting--no-colorand non-TTY pipes).
The human-readable summary line goes to stderr and the diff body to stdout, so neon diff main > changes.patch captures just the diff. When the schemas match, diff prints No schema differences … and writes nothing to stdout. For history-aware comparisons (a branch against its own past state at a timestamp or LSN), use branches schema-diff.
env pull
env pull writes the linked branch's Neon environment variables into a local dotenv file: an existing .env if you have one, otherwise .env.local (override with --file <path>). Only Neon-managed keys are written (see the table below); any other lines in the file are preserved. The branch comes from the closest .neon file, so no --branch is needed (pass --branch <id|name> to target another branch).
What gets pulled, in precedence order:
--serviceand/or--env, when you pass either — their union is the complete selection, ignoringneon.tsand unselected branch variables.--serviceadds a service's complete variable bundle;--envadds only the individual variables you name.neon.ts, when the working directory has one — the policy is the source of truth, same asneon devandneon deploy.- Everything the branch has otherwise — Postgres, Neon Auth, the Data API, and object storage read back from the branch, plus the AI Gateway. The gateway has no branch-level state to read back, so a bare
env pullasks for it rather than detecting it and may mint a branch credential. To leave it out, name only what you do want with--serviceand/or--env.
If the gateway can't be resolved, it is dropped with a warning and the rest of the pull still lands. Gateway variables already in your file for this branch are left alone — a pull that couldn't reach the gateway is no evidence the branch has stopped having one — while ones left over from a different branch are pruned like any other stale value.
# Refresh the linked branch's vars in place
neon env pull
# Pull a specific branch into a specific file
neon env pull --branch preview --file .env.preview
# Only the AI Gateway
neon env pull --service ai-gateway
# Repeat the flag or comma-separate; -s, --service and --services are all accepted
neon env pull -s postgres -s data-api
neon env pull -s postgres,auth
# Pull one exact variable; repeat -e or comma-separate for more
neon env pull -e DATABASE_URL
neon env pull -e DATABASE_URL,NEON_AUTH_BASE_URL
# The selectors compose as a union: all Auth vars plus DATABASE_URL
neon env pull -s auth -e DATABASE_URLEvery services flag in the CLI takes those three spellings, the same value syntax, and the same service names — see config init --services.
| --service | Variables |
| --- | --- |
| postgres | DATABASE_URL, DATABASE_URL_UNPOOLED |
| auth | NEON_AUTH_BASE_URL, NEON_AUTH_JWKS_URL |
| data-api | NEON_DATA_API_URL |
| object-storage | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, AWS_REGION |
| ai-gateway | NEON_AI_GATEWAY_TOKEN, NEON_AI_GATEWAY_BASE_URL |
-e, --env accepts any variable in the table plus NEON_BRANCH. It is case-sensitive and rejects unknown names rather than silently widening the pull. NEON_BRANCH is written by unscoped and service-scoped pulls because it is branch identity, not a service; an env-only pull writes it only when you select it.
--env never narrows --service: neon env pull -s postgres -e DATABASE_URL still pulls the complete Postgres bundle (DATABASE_URL, DATABASE_URL_UNPOOLED, and NEON_BRANCH). The two selectors always form a union.
A scoped pull is scoped in both directions. An unscoped env pull owns the Neon-named variables: pointing a directory at a branch without Neon Auth prunes the stale NEON_AUTH_* lines. --service narrows that to the services you named, while --env narrows it to the exact keys you named, so env pull -e DATABASE_URL never touches DATABASE_URL_UNPOOLED. (AWS_* is never pruned by any pull: those names collide with credentials you may set yourself, so env pull only ever writes them.)
A scoped pull also never revokes a credential. Where an unscoped pull revokes the credential it replaces, a scoped one leaves the old one live — it can't tell which other variables still use it. It says so when it happens; revoke it in the Neon Console if nothing does.
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be selected together. Neon issues them as one object-storage credential; pulling only one would pair a fresh half with whatever old half remains in the file.
Naming a service the branch does not have is an error, not an empty pull:
--service auth: branch br-snowy-frost-12345 has no Neon Auth integration, so there are no
auth env vars to pull. Provision it first (`neon deploy`, `neon config apply`, or the Neon
Console), or drop auth from --service.link, checkout, and config apply invoke env pull automatically (see above). Those bundled pulls follow rules 2 and 3 above without the implied AI Gateway: minting a credential for a service you never named isn't something a side effect of another command should do. Run neon env pull to get it.
If you'd rather not keep env vars on disk, inject them at runtime instead with neon-env run -- <your dev command> (from @neon/env) or neon dev, and pass --no-env-pull to link / checkout.
neon dev resolves the same set, by the same rules — including the AI Gateway on a branch with no neon.ts. A function running locally gets what the deployed runtime would inject into it, which is the whole point of dev; a handler that reads NEON_AI_GATEWAY_BASE_URL should not work in production and fail on your machine. dev writes nothing, but it does read your .env / .env.local to reuse the branch credential behind the AI Gateway and object storage. Without a file to read from it issues one on every start and leaves the last one live — it has nowhere to keep it, and so cannot name it to revoke it. It says so when it happens; run env pull (or just link / checkout) once and restarts reuse the credential instead.
Where .neon lives: link writes .neon into the current working directory by default. If an existing .neon is found in any parent directory, that file is reused — so commands run from a sub-directory of a linked project still pick up the project's context. To pin the location explicitly, pass --context-file <path>.
.gitignore scaffolding: when .neon is created for the first time, the CLI also makes sure a .gitignore sits alongside it listing .neon. If .gitignore doesn't exist it's created with a single .neon line; if it does exist, .neon is appended only when missing (no duplicates, your other entries are left alone). On subsequent updates to an existing .neon, .gitignore is left untouched — so if you deliberately un-ignore .neon (e.g. to commit shared context), the entry is not re-added on every command.
Config as code (config / deploy)
Describe a branch's desired state in a neon.ts policy and reconcile it from the CLI — the Neon equivalent of terraform status / plan / apply. A policy splits into a static existential set — top-level auth / dataApi toggles and the beta preview block (Functions, buckets, AI Gateway) that decide what exists — and a dynamic branch closure that tunes each branch (compute settings, TTL, protection, parent) based on the branch it's evaluated for (name, isDefault, …):
// neon.ts
import { defineConfig } from '@neon/config/v1';
export default defineConfig({
// Static: what exists on every branch (drives the typed env).
auth: true,
// Dynamic: per-branch tuning only — cannot add/remove services.
branch: (branch) => {
if (branch.isDefault) {
return { protected: true };
}
return { parent: 'main', ttl: '7d' };
},
});Getting a neon.ts (config init)
neon config init scaffolds the policy and installs @neon/config / @neon/env, so a project can go straight to plan / apply. It is purely local — no auth, no API calls. In an interactive terminal it asks which services the policy should declare:
? Which Neon services should neon.ts declare? (space to toggle, enter to confirm) ›
◯ Managed Better Auth
Authentication with users and sessions stored in Postgres.
◯ Functions
Long-running, without timeouts, and closer to your database.
◯ Object Storage
S3-compatible blob storage that branches with your projects.
◯ AI Gateway
All models, one API, one bill. Powered by Databricks. Not available on the Neon free plan.Selecting nothing is a valid answer: you get the starter policy, which is also what a non-interactive run (CI, no TTY) writes. Pass --services to skip the prompt anywhere:
# Pick interactively (TTY) or take the starter policy (CI)
neon config init
# Declare services with no prompt
neon config init --services auth,functions,object-storage,ai-gateway
# Repeat the flag instead, and shorten it — every services flag takes all three spellings
neon config init -s auth -s functions
# Explicitly ask for the bare starter policy
neon config init --services none
# Scaffold but print the install command instead of running it
neon config init --no-installObject storage is spelled object-storage here, matching env pull --service and the rest of the CLI. The old storage still works and warns; it will be removed.
Choosing Functions also writes the handler the policy points at, since source is only resolved when apply bundles it — a declared function with no file on disk fails at deploy:
// hello.ts
export default async function hello(): Promise<Response> {
return new Response('Hello from Neon Functions');
}An existing neon.ts (or hello.ts) is never overwritten.
Four sub-commands plus two top-level aliases drive it:
# Scaffold a neon.ts and install the config packages (local only)
neon config init
# Inspect the branch's live Neon state (read-only — never mutates)
neon config status
# `neon status` is an alias for `neon config status`
neon status
# Dry-run diff: show exactly what `apply` would change
neon config plan
# Reconcile the policy against the branch
neon config apply
# `neon deploy` is an alias for `neon config apply`
neon deployProject & branch resolution follows the same chain as the rest of the CLI, each entry winning over the next:
--project-id <id>flagprojectIdfrom the closest.neonfile (found by walking up from the current directory — see "Where.neonlives" above)- If still unresolved and the API key maps to exactly one project, that project is auto-detected
The branch is chosen with --branch <id|name>; without it the project's default branch is used. The policy itself is found by walking up from the current directory for a neon.ts, or pass --config <path> to point at one explicitly.
Apply-only flags (also available on deploy):
--update-existing— auto-confirm overriding existing remote settings on the branch. Without it, drift on settings already present remotely (compute, TTL,protected) is reported as a conflict andapplymakes no changes until you resolve it or pass this flag.--allow-protected— auto-confirm applying to a branch Neon marks as protected. Without it,applyrefuses to touch a protected branch.
Output: status prints the project, branch, and reverse-engineered config. plan / apply render a git diff-style report (matching neon diff): service changes (Neon Auth, Data API, buckets, functions) list as green + additions, while branch setting changes (TTL, protected, compute) show grouped under a ~ <branch> header, one sorted field → value line each. A bare apply that hits drift on settings already present remotely prints those as a sorted before→after diff (current → desired, old in red / new in green) and exits non-zero until you pass --update-existing. Pass --output json (or --output yaml) to emit the full machine-readable result (PushResult) instead, for piping into other tools or CI.
config status --current-branch (alias neon status --current-branch) prints only the branch pinned in the local .neon file — no network, no auth, no analytics — and exits non-zero when none is pinned. This behavior lets it safely drive a shell prompt. Example starship segment:
[custom.neon]
description = "Current Neon database branch"
format = "[$symbol$output]($style) "
style = "bold green"
# `symbol` below uses a Nerd Font glyph; swap it for a plain
# label/emoji if you don't have a Nerd Font installed.
symbol = " "
command = "neon status --current-branch"
# Starship evaluates this on EVERY prompt render. To keep prompts instant
# everywhere outside a Neon project, do a zero-subprocess walk-up for an
# ancestor `.neon` first (the same walk the CLI does, stopping at $HOME and /).
# Only when one is found do we invoke the CLI, whose exit code is the real
# gate: non-zero (no branch pinned) hides the segment cleanly.
when = '''
d="$PWD"
while [ "$d" != "$HOME" ] && [ "$d" != / ]; do
if [ -e "$d/.neon" ]; then
neon status --current-branch >/dev/null 2>&1
exit $?
fi
d=$(dirname "$d")
done
exit 1
'''# CI gate: fail the build if the branch has drifted from the policy
neon config plan --project-id polished-snowflake-12345678 --output json
# Reconcile a feature branch, overriding any manual tweaks made in the console
neon deploy --branch my-feature --update-existingFunction deploys declared under preview.functions are bundled by neon's own esbuild helper and uploaded as part of apply, so the policy stays declarative and the packaged CLI never has to embed esbuild's native binary.
When a package cannot be bundled — a native addon with no esbuild loader, or an optional peer dependency a library references on an untaken code path — list it in that function's externalPackages and the bundler leaves the import alone. neon dev honours the same list. It does not make the package resolvable in the deployed archive (there is no node_modules next to the bundle), so it only unblocks an import that is never evaluated — a dependency the handler actually calls has to be bundled, and a natively-backed one cannot be. See @neon/config.
Scaffold a project (bootstrap)
neon bootstrap copies a Neon starter template into a new (or current) directory — conceptually like degit, but it only pulls from a small set of templates we maintain in the public neondatabase/examples repo. It requires no Neon login: it just downloads files from GitHub.
Pass a target directory (or . for the current one). In an interactive terminal you pick the template from a list; in CI / non-interactive contexts pass --template <id>.
# Pick a template interactively and scaffold it into ./my-app
$ neon bootstrap my-app
# Scaffold a specific template into the current directory (no prompts)
$ neon bootstrap . --template honoThe target directory must be empty unless you pass --force (a lone .git is ignored, so a freshly git inited folder is fine). Symlinks and executable bits in the template are preserved.
Set up a project for your coding agent (init)
neon init wires an existing project up to Neon: it signs you in, installs the Neon MCP server and agent skills into your editor, adds the Neon Local Connect extension for VS Code and Cursor, creates or picks a project, writes DATABASE_URL into .env, and offers to scaffold migrations.
$ neon initRun in a terminal it prompts you through those steps. This is what the retired neon-init package used to do; npx neon init replaces it.
Two side effects worth knowing before you run it. It installs or upgrades neonctl globally, with whichever package manager invoked it — the flow drives Neon by shelling out to the CLI rather than calling the API in-process. And it writes .neon in the project directory, the same context file neon link and neon checkout use.
Agent mode
--agent turns the same flow into a state machine an AI coding assistant drives. It prints one JSON object on stdout and nothing else: a phase response carrying a status and a nextAction telling the agent what to do next — usually another neon init invocation, spelled out as a command. The two read-only steps, status and finalize, return a snapshot instead.
$ neon init --agent --data '{"step":"status"}'
{
"auth": { "authenticated": true },
"tooling": { "mcpServer": { "configured": true, "scope": "global" }, "skills": { "installed": false, "scope": null } },
"project": { "databaseUrl": false },
"migrations": { "tool": "prisma", "hasMigrations": false },
"recommendations": [
{ "priority": "high", "message": "No DATABASE_URL found in .env", "command": "neon init --agent --data '{\"step\":\"db\"}'" },
{ "priority": "medium", "message": "Neon agent skills not detected in this project", "command": "neon init --agent --data '{\"step\":\"skills\",\"install\":true}'" },
{ "priority": "medium", "message": "prisma detected but no migrations found", "command": "neon init --agent --data '{\"step\":\"migrations\"}'" }
]
}--agent is implied when stdin is not a TTY and a known agent is detected from the environment (Claude Code, Codex, Cline, Cursor, VS Code, Windsurf).
--data takes a JSON object whose step selects the phase: auth, db, setup, getting-started, mcp, skills, migrations, neon-auth, status, or finalize. Remaining keys are that phase's options. Without --data, the orchestrator picks the next phase itself. An unrecognised step is refused with the full list.
Failures are JSON too, so an agent never has to distinguish "it broke" from "it returned nothing":
$ neon init --agent --data '{not json'
{
"success": false,
"error": "Invalid JSON in --data flag at position 1. Expected a JSON object."
}
$ echo $?
1That message reports where parsing stopped and nothing more. --data carries whatever you put in it, and the JSON parser's own message quotes a window of the input, so echoing either would put a connection string or an API key on stdout.
One exception to "JSON on stdout". Credentials are resolved before any command runs, so a failure in that step — an unknown --profile or NEON_PROFILE, --api-key and --profile together, or a credentials.json that cannot be read — prints ERROR: … on stderr, leaves stdout empty, and exits 1. Treat a non-zero exit with empty stdout as a credential problem and read stderr.
| Option | |
| --- | --- |
| --agent, -a | Emit the JSON state machine instead of prompting |
| --data <json> | Route to one phase, with that phase's options |
| --skip-migrations | Leave the migrations phase out of the flow |
| --preview | Enable preview features (scaffolding a project from a template) |
neon init refuses --profile; see Which credential an invocation uses.
Snapshots (snapshots)
neon snapshots (alias neon snapshot) manages snapshots — point-in-time backups of a branch that you can list, rename, expire, restore into a branch, or schedule automatically. Snapshots are a Beta Neon feature and were previously only available in the Console and REST API; this command group brings them to the CLI.
Every sub-command resolves the project through the standard chain (--project-id, then the .neon context file, then a single-project auto-detect). Branch-scoped sub-commands (create, schedule) default to the branch pinned in .neon, falling back to the project's default branch, and accept --branch <id|name>. The get, update, delete, and restore sub-commands take a snapshot id or name as their positional argument (an id wins; an ambiguous name errors and asks you to use the id).
# Snapshot the head of the current/default branch
neon snapshots create --name pre-migration
# Snapshot a specific branch at a point in time (RFC 3339 timestamp OR LSN — mutually exclusive)
neon snapshots create --branch main --timestamp 2025-01-01T00:00:00Z
neon snapshots create --branch main --lsn 0/1F3C8A0 --expires-at 2025-12-31T23:59:59Z
# List / inspect
neon snapshots list
neon snapshots get pre-migration
# Rename or change expiration (omit both to error; --expires-at and --clear-expiration conflict)
neon snapshots update snap-1234 --name nightly
neon snapshots update snap-1234 --expires-at 2030-01-01T00:00:00Z
neon snapshots update snap-1234 --clear-expiration # keep indefinitely
# Restore a snapshot to a NEW branch
neon snapshots restore snap-1234 --name recovered
# Restore ONTO an existing branch. Without --finalize the restore is left un-finalized
# so you can inspect it first, then swap it in:
neon snapshots restore snap-1234 --target-branch main
neon snapshots finalize br-restored-1234 # commit the swap
# …or do it in one step:
neon snapshots restore snap-1234 --target-branch main --finalize
# Delete
neon snapshots delete snap-1234
# Automatic snapshot (backup) schedule of a branch
neon snapshots schedule get --branch main
neon snapshots schedule set --branch main --frequency daily --hour 3 --retention 604800
neon snapshots schedule set --branch main --schedule '[{"frequency":"weekly","day":1,"hour":2},{"frequency":"daily","hour":3}]'All sub-commands honor the global options, including --output json|yaml|table.
Logs (logs)
neon logs reads the log records the services on a branch emit — Neon Functions, object storage, and Postgres computes. Logs require Neon Platform Beta and are currently available only for projects in aws-us-east-2.
Every sub-command resolves the project through the standard chain (--project-id, then the .neon context file, then a single-project auto-detect), and takes --branch <id|name>, defaulting to the project's default branch. logs query searches the previous hour by default; logs field-values searches the previous six hours. The maximum time window is seven days.
# The last 30 minutes on the default branch
neon logs query --since 30m
# Postgres compute errors on main, oldest first
neon logs query --branch main --source pg_endpoint --minimum-severity error --sort-order asc
# An explicit window (--start-time replaces --since; --end-time works with either)
neon logs query --start-time 2025-01-01T00:00:00Z --end-time 2025-01-01T01:00:00Z
# One request trace, across every service that took part in it
neon logs query --trace-id 4bf92f3577b34da6a3ce929d0e0e4736
# What the structured filters cannot express: a raw LogQL selection. It replaces
# them, but the window, --limit, --sort-order and --cursor still apply.
neon logs query --since 1h --logql '{entity_type="function"} |= "timeout"'
# Which fields this branch supports, and the values it has actually seen
neon logs fields
neon logs field-values service_name --since 6h --source functionA response holds at most --limit records (1–1000; default 100). When more matched, table output prints the --cursor to repeat the same query with; --output json|yaml returns is_truncated and next_cursor on the envelope instead, so nothing but the payload lands on stdout. Table output shows the common fields; use structured output for the complete records:
$ neon logs query --since 24h --output json
{
"logs": [
{
"timestamp": "2025-01-01T00:00:02.000Z",
"message": "GET /api/todos 200",
"source": "function",
"service_name": "api",
"severity_text": "INFO",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"attributes": { "http_status": 200 }
}
],
"next_cursor": "eyJvZmZzZXQiOjEwMH0",
"is_truncated": true
}Two combinations are rejected before the request: --since with --start-time, and --logql with any of --source, --service-name, --scope-name, --minimum-severity, --severity-text, --body-contains or --trace-id. --minimum-severity and --severity-text are independent filters and combine with AND. If Neon reports that --minimum-severity is unsupported, use --severity-text instead; neon logs field-values severity_text lists the exact values present on a branch.
--body-contains compares a case-sensitive substring against the rendered message. Structured bodies, including object storage records, are rendered as compact JSON, so match the JSON form (for example, "http_status":200).
Profiles
The CLI holds one Neon account by default. A profile adds another, and is nothing more than a pointer to a credentials file:
~/.config/neon/
├── credentials.json # this IS the DEFAULT profile
├── credentials.work.json # created by `neon profile create work`
└── profiles.json # created only once a second profile existsneon profile create work # a browser sign-in, or an API key — see below
neon profile list
neon profile remove work$ neon profile list
Profiles
┌────────┬─────────┬───────────────────────┬─────────┬────────────────┬──────┬────────────────────────┐
│ Active │ Name │ Account │ Auth │ Scope │ File │ Credentials │
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
│ * │ DEFAULT │ [email protected] │ oauth │ - │ ok │ credentials.json │
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
│ │ work │ [email protected] │ api key │ account │ ok │ credentials.work.json │
├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
│ │ ci │ org-old-flower-827148 │ api key │ project proj-1 │ ok │ credentials.ci.json │
└────────┴─────────┴───────────────────────┴─────────┴────────────────┴──────┴────────────────────────┘Scope is what a key can reach; an OAuth session has none of its own, so it shows -. File
says whether the credentials file can be read and understood — ok, invalid or missing —
which is not the same as the credential still working; only using it shows that. The table shows
the file name, --output json the full path.
Select one per invocation with --profile, or per shell with NEON_PROFILE. There is no profile use command and nothing is stored about which profile is "current", so what you type is always what runs.
Entries in profiles.json are paths, and a path may point anywhere — which is how you adopt a directory you already have, without moving or re-authenticating anything:
{
"version": 1,
"profiles": {
"DEFAULT": { "credentials": "credentials.json" },
"work": { "credentials": "../neonctl-work/credentials.json" }
}
}neon profile remove revokes what the profile holds — an OAuth refresh token at the
authorization server, or an API key this CLI minted — rather than only forgetting it locally. A
key you supplied is the exception and stays live, because nothing records its id; the command
says so. It asks for confirmation first, which --yes skips; without a terminal on stdin, in
CI or behind a pipe, it refuses rather than prompting into the void. It deletes the credentials
file only when the CLI created it: an adopted path like the one above is unlinked and left on
disk, and the command says so. Removing the last named profile deletes profiles.json,
returning you to the single-account layout. neon profile remove DEFAULT signs you out.
A profile holds either a sign-in or an API key
neon profile create makes a profile, and how you call it decides which kind of credential it holds. A key-backed profile is what you want for an agent, a shared machine, or anything that must never be interrupted by a browser:
neon profile create work # sign in with the browser, like `neon auth`
neon profile create work --api-key "$KEY" # store a key you already have
echo "$KEY" | neon profile create work --api-key - # or pipe it, keeping it out of argv
neon profile create ci --mint # sign in once, keep only a minted key
neon profile create ci --mint --org-id org-abc-123 # minted for an organization
neon profile create ci --mint --project-id proj-1 # minted for one project only
neon profile create work --force # replace it, revoking what it holds now
neon profile rotate-key work # mint a replacement, revoke the old one--force is not only a local edit: replacing a profile revokes the credential it held, so a key
this CLI minted stops working everywhere it was pasted, and an OAuth session is signed out.
Without --force, create refuses and names what would be revoked. To keep a working profile
and swap only its key, use rotate-key.
create and rotate-key print the profile they wrote, so an agent needn't follow up with
list. Under --output json that is a record, and it never carries the secret:
$ neon profile create ci --mint --org-id org-abc-123 --output json
{"name":"ci","account":"org-abc-123","auth":"api key","scope":"org org-abc-123","keyId":3239771,"credentials":"/home/me/.config/neon/credentials.ci.json"}One flag takes the key, because the shell already covers the variations: --api-key "$(cat
~/keys/work)" reads a file and --api-key "$KEY" takes a variable. Those put the key in the
process arguments, where ps and shell history can see it, so --api-key - reads it from stdin
instead — the usual convention for a piped value. --mint avoids the question entirely, because
the key never leaves the CLI.
A profile is one kind or the other, never both. type in the credentials file states which:
// oauth: what a plain `create` (or `neon auth --profile`) writes. An absent `type` means this.
{ "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
// api_key: what `--api-key` writes
{ "type": "api_key", "api_key": "napi_…", "user_id": "…" }
// api_key from `--mint --org-id`, which records the scope it was issued at
{ "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }Nothing is carried over when a profile is replaced, so one profile can never hold two credentials — or two different accounts. The secret stays in that file and never goes into profiles.json, so listing profiles cannot leak one. Both files are written owner-only through a temporary file and a rename, which also repairs the permissions of a file created too permissively.
--mint is the one to reach for. It signs you in through the browser once, mints a key with that session, stores only the key, and signs the session back out — so afterwards nothing about the profile can open a browser, and no half-forgotten login is left behind. --org-id and --project-id narrow what the minted key can reach, exactly as they do on neon api-keys create; a project-scoped key cannot create projects, mint keys, or read any other project.
Every key is verified against the API before it is stored, and the account it belongs to is recorded so profile list can show it. Only a real API key is accepted: an OAuth access token authenticates today and then expires with nothing to refresh it.
rotate-key mints at the scope the profile already has — replacing an org key with an account key would quietly widen everything it reaches — and stores the new key before revoking the old one, so a failed write leaves the old key working.
One thing it cannot do: an organization key cannot mint its own replacement. Neon only accepts a personal credential when creating organization keys, so rotating an org- or project-scoped profile means signing in again — neon profile create ci --mint --org-id org-abc-123 --force. rotate-key checks this before minting and says so, rather than letting the API answer with a rule you had no reason to expect.
Two things the CLI cannot do for a key you supplied rather than minted. It cannot revoke it, because GET /api_keys exposes no prefix and a stored secret cannot be matched to a listing entry, so both rotate-key and profile remove say the old key is still live and point you at neon api-keys list. For a key you supplied it records the organization the API reports, but cannot know whether that key was narrowed to a single project — so rotate-key will not suggest an organization-wide replacement without telling you to check neon api-keys list first.
If a stored key stops working there is nothing to refresh, so recovery is one browser sign-in: neon profile create work --mint --force.
Which credential an invocation uses
An explicit flag always beats an environment variable:
| Given | What runs |
| --- | --- |
| --api-key and --profile | neither — contradictory, so the command fails |
| --api-key and NEON_PROFILE | the flag's key |
| --profile and NEON_API_KEY | the profile |
| NEON_API_KEY and NEON_PROFILE | the key, and the ignored profile is named in a warning |
| --profile or NEON_PROFILE alone | that profile |
| nothing | DEFAULT |
Passing both flags fails rather than picking a winner: --api-key supplies a credential and --profile selects a stored one, so there is no reading of the command that makes both true.
When both are only environment variables the key wins, which keeps a CI pipeline that injects NEON_API_KEY working even if a NEON_PROFILE leaks into the environment — but the disregarded profile is named on stderr rather than passed over silently.
neon auth and the profile subcommands are outside all of this, because they read the same flags to mean something else: neon auth --profile work names where to write a credential, and neon profile create work --api-key … names one to store.
neon init does not support --profile yet. It runs its own auth flow, which reads the default credentials directly and re-invokes the CLI as a subprocess without passing a profile down, so passing the flag fails instead of quietly running as the default account. --api-key and NEON_API_KEY reach neon init no better and are not refused. The flow reads the stored credential and nothing else, so a supplied key is ignored: with a credential on disk neon init runs silently as that account, and with none it sends you to a browser sign-in. The silent case is the one to watch — it is the same failure --profile is refused for, without the refusal. Until that is fixed, sign in as the account you want first, or use another command.
API keys (api-keys)
neon api-keys list # your account's keys
neon api-keys list --org-id org-… # an organization's, with scope shown
neon api-keys create --name ci # account key
neon api-keys create --name ci --org-id org-… # organization key
neon api-keys create --name agent --project-id frosty-… # can access only that project
neon api-keys revoke <id> [--org-id org-…]The key is returned once, on create, and cannot be retrieved again. It prints on its own line below the table, so it can be selected in one gesture regardless of terminal width — and … | tail -1 on stdout yields exactly the key, since both notices go to stderr.
$ neon api-keys create --name agent --project-id proj-in-org
API key
┌─────┬───────┬─────────────┐
│ Id │ Name │ Project │
├─────┼───────┼─────────────┤
│ 303 │ agent │ proj-in-org │
└─────┴───────┴─────────────┘
napi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
WARNING: Store this key now: it is not shown again.
INFO: Limited to proj-in-org: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.--org-id and --project-id are mutually exclusive. A project-scoped key is an organization key, and its organization is looked up from the project rather than chosen separately. With neither flag you get an account key.
Project-scoped keys
A key created with --project-id bounds what it can reach to one project. Verified against a real scoped key:
| Attempt | Result |
| --- | --- |
| Read and write its own project | works |
| Read any other project | project not found — not even an existence oracle |
| neon projects create | project-scoped keys are not allowed to create projects |
| neon projects list | refused |
| neon api-keys create / list | refused (true of any organization key, not only scoped ones) |
| neon orgs list | works — it can see the id, name and handle of the organization it belongs to |
| Anything else about that organization (GET /organizations/{id}, members) | refused |
It is not read-only. Inside its one project it can do everything the API allows, including deleting branches and the project itself — neon deploy working at all is proof of that. What it bounds is reach, which is what lets you hand it to an agent or a CI job without handing over your account:
neon link --project-id frosty-… # once, as yourself — writes .neon
NEON_API_KEY=napi_… neon deploy # then the agent, reaching only that projectneon link needs --project-id explicitly when using a scoped key: the interactive picker lists your projects, which a scoped key cannot do.
api-keys deliberately ignores the .neon context file, unlike every other project command. Otherwise neon api-keys create --name ci inside a linked directory would silently mint a key scoped to that project instead of the account key you asked for. How far a credential reaches comes only from a flag you typed.
Seeing what is scoped
$ neon api-keys list --org-id org-7
API keys in org-7
┌─────┬──────────┬────────────────┬──────────────────────┬──────────────────────┬─────────────────────┐
│ Id │ Name │ Project │ Created At │ Last Used At │ Last Used From Addr │
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
│ 301 │ scoped │ proj-in-org │ 2026-01-02T00:00:00Z │ │ │
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
│ 302 │ org-wide │ (all projects) │ 2026-01-03T00:00:00Z │ 2026-02-03T00:00:00Z │ 203.0.113.9 │
└─────┴──────────┴────────────────┴──────────────────────┴──────────────────────┴─────────────────────┘last_used_at and last_used_from_addr are how you spot a key worth revoking.
Commands
| Command | Subcommands | Description |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
| auth | | Authenticate |
| profile | list, create, rotate-key, remove | Manage named sets of credentials |
| api-keys
