Skip to content
BuildStackHQHosting
All docs

Docs

Environment and secrets

Plain variables, secret variables, and which layer wins when both declare one.

Every agent has its own set of environment variables, editable in the dashboard (agent page, "Environment") and with keel env. A variable is either plain (the value is stored in Postgres and visible to everyone who can read the agent) or secret (the value lives in the organization's tenant secret store and is never listed again). The set is applied on the next start: the next run of a run mode agent, the next deploy or restart of a service. Nothing is injected into a running container.

Where this sits next to what already existed: keel.yaml still compiles environment: (plain values baked into the revision manifest) and secrets: (references to organization wide secrets set with keel secrets set). Per-agent variables are the third layer, the one you edit without a deploy.

Precedence

At launch both executors call mergeRuntimeEnvironment (packages/infrastructure/src/executor/runtime-environment.ts) with the manifest and the agent's set. The result is one environment map plus one list of secret references, layered lowest to highest:

  1. manifest.environment from keel.yaml.
  2. Per-agent plain variables.
  3. Organization secrets referenced by manifest.secrets.
  4. Per-agent secret variables.

A name that appears in more than one layer takes the highest one; a name that resolves to a secret is removed from the plain map so a plain value never shadows a secret with the same name. Platform variables (KEEL_*) are added after the merge by the executor and always win, which is why they are refused as names. PORT is refused for the same reason: the ingress port is owned by the manifest.

On Docker the secret references are resolved to values through the tenant vault before the containers are created, so they sit in the container's Env exactly like the manifest secrets already did and docker inspect on the host shows them; the single host tier treats host root as trusted. On Fargate they become secrets entries of the task definition (valueFrom on the organization's Secrets Manager secret, JSON key agent/<agentId>/<NAME>), so the value never passes through the platform. In both cases the supervisor receives every secret name in KEEL_REDACT_ENV_NAMES and redacts the values from logs.

Names, limits and validation

  • Names follow POSIX: [A-Za-z_][A-Za-z0-9_]*, at most 128 characters, case sensitive. Names starting with KEEL_ (any case) and PORT are reserved (reserved_env_var_name). Anything else is invalid_env_var_name.
  • At most 100 variables per agent (env_var_limit_exceeded with limit: count), plain values at most 32 KB in total counting NAME=value (limit: plain_bytes), a single value at most 32 KB plain or 4 KB secret (env_var_value_too_large), and at most 16 KB of secret values per request (limit: secret_bytes), because every secret of an organization shares one 64 KB vault entry. The limits are constants in serviceEnvLimits (packages/domain/src/services/env-vars.ts); they are not per plan.
  • A request that names the same variable twice is duplicate_env_var_name; one that sets and deletes the same name is env_var_set_and_deleted. Every check runs before anything is written.

Secrets versus plain values

  • A plain variable stores its value in service_env_vars.value and is returned by GET /v1/agents/{id}/env.
  • A secret variable stores only secret_ref, the vault name agent/<agentId>/<NAME>, and the value goes to the tenant secret store through the existing TenantSecretStore port (PutSecret semantics, same store as keel secrets set). Names containing / are hidden from GET /v1/secrets, so per-agent secrets never show up in the organization list. Listing returns kind: "secret" with value: null.
  • Setting a secret always rewrites the vault entry and bumps updatedAt; setting a plain variable to the value it already has is a no-op and leaves history and the restart flag alone.
  • Turning a secret into a plain value (or deleting it) removes the vault entry in the same transaction. Archiving the agent purges the rows and the vault entries (PurgeServiceEnvVars, run by ChangeAgentLifecycle on archive).
  • Reading secret values back is a separate permission, secrets.reveal, held by owners and admins only. No API key scope grants it. Every reveal is an audit entry with the revealed names.

Restart semantics

EnvVarSet.hash is a 64 bit FNV-1a over the sorted set: name, kind and the plain value, or the secret's updatedAt for secrets. When a run is dispatched the hash of the set at that moment is stored on runs.env_hash, and the launch input carries the set itself (LaunchInput.serviceEnvironment).

