Docs / Secrets (API keys & OAuth credentials) reference
Guidedeploymill://guides/secrets

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:

  1. The org vault — a per-org, encrypted-at-rest store. A value is entered once and shared across the org's apps.
  2. 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_KEY configured. 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)

  1. The agent calls request_secret({ name }) (e.g. name: "ANTHROPIC_API_KEY"). It returns a single-use url, a requestId, and an expiresAt.
  2. The agent hands the url to you. Do not paste the secret into the chat — that's the whole point. Open the link in a browser.
  3. 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.
  4. The agent polls secret_request_status({ requestId }) until it returns fulfilled (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, plus usedBy (the apps that currently consume each secret, with the env key and whether they've overridden it locally) and a usedByCount. Never values. Use usedBy as 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 a source so org-vs-app is legible:
    • org-linked — synced from an org secret; the live value matches the vault.
    • app-override — an app value (set via set_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 on reconcile_project; rotating the vault re-syncs on the next reconcile.
  • If a human later runs set_env_vars({ vars: { SECRET: "app-specific" } }), that value winsreconcile_project reports it as an overridden binding and will not clobber it. list_env_vars shows it as app-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_vars is 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

  1. 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.
  2. request_secret({ name: "GOOGLE_CLIENT_ID" }) and request_secret({ name: "GOOGLE_CLIENT_SECRET" }), and enter both values via the links.
  3. Bind them (or declare under secrets in project.json and reconcile):
     bind_secret({ applicationId, name: "GOOGLE_CLIENT_ID" })
     bind_secret({ applicationId, name: "GOOGLE_CLIENT_SECRET" })
  4. deploy. Set any non-secret redirect/base URL via set_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_secret and 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_secret to scrub apps. It only removes the vault entry (and returns affectedApps so you know who still holds the value); bound copies persist in app envs until you delete_env_vars them.