Secrets reference (deploymill)
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.
There are two layers:
- The org vault — a per-org, encrypted-at-rest store. A value is entered once and shared across the org's apps.
- Binding — copying a vault value into a specific app's env so the running container can read it (resolved server-side; the value is never returned to the agent).
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({ name })(e.g.name: "ANTHROPIC_API_KEY"). It returns a single-useurl, arequestId, and anexpiresAt. - 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.
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_org_secrets()— names + last-updated timestamps, plususedBy(the apps that currently consume each secret, with the env key and whether they've overridden it locally) and ausedByCount. Never values. UseusedByas the blast radius before rotating or deleting a 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 asourceso org-vs-app is legible:org-linked— synced from an org secret; the live value matches the vault.app-override— an app value (set viaset_env_vars) shadowing an org secret of the same name.app— a plain 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
Option A — 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 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. - ⚠️ Binding writes the plaintext into the compute backend's env 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.
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_org_secretto scrub apps. It only removes the vault entry (and returnsaffectedAppsso you know who still holds the value); bound copies persist in app envs until youdelete_env_varsthem.