Secrets
A secret "<name>" { ... } block sources a sensitive value from outside the .strat file and makes it referenceable as secret.<name>.value. Plaintext flows into provider attrs at apply time; state stores a redaction marker, never the value itself.
secret "pg_password" {
from_env = "PG_PASSWORD"
}
resource "docker_container" "db" {
host = host.primary.addr
image = "postgres:16-alpine"
env = {
POSTGRES_PASSWORD = secret.pg_password.value
}
}
secret_block ::= "secret" string "{" attr* "}"
The single label is the secret's name. Names must be unique within the document — duplicates across -c files error with duplicate secret.
Sources
Exactly one of from_env or from_file is required. Both set or neither set is a hard error (BadSecretBody).
| attr | type | description |
|---|---|---|
from_env | string | Name of an environment variable. Resolved with std::env::var at config-load time. |
from_file | string | Path to a file. ~ and ~/ expand to $HOME (or $USERPROFILE on Windows). Relative paths resolve against the .strat file's directory. The file's contents are loaded with std::fs::read_to_string; one trailing \n or \r is trimmed. |
sensitive | bool | Default true. When false, the resolved value is still used by providers but is never placed in the redaction map — CLI output and state will show it in the clear. Opt-out for values you don't mind printing. |
Both sources resolve eagerly at config-load time. A missing env var or unreadable file is a hard error unless --allow-unresolved-secrets is set on plan.
References
The only allowed field is value:
env = { POSTGRES_PASSWORD = secret.pg_password.value }
Anything else (secret.pg.fingerprint, secret.pg, secret.pg.value.foo) errors with unknown secret field or reference ... too short.
Like host blocks, secret bodies are literal-only: any ref inside (including refs to other secrets) errors with references not allowed inside \secret` blocks`.
Redaction
When a secret is resolved and meets all of these conditions, its plaintext is added to a private redaction map:
sensitive = true(the default).- The resolved value is at least 8 characters long.
- The value is non-empty.
- The secret is not in the unresolved-placeholder state (see below).
The redaction walk runs over every plan step's desired and prior values before printing, and over every provider's returned attrs before they're persisted to state. Any leaf string that exactly matches a known plaintext is replaced with a marker object:
{
"__secret": "pg_password",
"__secret_sha256": "sha256:f7c3bc1d808e04..."
}
The marker is what lives in .stratum/state.json. Re-loading state and re-planning produces the same marker (no re-redaction needed — redact_walk is idempotent on markers).
Substring redaction
The exact-match case covers env = { POSTGRES_PASSWORD = secret.pg.value } — the leaf string equals the plaintext, so the whole leaf is replaced with the object marker. A secret ref inside a ${...} interpolation (see String interpolation) is different: the plaintext is glued into a larger string at evaluation time, so the redaction walk sees "postgresql://app:CORRECTHORSEBATTERY@db:5432/app", not the bare secret.
For those cases, redact_walk falls back to substring replacement. Every known secret plaintext that appears in the leaf is replaced inline with a marker token of the form <secret:NAME:sha256:HEX>. Longest match wins (so overlapping secrets stay deterministic), and replacement is per-occurrence. The substituted string is what lands in state:
{
"env": {
"DATABASE_URL": "postgresql://app:<secret:pg:sha256:f7c3bc...>@db:5432/app"
}
}
Substring redaction also runs over diff_observed output: when state holds the inline marker and the live host returns plaintext, the marker is applied to the observed side before comparison, so both sides collapse to the same string and the diff disappears. Without this, every plan --refresh would emit a spurious Update for every interpolated secret-bearing field. The post-redaction equality check happens in Extracted::redact_plan, which the CLI calls on both plan and apply before printing.
A short value (< 8 chars) still resolves and flows into provider attrs — it's just not added to the redaction map, because substring-substitution on short strings ("root", "5432") has a high false-positive rate. The CLI emits a warning to stderr in that case:
[secrets] warning: secret `s` resolved value is <8 chars; CLI/state will not redact it
If two distinct secrets resolve to the same plaintext, you get a different warning — the marker is ambiguous because there's no way to tell which secret a given plaintext leaf came from:
[secrets] warning: secrets `a` and `b` resolved to the same value — redaction marker may be ambiguous
Plan output
Secret-bearing fields render with a 6-char hash prefix:
~ docker_container.db
~ env.POSTGRES_PASSWORD: <secret:pg_password sha:f7c3bc> -> <secret:pg_password sha:9a1e44>
The hash is enough signal to tell that the value changed without leaking the value or a full attackable digest.
Drift detection
diff_observed is marker-aware:
- Marker (state) vs plaintext (observed) — hash the plaintext, compare to the marker's
__secret_sha256. Match → no drift. - Marker vs marker — compare hashes directly.
- Mismatch → emit a single
<secret> -> <secret-drifted>change, with no plaintext on either side.
This is what stops --refresh from showing a perpetual drift on every secret-bearing field. Without marker awareness, every refresh would compare the state-side marker object against the host-side plaintext and flag a difference.
The honesty guard
Some resource attrs receive opaque blobs that stratum can't substring-redact after the fact: file contents, file paths that may contain content interpolation, directory uploads. Embedding a secret ref in any of these is rejected at config-load time:
| kind | forbidden attr |
|---|---|
system_file | content |
system_file | content_file |
system_dir | source_dir |
resource "system_file" "x" {
content = secret.s.value # error: SecretInUnsupportedAttr
}
The error message is secret reference not allowed in \system_file.content` — secrets must be in single-leaf string attrs (e.g. inside `env`), not embedded in file content or path strings`.
This is a deliberate limitation. The right shape for a config-file secret is a templated config rendered outside stratum (or via a future system_template kind that knows about secret boundaries) — not a secret embedded inside a content blob whose redaction story is "search the file for the plaintext, hope you find it."
For the common case of "drop a whole secret blob on the host" (a Firebase service-account JSON, an .env file, an age-encrypted key), use system_secret_file. Its content attribute accepts a secret ref directly — state stores sha256 plus permissions, never the plaintext.
resource "system_secret_file" "firebase-sa" {
host = host.primary.addr
path = "/etc/app/firebase-sa.json"
content = secret.firebase_sa.value
mode = "0400"
}
--allow-unresolved-secrets
stratum plan --allow-unresolved-secrets treats a missing env var or unreadable file as a soft failure: the secret's value becomes the placeholder string <unresolved-secret:NAME> instead of erroring out. Useful when reviewing someone else's config without their env populated.
The placeholder flows through eval_value like any other string, so it shows up in plan output wherever the secret was referenced. Apply refuses to run a plan containing any placeholder:
refusing to apply: plan contains unresolved-secret placeholder for `pg_password`
hint: this only happens via `plan --allow-unresolved-secrets`; set the secret's source and retry.
Placeholders are not added to the redaction map.
Errors
| condition | error |
|---|---|
Neither from_env nor from_file | BadSecretBody |
Both from_env and from_file | BadSecretBody |
Env var unset (without --allow-unresolved-secrets) | SecretEnvMissing |
from_file path unreadable (without the flag) | SecretFileMissing |
from_file is relative but the source has no base dir | SecretFileNoBaseDir (only with load_str, not CLI flows) |
~ in from_file but neither HOME nor USERPROFILE set | SecretTildeNoHome |
| Ref inside the secret body | RefInSecretBlock |
| Unknown secret name | UnknownSecret |
Unknown field (anything other than value) | UnknownSecretField |
Duplicate name across -c files | DuplicateSecret (names both paths) |
| Secret ref in a forbidden attr | SecretInUnsupportedAttr |
Tutorial
See Inject a secret into a docker container for the env-var-on-docker_container pattern end to end.