GET /v1/agents/{id}/env answers restartRequired: true for a service agent when any active service run (queued, dispatching, provisioning, running, suspended) has a different hash from the current set. A run dispatched before this feature existed carries no hash and counts as launched with the empty set, so an agent that has never had a variable does not report a pending restart. Run mode agents always answer false: the next run picks the set up. The dashboard shows a "Restart to apply" banner while the service is running and the flag is set; the button calls POST /v1/agents/{id}/service/restart, the rolling restart, so replicas are replaced one at a time. The flag clears once every active replica runs with the current set, which is the end of the rollout, not the click.

Dotenv import and export

POST /v1/agents/{id}/env/import and keel env push parse dotenv text (parseDotenv in packages/domain/src/services/dotenv.ts):

  • A leading byte order mark is dropped and CRLF, lone CR and LF all count as line endings.
  • Blank lines and lines starting with # are skipped. An optional export prefix is dropped. Whitespace around the name and the = is ignored.
  • Unquoted values are trimmed and an inline comment ( # preceded by whitespace) is stripped, so COLOR=#ff0000 keeps its value and NOTE=hello # note is hello.
  • Single quoted values are literal. Double quoted values may span lines and expand \n, \r, \t, \", \\ and \$; there is no variable expansion.
  • Characters after a closing quote (other than a comment) are trailing_characters; a quote that never closes is unterminated_quote; a line without = is missing_assignment. Errors carry the line number (invalid_dotenv) and nothing is written.
  • A later assignment of the same name wins. Every entry is upserted as plain, or as secret when secret: true (--secret); import never deletes.

Export (POST /v1/agents/{id}/env/export, keel env pull) renders the whole set as dotenv text with secret values resolved, one NAME=value per line sorted by name, values double quoted and escaped only when needed, so the output round-trips through import. It needs secrets.reveal; a secret whose vault entry is missing fails the export with env_secret_missing rather than writing an empty value.

API

Method and path Permission Notes
GET /v1/agents/{id}/env agents.read { agentId, mode, vars: [{ name, kind, value, updatedAt, updatedBy }], hash, restartRequired, limits: { maxVars, maxPlainBytes }, history }. value is null for secrets. history holds the last 20 audit entries for this agent: { id, action: set | import | export, actor, at, set, secretsSet, deleted, revealed }.
PUT /v1/agents/{id}/env agents.write { vars: [{ name, value?, secret? }], deletes: [name] }. Bulk upsert and delete in one transaction; a missing value is the empty string. Answers the same shape as GET. Audited as env.set with names only.
POST /v1/agents/{id}/env/import agents.write { text, secret? }, text at most 256 KB. Audited as env.import.
POST /v1/agents/{id}/env/export secrets.reveal { text, names, secretNames }. Audited as env.export with revealed names.

Problem kinds: invalid_env_var_name, reserved_env_var_name, duplicate_env_var_name, env_var_set_and_deleted, env_var_limit_exceeded, env_var_value_too_large and invalid_dotenv are 400; env_secret_missing is 409; secrets_not_provisioned (409) when a secret is set on an organization without a tenant secret store; agent_not_found and agent_archived as elsewhere.

Storage

Migration 0030 adds service_env_vars (agent_id, organization_id, name, kind, value, secret_ref, updated_by, created_at, updated_at; primary key (agent_id, name); RLS like every tenant table; a check that plain rows carry a value and no ref and secret rows the opposite) and runs.env_hash. The port is ServiceEnvVars on TenantRepositories.serviceEnvVars (listForAgent, lockForAgent, upsert, remove, removeAll); lockForAgent takes a transaction scoped advisory lock per agent so concurrent writers serialize and the limits hold. The reader port for export is ServiceEnvSecretReader (read(secretsArn, name)), wired to the tenant vault.