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) — 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,workers,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, andworkers/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.LegacyCliSettings: apps/cli's legacy shell's own equivalent ofCliSettings— same role, scoped to the legacy shell, and pending deletion once the legacy/next shells consolidate. Defined atapps/cli/src/legacy/config/legacy-cli-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 and MissingCliConfigValueError are
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 next/config), and legacy's
ProjectRefResolver (exported as LegacyProjectRefResolver, carrying the legacy shell's own
separate mandatory prefix) — 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:
loadCliConfigsaveCliConfigloadCliConfigFilefindCliProjectRootForfindCliProjectPathsForloadCliProjectEnvironmentForloadFunctionsManifest
loadCliConfig, saveCliConfig, and loadCliConfigFile share their name with the ./effect
program they wrap (only the return type changes, Effect to Promise). The other four don't:
findCliProjectRootFor/findCliProjectPathsFor/loadCliProjectEnvironmentFor add a For suffix
their ./effect counterparts (findCliProjectRoot, findCliProjectPaths,
loadCliProjectEnvironment) don't carry, and loadFunctionsManifest wraps ./effect's
inferFunctionsManifest under an unrelated verb. Settling this naming — the For suffix
convention, and the loadFunctionsManifest/inferFunctionsManifest divergence — is tracked in
CLI-2234.
There is no global, fully-resolved config snapshot. 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).
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.json
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 takes precedence over config.toml
when both files exist.
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.tomlorsupabase/config.jsonsupabase/.envsupabase/.env.local
Their intended roles are:
config.tomlorconfig.json: 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:- if both
supabase/config.jsonandsupabase/config.tomlexist, JSON wins - the returned
LoadedCliConfig.ignoredPathsreports the shadowed config file path - if only one config file exists, that file is loaded
- if no config file exists in the discovered project, loading returns
null
- if both
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.json, save back to JSON - otherwise if it already has
config.toml, save back to TOML - otherwise default new writes to
supabase/config.json - callers can still force a format explicitly via
SaveCliConfigOptions.format - after writing, it removes the sibling config file in the other format, if one exists, so a project never ends up with both 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) 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 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 Go's TRUE/FALSE/1/0/t/f/… spellings, 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).
A caller can also resolve env(NAME) references explicitly, after config is loaded. The package
exposes two helpers, from @supabase/config/effect:
resolveCliConfigValue(value, cliProjectEnv, configPath, options?)resolveCliConfigSubtree(value, cliProjectEnv, pathPrefix, options?)
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 goViperCompat flag switches the env(NAME) matcher from the default, strict
SCREAMING_SNAKE_CASE-only pattern to Go/viper's case-agnostic ^env\((.*)\)$ form; only the
Go-parity legacy shell sets it.
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.
The package still exports a MissingCliConfigValueError class, and apps/cli classifies it for
telemetry, but neither resolver raises it today: 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: the mergedCliProjectEnvironment, when a project was found
It is built by calling loadCliProjectEnvironment 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- otherwise
process.env
This allows project-scoped env files to influence CLI behavior while keeping CLI runtime settings
distinct from the CliConfig document.
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), checkout-local service-version overrides (local-versions.json), and, for ordinary non-Git folders, a workspace-identity marker (identity.json) — Git checkouts keep that identity in Git metadata instead and don't write that marker - the global
SUPABASE_HOMEdirectory, holding managed-stack metadata and runtime state, keyed by stack identity — the canonical local-project key relative to the enclosing Git checkout, plus workspace identity — not the config-discovered project root
Neither is part of @supabase/config's input.
Important distinction:
@supabase/configdiscovers committed config only fromsupabase/config.tomlorsupabase/config.json- 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,stack update, 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