Managing Secrets
Spawn migrations sometimes need real secrets — a password for a role being created, an API key seeded into a config table, and so on. The [secrets] table in spawn.toml declares where each secret comes from, and the secret() template function reads it at render time, so real values never need to be committed to your repo as plain variables.
Defining secrets
Section titled “Defining secrets”Each secret has a default source, and optional per-environment overrides keyed by the same environment names used by [targets.*].environment:
[secrets.application_password.default]source = "host_file"path = "/run/secrets/application-password"
[secrets.application_password.environments.dev]source = "file"path = "./local-secrets/application-password.txt"When a template calls secret("application_password"), Spawn looks up environments.<current environment> first, falling back to default if there’s no override for the current environment. default is required — there’s no implicit “no secret configured” fallback, so pick a default that’s safe for production and override it for looser environments like dev.
See the configuration reference for the exact fields of every source type.
Choosing a source
Section titled “Choosing a source”There are five ways to tell Spawn where a secret’s value actually lives:
envreads an OS environment variable — the natural fit when a secret is already injected that way, e.g. by a CI/CD pipeline or a container orchestrator’s own secret injection.filereads a file via spawn’s configured operator, resolved the same way any other file spawn reads is. For now, this is restricted to the project’s path.host_filereads a file directly from the host filesystem, bypassing the operator. Use this for secrets mounted on the host outside spawn’s storage — Docker/Kubernetes secrets under/run/secrets, systemd’sLoadCredential=, or an already-decryptedsops/gpgoutput file. This is the one to use whenever the path is a real absolute host path.commandruns a command and uses its trimmed stdout as the value.literalis an inline value inspawn.toml, gated behind an explicitinsecure = trueflag.
command is a general-purpose escape hatch, and most secret managers ship a CLI that can print a value to stdout, so Vault, 1Password, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Doppler, Bitwarden, pass, and systemd-creds are all reachable this way without Spawn needing a dedicated integration for each one:
[secrets.application_password.default]source = "command"command = ["op", "read", "op://vault/application-password/password"]If a command source fails, Spawn never learns its value — so unlike every other secret-disclosure path, that value can’t be found and redacted afterwards. To guard against a provider script that logs its own inputs on failure (e.g. echo "got: $PASSWORD" >&2; exit 1), a failed command’s stderr is not shown by default — only its exit code. Set SPAWN_DEBUG_COMMAND_STDERR=1 to see the real stderr for local debugging. Never set this in CI or anywhere output may be logged or shared, since that’s exactly what the default behavior protects against.
literal is not intended to be used for production and therefore has an insecure flag to ensure the user understands that this is not for production use. Spawn refuses to use a literal secret without it, so a plaintext value committed to spawn.toml can’t accidentally become a project’s “secure default.” Reach for it only as a dev/local override, never as a default:
[secrets.application_password.environments.dev]source = "literal"value = "dev-only-password"insecure = trueUsing secret() in templates
Section titled “Using secret() in templates”CREATE ROLE app_user WITH LOGIN PASSWORD {{ secret("application_password") }};An unresolvable secret (missing environment variable, missing file, failing command, a literal without insecure = true, or a name not defined in spawn.toml) fails the render rather than silently producing an empty value. A secret referenced more than once in the same render resolves to the same value, even if its source (e.g. a command) isn’t deterministic.
Masking
Section titled “Masking”Whether secret() returns the real value or a placeholder like ***MASKED:application_password*** depends on the command being run, not on anything in your template or config:
| Command | Secrets |
|---|---|
migration apply | Always revealed — it executes the rendered SQL for real. |
migration build, test build | Masked by default. Pass --reveal-secrets to see real values for local debugging. |
test run, test compare, test expect | Always masked, with no way to reveal — see below. |
Masking only affects the value returned to the template — the secret is still fully resolved either way, so a masked build still fails if the secret is unreachable or misconfigured (including the literal/insecure check above). It just never displays the real value. This means build doubles as a way to verify your secrets are reachable in a given environment before you ever run apply.
test run/compare/expect mask unconditionally, with no --reveal-secrets equivalent: unlike a terminal or a CI log, their output is diffed against — and, via test expect, written into — an expected file that gets committed to the repo. A revealed secret there wouldn’t just risk a transient leak (e.g. a failing statement’s diagnostics echoing it back); it would be permanently baked into git history the first time a test happened to touch it. If you need a test that depends on a secret’s real value, that’s a real limitation today — there’s no per-test override, since a use case for one hasn’t come up yet. Please open an issue outlining your use case, so that we can understand better why this might be needed and therefore how to provide it.
Secrets in failed applies
Section titled “Secrets in failed applies”migration apply executes real SQL, and a failing statement can make the database echo a literal value back in its own error message — a unique or check constraint violation reporting the row’s actual contents, an invalid input syntax error quoting the offending value, and so on. If that statement used secret(), the real value could otherwise end up in whatever captures apply’s output — a terminal, a CI log, anything.
To prevent this, Spawn replaces every secret value a failed apply actually resolved with ***REDACTED*** in the returned error, before it’s ever displayed or logged.
Secrets and pinning
Section titled “Secrets and pinning”Secrets are entirely outside of pinning: spawn migration pin snapshots component content into the content-addressed store and records it in a migration’s lock.toml. It never touches [secrets] or resolved secret values — a secret’s source is declared once in spawn.toml, and its value is re-resolved fresh every time a migration is built or applied, regardless of pinning.