This document explains how the CLI's on-disk config document loading works, across
@supabase/config and the CLI.
CliConfig: the persisted config-file document (supabase/config.toml,supabase/config.json, orsupabase/config.jsonc) — the full local superset, including local-only sections (studio, ports,edge_runtime,analytics, …) plus[remotes.*]overrides. Owned by@supabase/config.ProjectConfig: the hosted-project subset — the sections a hosted project manages (api,auth,db,realtime,storage,compute,experimental), produced bytoProjectConfigfrom either aCliConfigdocument or a Management API v2 project-config response (CLI-2230). Sparse by design: it carries only what its source actually said, so it composes with the subtraction core (subtractCliConfig/omitDefaultValues, operand typeEffectiveConfig) without fabricating drift from schema defaults. An API-sourced value may speak for fewer fields than the section list implies —realtimemaps no fields today, andcompute/experimentalhave no v2 project-config API counterpart at all — so a comparison consumer should restrict itself tocomparableProjectConfigPaths/isComparableProjectConfigPathrather than treating a section's presence in that list as a per-field guarantee. Owned by@supabase/config(packages/config/src/project-config/).CliSettings: the CLI's effective runtime settings bundle (platformapiUrl,dashboardUrl, access token, telemetry flags,supabaseHome,noKeyring,debug). Lives inapps/cli, not this package.CliProjectEnvironment: the merged env map for the active project (supabase/.env+.env.local+ ambientprocess.env).CliProjectPaths: the discovered filesystem paths for the active project.CliProjectContext: apps/cli's runtime bundle of discovered paths + merged env for the active project.CommandSettings: apps/cli's command-level counterpart toCliSettings— the per-invocation settings every command handler resolves (active profile,--workdir, project id, access token). It predatesCliSettings; the two are slated to consolidate. Defined atapps/cli/src/config/command-settings.service.ts.
The Cli* prefix is a rule, not a per-name coincidence: it names the local checkout side — what
the CLI reads, writes, or resolves about itself on disk. A bare Project* name is reserved for the
hosted Supabase project. Value-helpers follow the config family regardless of their inputs, not the
shape of whatever they're passed — resolveCliConfigValue is Cli*-named for this reason. See
ADR 0020 for the full decision record.
Within CliConfig itself, project_id is overloaded by position: the root-scope project_id is
a local identifier that defaults to the working directory name when running supabase init (see
packages/config/src/base.ts), while a [remotes.<label>].project_id is the hosted project's
ref — the value that binds that remote block to a specific persistent Supabase branch.
The Cli* prefix names the local checkout side — what the CLI reads, writes, or resolves about
itself on disk. A bare Project* name is reserved for the hosted Supabase project side. (Deliberate
exceptions live in apps/cli: services that describe the hosted project itself or the CLI's link to
it — ProjectLinkRemote, ProjectLinkState (both in shared/config), and
RemoteTarget (in apps/cli/src/config) — keep the bare Project* root under this same
rule.)
@supabase/config (.), @supabase/config/effect, and @supabase/config/io are the package's
three published entrypoints (see the package README's "Entrypoints" section for the full
contract). ./io is the one whose names diverge from the vocabulary above: it is a Promise-based
facade over ./effect's programs, for non-Effect consumers, and its seven exports (verified
against packages/config/src/node.ts/bun.ts) are:
loadCliConfigsaveCliConfigloadCliConfigFilefindCliProjectRootfindCliProjectPathsloadCliProjectEnvironmentinferFunctionsManifest
Every name here matches its ./effect counterpart one-to-one (only the return type changes,
Effect to Promise) — the subpath itself (/io vs /effect) is what conveys Promise-vs-Effect.
findCliProjectRootFor/findCliProjectPathsFor/loadCliProjectEnvironmentFor/
loadFunctionsManifest were the pre-CLI-2234 names: the first three carried a For suffix their
./effect counterparts didn't, and the fourth wrapped ./effect's inferFunctionsManifest under
an unrelated verb. CLI-2234 renamed all four to match.
The package itself has no global, fully-resolved resolved config. Most env(NAME) references
inside CliConfig are substituted automatically when the file is loaded (see "Raw Config Loading"
below). A narrow set of fields are deliberately left as literal env(NAME) strings through decode,
and are resolved by a caller later, on demand (see "Lazy env(NAME) Resolution" below). The CLI
builds its own per-command resolved config on top of the package stages (see "CLI value precedence").
Project discovery (findCliProjectPaths/findCliProjectRoot, exported from
@supabase/config/effect) starts from the current working directory and, by default, walks
upward until it finds the nearest ancestor containing one of:
supabase/config.tomlsupabase/config.jsonsupabase/config.jsonc
The first match wins. A caller that already holds an authoritative, pre-resolved root — for
example one derived from an explicit --workdir/SUPABASE_WORKDIR — can pass { search: false }
to check only that exact directory, with no ancestor climb.
Within one discovered supabase/ directory, config.json or config.jsonc takes precedence over
config.toml when both exist. Discovery never fails on a config.json + config.jsonc pair; loading
and saving do.
This produces a CliProjectPaths (the type is exported from @supabase/config):
projectRoot: the matched ancestor directorysupabaseDir:${projectRoot}/supabaseconfigPath: the selected config file pathenvPath:${supabaseDir}/.envenvLocalPath:${supabaseDir}/.env.local
Only the nearest matched supabase/ directory is used. The loader does not merge config or env
files from higher ancestors.
This discovery behavior is intentionally narrower than the CLI's broader project-state discovery
(see "CLI-owned Repo State" below). @supabase/config only cares about committed project config
and env files under supabase/.
The project-scoped files are:
supabase/config.toml,supabase/config.json, orsupabase/config.jsoncsupabase/.envsupabase/.env.local
Their intended roles are:
config.toml,config.json, orconfig.jsonc: the shared, committedCliConfigdocument.env: managed or shared project env values.env.local: user-editable local overrides
The CLI can run from any subdirectory inside a monorepo, but once a project is discovered, all
config and env loading is scoped to that project's supabase/ directory.
The loader never merges config from multiple ancestor projects. One discovered project root defines the full config/env scope for that invocation.
loadCliConfig() and loadCliConfigFile() (both exported from @supabase/config/effect) apply
these rules:
loadCliConfig(cwd)discovers the project, then:config.jsonandconfig.jsoncare both parsed as JSONC (comments and trailing commas allowed); parse errors report the line and column- if both
supabase/config.jsonandsupabase/config.jsoncexist, loading fails withCliConfigFileConflictErrornaming both files - if a JSON file and
supabase/config.tomlboth exist, JSON wins, and the returnedLoadedCliConfig.ignoredPathsreports the shadowedconfig.toml; the CLI warns that it is ignored - if only one config file exists, that file is loaded
- if no config file exists in the discovered project, loading returns
null
loadCliConfigFile(path)loads one explicit file path directly instead of discovering or choosing between sibling formats, so its ownignoredPathsis always empty
saveCliConfig() uses these rules:
- if the discovered project already has
config.jsonc, save back to it; otherwise, if it hasconfig.json, save back to that - otherwise if it already has
config.toml, save back to TOML - otherwise default new writes to
supabase/config.json - if both
config.jsonandconfig.jsoncexist, saving fails withCliConfigFileConflictError - callers can still force a format explicitly via
SaveCliConfigOptions.format - when the target is an existing JSON/JSONC file, the save applies minimal
jsonc-parseredits towards the regenerated document so comments and key order survive, and falls back to the regenerated text if the edited text does not re-parse to exactly that document; new files and format switches always write the regenerated text - a TOML save keeps the existing file's first-line
#:schema <url>directive - after writing, it removes every other config file among
config.json,config.jsoncandconfig.toml, so a project never ends up with more than one after a save
The saved file may also preserve a top-level "$schema" key as editor metadata: when a caller
doesn't pass an explicit schemaRef, saving reuses whatever $schema the project's existing
config file already had. That key does not participate in runtime config semantics.
loadCliProjectEnvironment() (exported from @supabase/config/effect) is the package's public env
loader. The CLI does not call it; it uses its own loader, described under "CLI value precedence".
The public loader loads project env in this order:
supabase/.envsupabase/.env.localprocess.envpassed in asbaseEnv
The resulting precedence is:
process.envwins over.env.local.env.localwins over.env.envprovides the lowest-priority project values
The loader returns a CliProjectEnvironment object (the type is exported from
@supabase/config) containing:
pathsvalues: the merged effective env maploadedPathssources: per-key provenance (.env,.env.local, orambient)
The ambient source label just means the value came from process.env.
CliConfig is loaded by parsing the TOML/JSON(C) file and decoding it against CliConfigSchema.
Before decoding, most string values matching env(NAME) are substituted automatically using the
resolved CliProjectEnvironment — this is required so numeric and boolean fields don't crash the
strict decoder when their TOML/JSON value is still a string. Substitution also coerces the
resulting string to the field's declared type: a numeric field is parsed as a number, a boolean
field accepts 1/t/T/TRUE/true/True and 0/f/F/FALSE/false/False, and a string-array field is split on
,. A missing or empty-string env var leaves the literal env(NAME) untouched rather than
substituting an empty value.
A narrow set of fields are exempt from that pre-decode substitution and keep the literal
env(NAME) string all the way through decode — currently only the per-function passthrough values
at functions.<name>.env.<VAR_NAME>. Those fields are resolved later, on demand, by a caller — see
"Lazy env(NAME) Resolution" below.
Schema defaults still provide true runtime defaults, and Effect schema filters still validate
cross-field feature contracts such as enabled => required sibling fields. Raw config loading
fails when a feature block is structurally invalid, but not just because a field still contains a
literal, unresolved env(NAME).
auth.email.enabled and auth.sms.enabled are the preferred spelling of the provider switch;
the decoded name stays enable_signup until v4. The loader folds the alias per section and per
layer (top level and each [remotes.*] block), before the [remotes.*] merge, and enabled wins
when both are set. Every legacy enable_signup present is reported in the load result's
deprecatedPaths, e.g. remotes.staging.auth.sms.enable_signup; the package prints nothing, and
the CLI prints a WARN: line on stderr. Saving a config writes enabled.
Callers that start with a loaded config and apply an in-memory override should validate the effective document before consuming it:
const loaded = yield * loadCliConfig(cwd);
const effective = applyCallerOverrides(loaded.config);
const config = yield * validateCliConfig(effective);
consumeConfig(config);validateCliConfig is exported from @supabase/config/effect. It reads only the value supplied
by the caller: it does not reread config files, read project or ambient environment variables, or
interpolate env(NAME) references. It returns an Effect with the native Effect Schema error
channel, so schema failures remain typed for the caller to handle.
Validation follows the loader's existing remote policy. The base document receives all schema
business-rule checks. Each [remotes.*] block still receives structural decoding, defaults, and
transformations, while its business-rule checks are disabled until a caller selects and merges
that remote into the effective config. An unselected remote may therefore be an incomplete but
structurally valid stub; structurally invalid remote values still fail validation.
A caller can also resolve env(NAME) references explicitly, after config is loaded. The package
exposes two helpers, under the same names from both . (plain, synchronous) and
@supabase/config/effect (Effect-typed; the Effect-typed variant wins when both are in scope via
@supabase/config/effect, since explicit named exports take precedence over a star re-export of
the same name). Neither has a failure mode: an unresolved env(NAME) reference is preserved
verbatim rather than rejected or thrown (see "Lazy env(NAME) Resolution" behavior below).
resolveCliConfigValue(value, cliProjectEnv, configPath)resolveCliConfigSubtree(value, cliProjectEnv, pathPrefix)
Resolution only applies to exact whole-string matches of the form:
env(NAME)It does not interpolate inside larger strings.
These helpers do two things at once:
- Substitute any string that is still a literal
env(NAME)reference — this only matters for the deferred fields from the previous section, since everything else was already substituted at load time — usingcliProjectEnv.values. Like the pre-decode substitution, a missing or empty-string env var leaves the literal untouched rather than failing. - Wrap every schema-secret-marked (
x-secret) leaf that resolved to a real value inRedacted<string>— except a leaf that is still an unresolved literalenv(NAME)reference, which passes through as a plain string so a caller can see the missing reference.
resolveCliConfigSubtree walks recursively through objects, arrays, and records, so it also
resolves and redacts leaves nested inside [remotes.*] blocks.
An optional cliCompat flag switches the env(NAME) matcher from the default, strict
SCREAMING_SNAKE_CASE-only pattern to the Supabase CLI's case-agnostic ^env\((.*)\)$ form; only
apps/cli sets it. The public resolveCliConfigValue/resolveCliConfigSubtree on
././effect take no options parameter at all (CLI-2234) — cliCompat is internal-only,
typed on InternalResolveCliConfigOptions, a package-internal type that is not itself exported.
@supabase/config/internal re-exports these same runtime functions re-typed to additionally
accept it; apps/cli imports from there instead.
Callers such as functions serve/functions dev, secrets set, and start call these resolvers
on the subtrees they actually need (e.g. auth, edge_runtime, functions), so dormant
config — like a disabled Twilio block whose auth_token is still env(TWILIO_AUTH_TOKEN) because
that variable was never set — never has to resolve at load time, and no caller pays for resolving
or redacting a subtree it doesn't use.
Neither resolver ever fails: an unresolved env(NAME) reference is returned as a plain string,
not a typed failure.
Secret sensitivity is derived from schema annotations: fields wrapped in the package's internal
secret() helper (e.g. auth.jwt_secret, edge_runtime.secrets.*) carry an x-secret
annotation.
Behavior:
- decoding never wraps a value in
Redacted— schema decode only ever produces plain strings, whether that string is a resolved secret or, for the deferred fields above, still a literalenv(NAME) CliProjectEnvironment.valuesstays a plain string map, for substitution and file IOresolveCliConfigValue/resolveCliConfigSubtreeare the only place secret-marked values get wrapped inRedacted<string>, and only once they've resolved to a real value
This keeps persisted config and merged env serializable, while still protecting resolved runtime values once a caller pulls them out for use.
All CliConfig keys are optional at input time.
Decoding {} produces the full effective config using schema defaults (getDefaultCliConfig(), a
memoized decode of {}). Saving strips values that are equal to those same defaults — function
config subtracts its own entry-level defaults separately, since the default config's own
functions map is empty — so a saved file stays sparse instead of expanding into a
fully-populated template.
That gives the config system two properties at once:
- legacy default compatibility
- minimal input: a file containing only
project_id = "..."is validCliConfiginput - stable round-tripping: decoding a saved file and re-encoding it reproduces the same sparse output, since both sides derive from the same schema defaults
@supabase/config also exposes subtractCliConfig()/omitDefaultValues() as a standalone, pure
API over this same subtraction, for comparing two effective configs (e.g. a project's local
config against its Management-API-reported effective config) without hand-rolling default
stripping.
The CLI builds runtime state in two layers:
CliProjectContextCliSettings
CliProjectContext is the CLI's discovered-project runtime bundle. It contains:
paths: the discoveredCliProjectPaths, when a project was foundprojectEnv:{ values }, the project env files merged into one map (shell values excluded), when a project was found
It is built by loadCliProjectEnvFiles (see "CLI value precedence") for the nearest discovered
project from cwd. If no supabase/config.* exists, both fields stay absent — CliProjectContext
does not invent a project from .supabase/ alone.
CliSettings contains effective CLI runtime settings such as:
apiUrl,dashboardUrl,projectHost— the platform endpointsaccessTokensupabaseHomenoKeyringdebugand telemetry flags (telemetryDebug,telemetryDisabled,telemetryPosthogHost,telemetryPosthogKey,doNotTrack)
Its values are derived from:
CliProjectContext.projectEnv.values, when a project exists- then the ambient shell environment
projectEnv.values never contains a key the shell sets, so the shell still wins.
This allows project-scoped env files to influence CLI behavior while keeping CLI runtime settings
distinct from the CliConfig document.
The CLI resolves every config value, whether from CliConfig or from a SUPABASE_* variable, with
one order (ADR 0032):
- an explicit flag
- shell environment
- project
.env*files - config:
config.jsonorconfig.jsoncwhen present, elseconfig.toml, with the matched[remotes.*]block over the base document - the default
Tiers are named flag, shell, projectEnv, config and default. pickCliConfigKey
(apps/cli/src/config/cli-config-key.ts) is the single implementation; commands read through the
CliConfigValues service rather than calling it.
These are different mechanisms and both apply:
env(NAME)inside a config value is substituted from the merged environment (shell over project files) when the document is decoded. An empty variable leaves the literal untouched.- A
SUPABASE_<UPPER_SNAKE_PATH>variable overrides the key at the env tier. An empty variable is ignored and falls through to config. A value of the formenv(NAME)in the variable expands once. A shell variable, even an empty one, shadows the same name in a project.envfile.SUPABASE_AUTH_EMAIL_ENABLEDandSUPABASE_AUTH_SMS_ENABLEDare the exception: they setenable_signupin their section and beat..._ENABLE_SIGNUPat the same tier.
An override is parsed with a codec derived from the schema type, so an unparsable value fails the command that loads config instead of falling back to the file.
An env override for a key inside an optional section applies only when that section exists in the
merged document: webhooks, storage.image_transformation, db.ssl_enforcement, auth.captcha,
auth.email.smtp, hooks, SMS providers, passkey, WebAuthn and external providers other than
Apple. auth.sessions, db.settings and experimental.pgdelta are exempt; pgdelta.enabled
defaults to true, so its env rollback applies while the section is absent.
[remotes.<name>] is selected when its effective project_id equals the target project ref. The
effective value is SUPABASE_REMOTES_<UPPER(NAME)>_PROJECT_ID when set and non-empty, else the
TOML value. Duplicate effective ids and invalid ids fail every load, whether or not a remote
matches. A matched block that does not declare db.seed.enabled seeds nothing; a flag or env value
still wins.
The CLI's own loader (shared/config/cli-config-env.ts) reads SUPABASE_ENV (default
development) and then, in each of <workdir>/supabase and <workdir>, .env.<env>.local,
.env.local (skipped when the environment is test), .env.<env> and .env. The first writer
of a key wins, and a key the shell sets is never taken from a file. This differs from the public
loadCliProjectEnvironment described above.
CliConfigValues.load({ workdir, projectRef }) returns a resolved config memoised per workdir, project
ref and flag set; writeThrough drops the memo around a write. The resolved config exposes:
get(key): the value and its origin tier.loaded: the document with every flag, env and secret winner written in, before defaults.fileDeclared: what the config file alone declares, withenv()resolved and no flag orSUPABASE_*overlay.config pullcompares against it.materialized:loadedplus defaults and normalizers, withoriginAt(path).origins,appliedRemote,hasConfigFile,declares(path),familyNames(family),withheldEnv,dotenvPrivateKeys.invalid: values left out under thetolerateInvalidload option; empty otherwise.envValues(names): the non-empty value of each name, shell before project.env*, for resolvingenv(NAME)references.projectEnvValues: the raw project.env*record, for variables outside the registry such as Docker, registry mirrors andSUPABASE_NETWORK_ID.
Load-time warnings print once per runtime. Outside the foundation files the CLI never calls
loadCliConfig, resolveCliConfigSubtree or loadCliProjectEnvironment; a guard test enforces it.
auth.email.smtp.enabled defaults to off in the schema. The CLI treats a present
[auth.email.smtp] table that omits enabled as on, so a partial table, for example one holding
only pass = "env(SMTP_PASS)", still loads.
A config that declares a removed key fails to load with CliConfigRemovedKeyError, which names
every removed key in the file, [remotes.*] blocks included, with its replacement:
| Removed | Replacement |
|---|---|
[inbucket] |
[local_smtp] |
[auth.external.linkedin], [auth.external.slack] |
[auth.external.linkedin_oidc], [auth.external.slack_oidc] |
experimental.orioledb_version with a value |
db.orioledb_version |
A remote's keys are reported with their remotes.<name>. prefix. An empty
experimental.orioledb_version = "", which older supabase init templates wrote, is ignored: it is
dropped from rawDocument before decoding, so it never appears as a value, origin or difference.
The published JSON Schema accepts only that empty value, so editors agree with the loader.
The CLI also rejects a non-empty SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION environment variable; use
SUPABASE_DB_ORIOLEDB_VERSION.
@supabase/config/internal exposes the stages the CLI composes in place of loadCliConfig:
parseCliConfigDocumentFilereadsconfig.json,config.jsoncorconfig.tomlwithout decoding, and fails withCliConfigRemovedKeyErrorwhen the document uses a removed key, orCliConfigFileConflictErrorwhen both JSON files exist.mergeParsedCliConfigapplies the remote chosen by theselectRemotecallback.- The CLI overlays flag, env and default values onto the merged document.
decodeMergedCliConfigexpandsenv()references, then decodes and validates the overlaid document.
The result is the materialized config on the resolved config; get(key) also reports the tier a value
came from.
The CLI also keeps machine-local project state outside @supabase/config's scope, in two places
(see apps/cli/docs/supabase-home.md for the full layout):
- a repo-local
.supabase/directory, sibling tosupabase/, holding checkout-specific caches: linked remote project metadata (project.json) and checkout-local service-version overrides (local-versions.json). It does not hold stack identity markers - the global
SUPABASE_HOMEdirectory, holding managed-stack metadata and runtime state, keyed by the canonical project root, the full Git branch ref (ordetached/ordinary-workspace), and the stack name
Neither is part of @supabase/config's input.
Important distinction:
@supabase/configdiscovers committed config only fromsupabase/config.toml,supabase/config.jsonorsupabase/config.jsonc- the CLI may still resolve a project root from the nearest
.supabase/project.jsonwhen it needs to find local machine state for commands likelink,unlink,start,stop,status,stack list, orlogs
In other words, .supabase/ broadens the CLI's own project-state discovery, but it does not
broaden config loading semantics in @supabase/config.
CliConfig should contain committed project intent:
- local stack settings
- auth, db, studio, storage, and function config
- shared dev workflow settings that belong to the repo
CliSettings should contain runtime CLI settings that are not part of the committed project
contract:
- access token
- the platform endpoints
supabaseHomeand keyring behavior- debug and telemetry flags
The important rule is semantic overlap, not storage overlap. A value does not belong in
CliConfig just because it can be sourced from env.
For example:
CliSettings.apiUrlis the Supabase platform Management API base URLCliConfig.studio.api_urlis the local API URL used by Studio
Those are different meanings and should remain separate.
ProjectConfig — the hosted-project subset (CLI-2230) — sits alongside these two: it converges
the same committed-intent fields from either a CliConfig document or a Management API response,
without the local-only sections (studio, ports, edge_runtime, analytics, [remotes.*], …)
that only make sense for a local checkout. See the Vocabulary entry above for its sparse
semantics and comparison contract.
The system still tracks value provenance for:
- precedence
- diagnostics (
CliProjectEnvironment.sources) - env file writes
But process.env is treated as infrastructure input, not as an application-level service or
domain abstraction.
So the public architecture intentionally stays at:
CliConfigCliProjectEnvironmentCliProjectPathsCliProjectContextCliSettingsProjectConfig