# Project configuration manifest

volcano-config.yaml is the declarative manifest for a project's user-facing configuration.

`volcano-config.yaml` is the declarative manifest for a project's user-facing
configuration. The CLI uploads it with `volcano cloud config deploy`, downloads
the current state with `volcano cloud config pull`, and the dashboard offers the
same download on the Projects page. Without `cloud`, both commands target local
development. All reconciliation happens server-side through
two endpoints:

- `GET /projects/{id}/config` — export current configuration (JSON, or
  canonical YAML with `Accept: application/yaml` / `?format=yaml`)
- `PUT /projects/{id}/config` — validate and apply a manifest
  (`?dry_run=true` reports projected actions without changing anything)

The manifest applies to the currently selected project (`volcano use <name>` /
`VOLCANO_PROJECT_ID`). Creating or selecting a project is not part of the
manifest.

[GitHub auto-deploy](https://docs.volcano.dev/platform/projects/git-deploy.md) reads the same file from your repository,
but applies only the `functions[]` settings that belong to the code a push
deploys: `visibility`, `invocation_mode`, `http_auth_mode`, `openapi_spec`,
`variable_scope`, and `variables`. Every other section still needs `volcano
cloud config deploy`.

## Full schema (version 1)

```yaml
version: 1

project:
  name: my-app # optional rename
  all_regions: false
  selected_regions: [us-east-1, us-west-2] # bare region names; subset requires SUPERAGENT

databases: # must already exist; assertion-only
  - name: appdb # required, matches deployed database
    region: us-east-1 # required; mismatch => error
    pg_version: '18' # required; mismatch => error
    database_type: volcano-db-xs # asserted; tier changes are NOT allowed via config

frontend_shared_variables: # complete list for shared frontends
  - NEXT_PUBLIC_API_URL

variables: # ALWAYS fully synced when declared
  - name: STRIPE_SECRET_KEY
    value: ${STRIPE_SECRET_KEY} # ${ENV} interpolated by the CLI
  - name: NEXT_PUBLIC_API_URL
    value: https://api.myapp.com

buckets: # must already exist (never created here)
  - name: avatars
    file_size_limit: 5242880 # bytes, optional
    allowed_mime_types: [image/png, image/jpeg]
    policies: # fully synced when declared; omit the key => policies untouched
      - name: public-read
        operation: SELECT # SELECT|INSERT|UPDATE|DELETE
        definition: 'true'

realtime:
  enabled: true
  broadcast_enabled: true
  presence_enabled: true
  postgres_changes_enabled: true

notifications: # omitted fields keep their value
  email:
    enabled: true
    recipients: [] # the project owner; no other address yet
  webhook:
    url: https://ops.myapp.com/hooks/volcano # "" removes it; the secret is never in the manifest
  scheduler_failures:
    notify_on: consecutive_failures # first_failure | consecutive_failures
    threshold: 3 # 1-100
    notify_on_recovery: true

auth:
  tokens:
    access_token_lifetime: 3600
    refresh_token_lifetime: 2592000
    refresh_token_reuse_interval: 10
    platform_token_ttl: 2592000
  sessions:
    inactivity_timeout: 0
    max_session_duration: 0
  signup:
    enable_signup: true
    enable_anonymous_signins: false
    allowed_email_domains: [] # empty = any domain may sign up
    allowed_email_domains_mode: signup # signup | signup_and_signin | disabled
  rate_limits: # per hour
    signup: 100
    signin: 100
    token_refresh: 1000
    password_reset: 10
  password:
    min_length: 15 # 15-128
    require_uppercase: true
    require_lowercase: true
    require_numbers: true
    require_special_chars: false
  password_reset:
    allow: true
    timeout: 3600
    max_history: 0
  email_verification:
    require_confirmation: true
    confirmation_timeout: 86400
  cors:
    enabled: true
    allowed_origins: [https://myapp.com, http://localhost:3000]
    allow_credentials: true
    max_age: 86400
    allow_frontend_previews: true # allow this project's live preview origins
  providers:
    email_password:
      enabled: true
    oauth: # fully synced when declared
      - provider: google # google|github|microsoft|apple|device
        enabled: true
        client_id: ${GOOGLE_CLIENT_ID}
        client_secret: ${GOOGLE_CLIENT_SECRET} # write-only
        redirect_url: https://api.myapp.com/auth/oauth/google/callback
        scopes: [openid, email, profile]
      - provider: device # client_id/secret server-generated
        enabled: true
  email:
    enabled: true
    from:
      address: no-reply@myapp.com
      name: My App
    smtp:
      host: smtp.sendgrid.net
      port: 587
      username: ${SMTP_USERNAME}
      password: ${SMTP_PASSWORD} # write-only
      use_tls: true
    templates: # fully synced when declared
      confirmation:
        subject: 'Confirm your email'
        html_body: '<p>Confirm: {{.Token}}</p>' # bodies require SUPERAGENT
        text_body: 'Confirm: {{.Token}}'
      password_reset:
        subject: 'Reset your password' # subject-only works on HOBBY
      password_changed:
        subject: 'Your password was changed'
      welcome:
        subject: 'Welcome'
        html_body: '<p>Welcome {{.Email}}</p>'
        text_body: 'Welcome'
  managed_pages:
    enabled: true
    redirects:
      allowed: [https://myapp.com/welcome, https://myapp.com/login]
      post_auth: https://myapp.com/welcome
      post_logout: https://myapp.com/goodbye
      device_verification: https://myapp.com/device
    pages: # SUPERAGENT; upsert-only (omission => untouched)
      login:
        html: '<html>...</html>' # required, <=256 KiB
        css: 'body{}' # optional, <=256 KiB
      reset_password: # maps to page type "reset-password"
        html: '<html>...</html>'
      signup: # also: forgot_password, device, verify_email
        html: '<html>...</html>'
    appearance: # SUPERAGENT; built-in renderer configuration
      theme: # shared by every managed auth page
        version: 1
        colors:
          background: '#f7f5f2'
          surface: '#ffffff'
          text: '#1a1a1a'
          accent: '#1d4ed8'
          accent_text: '#ffffff'
        font: humanist # system|humanist|geometric|slab|mono
        scale: large # small|default|large
        density: spacious # compact|comfortable|spacious
        radius: large # none|small|medium|large
      layouts: # centered|split-left|split-right
        login: split-left
        signup: split-right
        forgot_password: centered
        device: split-left
        verify_email: centered
        reset_password: split-right

functions: # must already be deployed
  - name: hello
    kind: standard # standard (default) or durable; fixed at creation
    visibility: public # private, authenticated, or public; omit to keep the current level
    invocation_mode: http # rpc (default) or http
    http_auth_mode: none # volcano (default) or none; none requires public
    openapi_spec: # optional OpenAPI 3.0/3.1 metadata; HTTP mode only
      openapi: 3.1.0
      info: { title: Hello webhook, version: 1.0.0 }
      paths: {}
    variable_scope: scoped # all (default) or scoped
    variables: # replaces the declared list when present
      - STRIPE_SECRET_KEY
    schedulers: # fully synced when declared
      - name: nightly
        cron: '0 3 * * *' # 5-field UTC cron
        enabled: true
        payload: { source: cron }

frontends: # must already be deployed
  - name: web
    variable_scope: shared # all (default), shared, or scoped
    function_routes: # fully synced when declared
      - function: hello # public standard HTTP-mode function
        path_prefix: /api # exact segment prefix; /api or /api/...
        strip_prefix: true # function receives / for /api
    frontend_routes: # fully synced when declared
      - frontend: marketing # another frontend of the project
        path_prefix: /blog # forwarded unchanged; /blog or /blog/...
    custom_domain: # omitted: kept; null or bare key: deleted
      domain: app.myapp.com
      tls: # optional for an existing domain
        mode: managed
  - name: marketing
    custom_domains: # instead of custom_domain; fully synced when declared
      - domain: www.myapp.com
        tls: { mode: managed }
      - domain: myapp.com # a second domain requires SUPERAGENT
        tls: { mode: managed }
        redirect: # answer with a redirect instead of serving
          to: www.myapp.com # a serving domain listed here
          status: 308 # 301, 302, 307, or 308 (default)
```

Not in the manifest: project logo, anon/service keys, project access tokens,
auth users (no end-user creation, bans, or sessions), scheduler regions, code
artifacts (function/frontend deploys), and one-shot actions (password reset, key
regenerate, test email, redeploy).

Credentials stay out because they are state, not configuration: they are created
and revoked imperatively, and a project access token's secret exists only in the
response that created it. Manage them through
[their endpoints](https://docs.volcano.dev/platform/authentication/security/project-access-tokens.md).

## Reconciliation semantics

- **Omitted sections are untouched.** Only what you declare is reconciled.
- **Patch semantics within entries.** An omitted optional field keeps its
  current server value (for example a bucket entry with only
  `file_size_limit` leaves `allowed_mime_types` alone).
- **Function visibility.** `visibility` is `private`, `authenticated`, or
  `public`; see
  [who can invoke a function](https://docs.volcano.dev/platform/functions/creating-functions.md#choose-who-can-invoke-it).
  Omitting it keeps the current level. `public` is a deprecated alias that
  still applies: `public: true` means `public` and `public: false` means
  `authenticated`, not `private`. A manifest that sets both must make them
  agree. Export writes `visibility` and never `public`.
  Exports made before `visibility` existed wrote `public: false`, and an entry
  that still says so applies `authenticated` on every deploy and every push. To
  keep the function `private`, replace it with `visibility: private`;
  `volcano cloud config pull` writes `visibility` for every function.
- **Function invocation metadata.** `invocation_mode`, `http_auth_mode`, and
  `openapi_spec` use the same validation as
  `PATCH /projects/{id}/functions/{functionId}`.
  Unauthenticated HTTP ingress (`http_auth_mode: none`) is allowed only when
  the function is `public`. Moving such a function off `public` implicitly
  restores `http_auth_mode: volcano` when the auth mode is omitted.
- **Frontend Function routes.** Declaring `frontends[].function_routes` fully
  syncs the same-origin path mappings for that frontend; routes absent from the
  list are deleted. Omitting the key preserves existing routes, while
  `function_routes: []` deletes them all. Targets must be deployed standard
  Functions with `visibility: public` and `invocation_mode: http`; one apply
  can set both on the function and add the route, and a route to a function
  that stays non-public is a validation error. A function that keeps a route
  cannot be made non-public or moved to `rpc` unless the same apply removes
  that route through the frontend's `function_routes`. A route works on both the
  generated frontend hostname (including preview deployments) and a custom
  domain because routing follows the resolved frontend rather than the domain.
  An apply may remove a function's routes and change its visibility or mode
  together; Volcano removes the routes before changing the function. See
  [Frontend Function routes](https://docs.volcano.dev/platform/frontends/function-routes.md).
- **Frontend routes.** Declaring `frontends[].frontend_routes` fully syncs the
  paths that frontend serves from another frontend of the project; routes absent
  from the list are deleted. Omitting the key preserves existing routes, while
  `frontend_routes: []` deletes them all. Each `frontend` must already exist and
  can't be the frontend itself. Function and Frontend routes on one frontend
  share one set of paths, so a path listed in both is a validation error, as is a
  path the other kind already routes when the manifest leaves that kind alone.
  An apply removes routes before it adds any, so it can move a path from one kind
  to the other. An export includes `frontend_routes` only on frontends that
  have routes. Where `GET /capabilities` lists `frontend_routes` as
  unavailable, a manifest that would create or change a route answers `400`
  with code `feature_unavailable`; keeping or removing routes still applies.
  See [Frontend routes](https://docs.volcano.dev/platform/frontends/frontend-routes.md).
- **Function kind is asserted, never written.** `kind` is fixed when a function
  is created, so the manifest compares it and reports an error when it
  disagrees with the deployed function. Omitting it means `standard`, so name
  `kind: durable` to describe a durable function. An export writes
  `kind: durable` for durable functions and leaves it off standard ones, so
  re-applying an export is a no-op either way.
- **Durable functions take no invocation settings.** `invocation_mode`,
  `http_auth_mode`, and `openapi_spec` describe synchronous HTTP invocation,
  which a durable function does not have — it is started through its executions
  collection. Declaring one on a durable function is a validation error, and an
  export never writes them. `visibility` still applies: it decides who may
  start executions. See
  [Durable functions](https://docs.volcano.dev/platform/functions/durable-functions.md).
- **Shared variables.** `shared_variables: [LOG_LEVEL]` replaces the complete
  shared list using existing names only. Omission keeps membership; `[]` clears
  it. Apply changes no values and deletes no variables through this field.
  Re-applying the same list retries function environment reconciliation, including
  after a previous apply failed to start it.
  Export emits the shared names and omits variable values. Unknown names and
  oversized final function environments are rejected before mutations. Scoped
  functions select variables independently of this list. The variable API's
  optional `shared` write field preserves existing membership when omitted and
  defaults to `true` for new variables for legacy clients.
  In a manifest that declares `shared_variables`, that list is the complete
  membership and is authoritative for every variable in the apply. Each create
  and update is written with the membership the list gives it, never as shared
  first and demoted after, so no reader sees a name under a membership the
  manifest did not ask for. A conflicting per-variable `shared` does not override
  the list — declaring `shared_variables: []` alongside `shared: true` on an entry
  writes that variable private. Use the per-variable field only when the manifest
  omits `shared_variables`.
- **Function variable scope.** `variable_scope: all` (the default) gives a
  function only variables marked `shared: true`. `variable_scope: scoped` gives it only the
  variables it selects: every name in `variables`, plus the names Volcano
  detects in its source that the project defines. Omitting either key keeps the
  function's current value, so a function scoped through an earlier apply stays
  scoped. See [Function variables](#function-variables) below.
- **Frontend shared variables.** `frontend_shared_variables` replaces the complete
  project list for frontends with `variable_scope: shared`. Omission keeps the
  list; `[]` clears it. Unknown names and environments over 4096 bytes are
  rejected before mutation. `all` keeps the legacy access to every project
  variable. `scoped` uses only the frontend entry's `variables` list.
- **Fully synced when declared (destructive by design):** `variables`,
  `buckets[].policies`, `auth.providers.oauth`, `auth.email.templates`,
  `functions[].variables`, `functions[].schedulers`, and
  `frontends[].custom_domains`. The declared list is the source of truth: entries
  absent from the manifest are deleted; an explicit empty list deletes
  everything. Run `volcano cloud config pull` before your first deploy and check the
  `--dry-run` report to see what a partial list would remove.
- **Email templates and built-in defaults.** Template content that still
  matches the built-in defaults is not a customization: exports omit the
  default bodies, and a declared `templates` map treats them as absent, so a
  subject-only entry works on HOBBY without touching the default content. A
  template type absent from a declared map reverts to the built-in default.
- **Never created, never deleted:** functions, frontends, databases, and
  buckets. The manifest only updates their configuration. Databases are
  assertion-only — `region`, `pg_version`, and `database_type` are compared,
  never written; a `database_type` mismatch is an explicit error because tier
  changes must go through `volcano cloud databases` or the dashboard.
- **Custom domains** belong to their declared frontend entry. Set
  `tls.mode: managed` to have Volcano issue and renew the certificate. Your
  account must own the hostname's domain in either TLS mode; see
  [Verify domain ownership](https://docs.volcano.dev/platform/frontends/domain-verification.md). Both an apply
  and a dry run refuse a hostname another account owns, and a BYOC hostname
  your account has not verified, naming the `_volcano` TXT record to publish.
  When DNS does not answer an ownership check, they refuse the hostname with a
  message to retry later.
  A managed hostname nobody owns is reserved for your account until you publish
  the record that `volcano cloud frontends domain get` reports; then keep the
  certificate validation CNAME in DNS for renewal. Domains already attached
  keep applying unchanged. If another account has only reserved the hostname,
  an apply that adds a domain to a frontend without one releases the
  reservation once your account owns the domain; for a managed domain it
  otherwise reports your account's `_volcano` TXT record, so publish it and
  apply again. A reservation is not ownership, so a dry run reports that
  domain as created and only the apply names the record. Replacing an
  existing domain never takes over a reservation: delete the existing domain
  in one apply, wait for its removal to finish, then declare the hostname. Add the
  separate routing record to direct traffic to the frontend. Use CNAME unless
  the hostname is the apex of your DNS zone, including a separately delegated
  subdomain. At an apex, use your provider's ALIAS, ANAME, or CNAME-flattening
  feature.
  `custom_domain` describes a frontend with at most one domain and no redirect;
  for any other frontend an apply that sets it fails validation and names
  `custom_domains`.
  Omitting `custom_domain` on a declared frontend leaves its domain alone, so a
  domain attached with `volcano cloud frontends domain create` survives a
  manifest that does not mention it. The report lists it as unchanged with a
  notice naming the hostname. To delete the domain, set `custom_domain: null`;
  the report names the hostname it deletes. Manifests that relied on omission
  to delete a domain need the explicit `null`. Moving a hostname to another of
  the project's frontends takes two applies: set `custom_domain: null` on its
  current frontend, wait for that removal to finish, then declare it on the
  new one.
  After a served domain is removed, proxies can keep serving its hostname for
  about a minute. An apply that declares that hostname within the minute fails
  validation; apply again once it has passed.
  Changing the hostname of a managed domain requires one apply to delete it
  and a later apply to create the replacement. Replacing a BYOC domain with a
  managed domain for a different hostname takes one apply; if Volcano cannot
  admit the new hostname, the BYOC domain keeps serving. Changing the TLS mode
  of an active domain for the same hostname takes one apply and keeps HTTPS up:
  the current certificate serves until its replacement does. See
  [Switch between BYOC and managed TLS](https://docs.volcano.dev/platform/frontends/overview.md#switch-between-byoc-and-managed-tls).
  A domain that is not yet active cannot switch in place; delete it first.
  Managed domains retain their certificate quota until
  deletion finishes. At the account limit, apply the deletion, wait for cleanup,
  then apply the replacement; planning a deletion does not grant capacity early.
- **Custom domain sets.** `frontends[].custom_domains` lists every custom
  domain of the frontend, each with `domain`, `tls`, and an optional
  `redirect` (`to`, `status`). Declaring it fully syncs the set: listed domains
  are created or updated, unlisted ones are deleted and the report names each
  hostname, and `custom_domains: []` deletes them all. Omitting the key leaves
  the domains alone. An entry sets `custom_domain` or `custom_domains`, not both.
  Each entry follows the `custom_domain` rules above for ownership, TLS, and
  rotation; where they say to set `custom_domain: null`, drop the entry from
  the list instead. A `redirect.to` must name another listed domain that is `active`
  and has no `redirect`, and `status` defaults to `308`. A domain the apply
  adds is not active yet, so add redirects to it in a later apply. One apply
  can add, remove, and retarget redirects among active domains, including
  swapping which domain serves: Volcano orders the changes so no redirect
  points at a domain that isn't serving. Replacing a hostname in one apply
  stops the old one serving as soon as its removal starts, before the new one
  is active. To swap without downtime, apply the list with both, wait for the
  new domain to be `active`, then apply the list without the old one. When the
  plan's allowance holds the old and new domains together, the apply creates
  the new domain first and keeps the old one if the create fails; the report
  marks the old entry as an error, and the next apply retries both. If the
  create stopped before Volcano stored the certificate, the retry needs
  `certificate_pem` and `private_key_pem` in the entry.
  A list longer than the plan's custom domains per frontend fails
  validation when it adds a hostname, so a frontend over the limit after a
  downgrade still re-applies its export and can shrink. Export writes
  `custom_domains` for a frontend with more than one domain or any redirect,
  and `custom_domain` otherwise. See
  [Redirect one domain to another](https://docs.volcano.dev/platform/frontends/overview.md#redirect-one-domain-to-another).
- **BYOC certificates** use `tls.mode: byoc` with `certificate_pem` and
  `private_key_pem` together, plus optional `certificate_chain_pem`. A `tls`
  block without `mode` is BYOC. New
  material for the same domain rotates the certificate **in place with zero
  downtime**: the frontend proxies pick up the new certificate within seconds
  while the old one keeps serving handshakes. Omitting `tls`, or sending only
  `tls.mode: byoc` as an export does, keeps the stored certificate. A different
  BYOC domain name is a detach-and-recreate; the new domain serves after
  provisioning.
- **Hosted pages are upsert-only.** There is no delete API for hosted pages,
  so pages omitted from `managed_pages.pages` are left untouched.
- **Managed-page appearance is also upsert-only in the manifest.** Omitted
  theme or layout fields stay untouched. Use the appearance API's `DELETE`
  operations to return a theme or page layout to default tracking.
- **`auth.signup.allowed_email_domains` replaces the stored list** when
  declared. `[]` removes the restriction; omitting the key keeps the current
  allowlist. Entries are normalized to bare lowercase domains, so
  `["@Acme.com"]` is stored as `["acme.com"]`. `allowed_email_domains_mode`
  follows the same rule — omit it to keep the stored mode. Declaring
  `signup_and_signin`, or narrowing the list while it is already set, signs out
  every session the list no longer admits. See
  [Email domain allowlist](https://docs.volcano.dev/platform/authentication/configuration/email-domain-allowlist.md).
- **New or changed `auth.email.from.address` and `name` values cannot contain line breaks.** Apply
  reports invalid values as `422` validation errors. Exports remove leading and
  trailing whitespace from older sender settings; reapplying them is a no-op
  and leaves the stored settings unchanged. Unchanged exports of legacy values
  with interior line breaks also preserve those values; delivery refuses an
  unsafe stored sender until it is repaired.
- **New or changed email template subjects cannot contain literal interior line breaks after
  Go template whitespace trimming.** Apply reports one `422` validation error
  under the affected template entry. Multiline control flow with `{{-` and
  `-}}` trim markers, outer whitespace, and trailing newlines from YAML block
  scalars are allowed; message bodies can span lines.
  Exports preserve older subjects. Reapplying the exact stored value is a no-op,
  and unrelated settings or template bodies remain editable. A changed subject
  must pass validation. Delivery uses the default subject when a rendered
  legacy value is unsafe. See
  [Subject headers](https://docs.volcano.dev/platform/authentication/configuration/email-templates.md#subject-headers).

## Leave out `lavaflow`

The manifest doesn't configure LavaFlow. A manifest that still has a `lavaflow`
section applies as if the section were absent: the dry run and the apply list
it as `unchanged` with a notice to remove it, and export never writes it. That
includes `autonomy: merge` and `merge_policy`: letting LavaFlow merge is the
project owner's decision, made with
[`PUT /lavaflow/settings`](https://docs.volcano.dev/platform/guides/lavaflow.md#merge-fixes).

## Notification settings

The `notifications` section reconciles where the project's
[alerts](https://docs.volcano.dev/platform/projects/notifications.md) go. An omitted field keeps its current value. Alert emails
go only to the project owner's verified account address, so leave `recipients`
empty. Naming that address also works, but the file then fails validation once
the address changes. Any other address is a validation error, and a dry run
reports it. The webhook signing secret is
never part of the manifest. Declaring a `webhook.url` on a project without a
webhook generates a secret the apply does not return. To get a secret you can
use, rotate it with `POST /projects/{id}/notifications/webhook-secret`.
`url: ""` removes the webhook and its secret.

Export writes the section once the settings have been saved. It never writes
`webhook`, because the URL can carry your receiver's token. An omitted webhook
keeps its URL and secret, so re-applying an export leaves it in place; declare
`url: ""` to remove one.

## Coverage warnings: skipped and missing

Because the manifest can never create functions, frontends, databases, or
buckets, the apply report tells you when your file and your deployed resources
disagree:

- `skipped` — the manifest configures a resource that does not exist. Deploy
  or create it first, then re-run `volcano cloud config deploy`.
- `missing` — a deployed resource has no entry in its declared manifest
  section.

Both are warnings: the rest of the manifest still applies and the CLI exits 0.

For a new frontend, deploy it with the variables its entry selects, then apply
the manifest (see
[First deploy with `volcano-config.yaml`](https://docs.volcano.dev/platform/frontends/deploy.md#first-deploy-with-volcano-configyaml)):

```bash
volcano cloud frontends deploy --variable-scope scoped --variable NEXT_PUBLIC_API_URL
volcano cloud config deploy
```

`volcano cloud deploy` does both for every frontend in
`.volcano/source-layout.json`, after the functions, and applies the manifest
last. Pass `-c` to apply another manifest, and repeat it to apply several in
order. It refuses a manifest with a `variables` section when an env file is
also present, since applying the section would delete the variables only the
env file sets. See the [CLI deploy reference](https://docs.volcano.dev/cli/deploy.md).

## Validation, plan limits, and partial failures

- Validation failures (bad cron expressions, invalid policy definitions,
  database assertion mismatches, plan-gate violations) return `422` with the
  full error list and **nothing is applied**.
- Plan gates are change-aware: they only fire when the manifest would change a
  gated value. Re-applying an export of a project downgraded from SUPERAGENT stays a
  no-op.
- SUPERAGENT-gated surfaces: region subsets, email template bodies, hosted pages,
  and the `auth.signup.allowed_email_domains` allowlist. A HOBBY project can
  still declare the allowlist it already has, or clear it to remove the
  restriction. Scheduler counts, storage policy counts, custom domains per
  frontend, and managed TLS certificates respect plan caps.
- Apply-phase failures (a provider call failing mid-apply) return `200` with
  per-entry `action: error`; already-applied changes are not rolled back.
  Re-running the deploy is safe — unchanged entries are no-ops.
- Applies are serialized per project; a concurrent apply returns `409`.

## Function variables

By default a function receives the project variables marked `shared: true`.
Keep this list small: the platform caps a function's environment at **4096 bytes**,
measured as its variables encoded as JSON, and a project can hold more than that.
See [environment variables](https://docs.volcano.dev/platform/functions/environment-variables.md) for how the
size is counted.

Scoping a function narrows it to the variables it actually reads:

```yaml
functions:
  - name: checkout
    variable_scope: scoped
    variables:
      - STRIPE_WEBHOOK_SECRET
```

The function's variables come from two sets, which differ in whether the name
has to exist:

- **Declared** — the names in `variables`. **Required:** a declared name the
  project does not define fails the apply. These round-trip through
  `volcano cloud config pull`, so the manifest stays the source of truth for them.
- **Detected** — the names Volcano finds in the source you deploy.
  **Optional:** a detected name is included when the project defines it and
  ignored when it does not, because a direct reference is often to something
  optional (`process.env.DEBUG`, `process.env.NODE_ENV`) and code written to
  run without it should still deploy. Volcano reads direct references only:

  | Runtime | Detected forms                            |
  | ------- | ----------------------------------------- |
  | Node.js | `process.env.NAME`, `process.env["NAME"]` |
  | Python  | `os.environ["NAME"]`, `os.getenv("NAME")` |
  | Ruby    | `ENV["NAME"]`, `ENV.fetch("NAME")`        |

  Detection runs on every deploy and reads the whole source you uploaded, so it
  follows your code without a manifest change. It reads code only: a name that
  appears just in a comment or inside an unrelated string is not a reference and
  does not select a variable. Code interpolated into a string still counts, so
  `${process.env.KEY}`, `#{ENV['KEY']}` and `f"{os.getenv('KEY')}"` are
  detected. It skips dependency trees (`node_modules`,
  `vendor`, `.venv`), so declare a variable your function only reads from inside
  a dependency.

Detected names are remembered even when the project does not define them yet, so
creating the variable later redeploys the function with it. **Declare a name in
`variables` when the function must not deploy without it** — that is the
difference between the two sets.

Anything computed — `process.env[key]`, a name built at runtime — cannot be
detected. **Declare those in `variables`.** A scoped function reading a variable
that is neither declared nor detected gets `undefined` at runtime, even when the
project defines it; that is the cost of not failing a deploy over an optional
reference.

Apply checks every function against the variables the manifest will produce, not
the ones the project has now, so one file can create a variable and declare it on
the same pass. It rejects the apply before any change lands when:

- A scoped function declares a variable the resulting project does not define —
  including one this file deletes, even if that function's own entry is
  unchanged. Create the variable, or drop the name.
- A function's resulting environment exceeds 4096 bytes. Scope the function to
  fewer variables, or shorten the values.

`volcano cloud config deploy` and a Git push refuse a `variables` key with no
value, such as a bare `variables:` or `variables: null`, rather than reading it
as omitted. Write `variables: []` to declare no names, or remove the key to keep
the current list. `volcano cloud config deploy` answers `400`, and a Git push
fails its deploy with nothing applied.

Changing `variable_scope` or `variables` redeploys that function's environment,
so switching from `all` to `scoped` removes the variables it no longer selects
from the running function rather than only from its configuration.

Creating, updating, or deleting a project variable only redeploys the functions
that select it. A function scoped away from a variable is left alone, so a write
to one secret no longer restarts every function in the project.

## Secrets

Write-only secrets — `auth.email.smtp.password`, OAuth `client_secret`, and
BYOC certificate material — are omitted from exports and stay unchanged unless
you set them explicitly. The notification webhook signing secret is never part of the manifest either; see
[Notification settings](#notification-settings). Custom-domain exports keep only `tls.mode`, for both
managed and BYOC domains. The CLI interpolates `${ENV_VAR}` references before
upload, so keep secrets in your environment, not in the file. Variable values
are included in exports (they are readable through the API).

## Asynchronous side effects

Apply starts long-running work and returns immediately: variable changes
propagate to functions and frontends through one batched workflow, region
changes trigger function redeploys, and new custom domains provision
asynchronously. Check convergence with the usual commands (`volcano cloud
variables list`, `volcano cloud frontends domain get`, ...).

## Frontend variable selection

Limit the project variables available to an existing frontend during both build
and runtime:

```yaml
version: 1
frontends:
  - name: web
    variable_scope: scoped
    variables:
      - NEXT_PUBLIC_API_URL
      - SESSION_SECRET
```

New frontends default to `scoped` with no selected project variables. Existing
frontends created under the legacy `all` default keep that selection until it
is changed explicitly; this legacy mode selects every project variable and does
not use the shared function list. `scoped` selects only the declared names;
frontend source is not scanned for
variable references. Missing declared variables reject deployment or apply.
`NEXT_PUBLIC_*` names remain build-only and do not enter the runtime environment.

Omitting either field preserves its stored value; `variables: []` selects no
project variables in scoped mode. Config export includes both fields.

A scope change synchronizes the running cloud frontend's environment. Rebuild
and redeploy to apply changes to values embedded in browser assets. Local mode
uses the stored selection on deployment and restart. Variable updates that the
frontend does not select do not trigger its runtime synchronization.

Selected runtime values, platform metadata, and reserved space for the largest
cache metadata and proxy-token rotation must fit the 4,096-byte
environment limit before a variable or scope change is saved. The platform's
share is about 900 bytes, reserved even when no rotation is staged, so roughly
3,100 bytes remain for selected variables. The reservation keeps updates safe
without reading deployed runtimes. Deployment also checks the final environment
including platform-managed values; an oversized environment fails before the
runtime configuration is updated. Error messages report byte counts without
exposing variable values.

## Sandbox template settings

Declare existing sandbox templates by name:

```yaml
version: 1
sandboxes:
  - name: browser-worker
    memory_mb: 1024
    ports: [8080]
    ttl_seconds: 3600
```

`config deploy --dry-run` previews changes. Applying updates the default lifetime
for newly created sessions; existing sessions keep their original lifetime.
An explicit session `max_duration_seconds` overrides the template default.
Cloud sessions allow 30–3,600 seconds on HOBBY and 30–28,800 seconds on
SUPERAGENT, subject to regional capacity. Template defaults above the plan limit
are clamped when starting a session; an explicit duration above it is rejected.
Local lifetime defaults to zero (unlimited); positive values impose a limit
without the cloud eight-hour limit. Local commands also default to unlimited.

`memory_mb` (1024 or 2048) and `ports` describe the deployed image. Running a
2048 MiB image requires SUPERAGENT; HOBBY supports a 1024 MiB baseline. Image
deployment and validation can succeed on HOBBY at either size, but starting a
2048 MiB session or one-shot execution returns `sandbox_plan_limit_exceeded`. Config apply
asserts these values and reports a validation error if they differ; deploy the
sandbox source to rebuild an image with different values. Omitted fields preserve
current settings. Missing templates are reported as skipped; templates absent from
the manifest are reported as missing and are never deleted. Export includes the
current settings and can be applied unchanged. A project without sandbox
templates exports no `sandboxes` section.

Git deployments discover each `volcano/sandboxes/<name>/Dockerfile` and its isolated
build context. Matching `sandboxes` entries supply memory, readiness ports, and
session defaults. Each push records a durable deployment for every discovered
sandbox and waits for image validation before reporting success. An entry without
a source directory does not create a template. Undeclared source directories use
the existing template's settings, or 1024 MB with no service ports for a new template.
