Secrets
How to give a deployed app the API keys and OAuth credentials it needs (an Anthropic key, an email-provider key, a Google OAuth client secret) without the secret value ever passing through the AI agent or a chat transcript.
The hard rule: secret values never cross the agent. No tool accepts a secret value, and no tool returns one. A value enters the system only when a human types it into a DeployMill web page; it leaves only when it's injected into an app's runtime env (server-side). The agent orchestrates the workflow but never sees the value.
Two independent axes: sensitivity and scope
A configuration value has two separate properties. Don't conflate them:
- Sensitivity: is it a secret (encrypted at rest, write-only, browser hand-off) or plain config (a port, a flag, a public base URL)?
- Scope: does it live on this app (an app-scoped secret, the common case, e.g. a per-app
STRIPE_KEYorDATABASE_URL) or is it shared across the org (an org-shared secret, oneANTHROPIC_API_KEYreused by every app)?
So a secret is app-scoped by default. It lives only on the app you create it for. Org-sharing is the deliberate upgrade, not a synonym for "secret":
- App-scoped secret:
request_secret({ name, applicationId }). Encrypted, owned by that app, delivered into its env automatically once filled. No binding step. - Org-shared secret:
request_secret({ name })(no app) puts the value in the per-org vault. You thenbind_secretit into each app that needs it (or declare it undersecretsinproject.json). - Share later:
share_secret({ applicationId, name })moves an app secret up to org scope. Detach:unshare_secret({ applicationId, name })copies an org secret back into one app's own vault.
Requires the server to have
SECRETS_ENC_KEYconfigured. Without it, the secrets tools and reconcile's secret bindings return a clear "not configured" error.
Adding or rotating a secret (the browser hand-off)
- The agent calls
request_secret. Pass anapplicationIdfor an app-scoped secret (request_secret({ name: "STRIPE_KEY", applicationId })), or omit it for an org-shared one (request_secret({ name: "ANTHROPIC_API_KEY" })). It returns a single-useurl, arequestId, anexpiresAt, and the resolvedscope. - The agent hands the
urlto you. Do not paste the secret into the chat. That's the whole point. Open the link in a browser. - You sign in to DeployMill (if you aren't already) and type the value into a form. It POSTs straight to DeployMill over HTTPS, is encrypted at rest, and the link is burned. Entering a value for an existing name rotates it.
- The agent polls
secret_request_status({ requestId })until it returnsfulfilled(other states:pending,expired,not_found). The status never includes the value.
For an app-scoped secret, that's it. The value is encrypted into the app's own vault and written into its env automatically. Just deploy to roll it out. For an org-shared secret, fulfilment only puts it in the vault. Wire it into apps with bind_secret (or project.json) next.
The link is single-use, expires (~15 min), is bound to your org, and requires you to be logged in, so a leaked link alone is useless.
8 KB size limit: the entry endpoint rejects values that exceed 8 192 UTF-8 bytes with a 400 (
"Secret value must be ≤ 8192 bytes."). This covers any API key, token, or PEM-encoded cert. Values larger than this (e.g. a bundled CA chain, a base64-encoded file) don't belong in the secrets vault. Use a volume mount or object store instead.
Reading what exists (names only)
list_secrets({ applicationId }): names + last-updated timestamps of an app's own (app-scoped) secrets. Never values. Each entry is taggedscope: "app".list_secrets()(omitapplicationId): names + last-updated timestamps of the org-shared vault, plususedBy(the apps that currently consume each secret, with the env key and whether they've overridden it locally) and ausedByCount. Never values. Each entry is taggedscope: "org". UseusedByas the blast radius before rotating or deleting a shared secret.list_env_vars({ applicationId }): env var names on an app. Values are write-only. There is no way to read them back through any tool. Each key carries asourceplus two booleans (sensitive,shared) so the two axes are legible:app-secret: the app's own encrypted secret (sensitive, not shared).org-shared: synced from the org-shared vault, so the live value matches it (sensitive + shared).app-override: an app value (set viaset_env_vars) shadowing an org-shared secret of the same name (sensitive + shared).app-plain: a plain, non-secret app env var DeployMill doesn't manage.
Precedence: org secret is a default, app-level value always wins
Org secrets and app env vars resolve into one namespace per app. The org secret is just a default that flows in. On a name conflict, the app-level value wins (the same rule Vercel and Railway use for shared-vs-project variables):
- Declare
secrets: ["SECRET"]and the vault value flows into the app onreconcile_project. Rotating the vault re-syncs on the next reconcile. - If a human later runs
set_env_vars({ vars: { SECRET: "app-specific" } }), that value wins.reconcile_projectreports it as anoverriddenbinding and will not clobber it.list_env_varsshows it asapp-override. - To drop the override and let the org value flow back,
delete_env_vars({ keys: ["SECRET"] })and reconcile again. With the binding still declared, the org value is re-written.
DeployMill tracks what it last wrote per app (a value hash, never the value) so it can tell a stale-but-managed value (safe to rotate) from a deliberate app override (never touch).
Getting a secret into an app
App-scoped secret: nothing to bind
An app-scoped secret (request_secret({ name, applicationId })) needs no binding step. Once the entry link is filled, the value is encrypted into the app's own vault and injected into the app's runtime from the vault at deploy time (it never sits in the editable env blob). Just deploy to pick up the new value. Manage it later with list_secrets({ applicationId }), delete_secret({ scope: "app", applicationId, name }) (clears the vault, so the next deploy stops injecting it, no plaintext left behind), and (to share) share_secret.
Org-shared secret: bind_secret (imperative, quick)
bind_secret({ applicationId, name: "ANTHROPIC_API_KEY" }) // env key == secret name
bind_secret({ applicationId, name: "SENDGRID_KEY", as: "EMAIL_KEY" }) // map onto a different env key
Resolves the org-shared vault value server-side and writes it into the app's env under as (defaulting to name). The value is never returned. You get the env key it landed under. Takes effect on the next deploy. Call deploy afterwards.
Option B: .deploymill/project.json (declarative, reproducible)
{
"version": 2,
"name": "my-app",
"domains": { "prod": "my-app-acme.detz.dev" },
"mounts": [],
"rollback": false,
"secrets": ["ANTHROPIC_API_KEY", { "name": "SENDGRID_KEY", "as": "EMAIL_KEY" }]
}
reconcile_project resolves each declared secret from the vault and writes it into the app's env (so rotating the vault value re-syncs on the next reconcile). plan.secrets reports set / keep / missing / overridden / action, names only, never values. A missing entry means the secret hasn't been entered yet: run request_secret, have the human fill it, then reconcile again. An overridden entry means the app has its own value for that key (set via set_env_vars), which reconcile leaves untouched (see precedence above). Only names live in the repo.
Removing a binding from the array does NOT prune the env var. Use delete_env_vars to remove it from the app.
What the protections do and don't cover
- ✅ The value never passes through the agent, the MCP channel, or a transcript. It's entered by a human in the browser.
- ✅ Encrypted at rest (AES-256-GCM). Entry links are hashed at rest, single-use, short-lived, org-bound, and require login.
- ✅ Write-only: no tool ever returns a value;
list_env_varsis names-only. - ✅ App-scoped secrets are injected from the vault at deploy time, into a dedicated runtime channel. They never sit in the editable env blob. Deleting one leaves no plaintext behind. The next deploy simply stops injecting it.
- ⚠️ Org-shared
bind_secretwrites the plaintext into the compute backend's env blob so the container can read it at runtime. The vault protects the source of truth and the agent channel, not end-to-end secrecy from the host. (App-scoped secrets avoid the env blob entirely. This caveat is the org-shared path.)
Non-secret config
For values that aren't sensitive (ports, feature flags, public base URLs), set_env_vars is fine and faster. Anything you'd hate to see in a transcript (keys, tokens, passwords, OAuth secrets) goes through the vault, never set_env_vars.
Wiring Google OAuth into an app
- Create an OAuth client in the Google Cloud console; set the redirect URI to your app's callback on its prod domain, e.g.
https://my-app-acme.detz.dev/api/auth/callback/google. request_secret({ name: "GOOGLE_CLIENT_ID" })andrequest_secret({ name: "GOOGLE_CLIENT_SECRET" }), and enter both values via the links.- Bind them (or declare under
secretsinproject.jsonand reconcile):bind_secret({ applicationId, name: "GOOGLE_CLIENT_ID" }) bind_secret({ applicationId, name: "GOOGLE_CLIENT_SECRET" }) deploy. Set any non-secret redirect/base URL viaset_env_vars.
This is for an app consuming Google OAuth. "Sign in with Google" for DeployMill itself is a separate, server-level feature.
What NOT to do
- Never ask the user to paste a secret into the chat. Use
request_secretand hand them the link. - Don't put secrets in
set_env_vars. It's for non-secret config. - Don't expect a bind to apply without a redeploy. Env changes need a
deploy. - Don't rely on
delete_secret({ scope: "org" })to scrub apps. It only removes the org-shared vault entry (and returnsaffectedAppsso you know who still holds the value). Bound copies persist in app envs until youdelete_env_varsthem. (delete_secret({ scope: "app" })does not have this footgun. It removes the app-scoped secret from both its vault and the app's env in one call.)