Inject a secret into a docker container
You have a docker_container resource that needs a sensitive env var — a database password, an API token, a webhook secret. You want the value to come from your shell environment (or a file outside git), flow through stratum into the container's env map, and never land in .stratum/state.json as plaintext.
This is what secret blocks are for. The pattern is two resources: one secret block sourcing the value, and one docker_container reading secret.<name>.value inside its env map. Stratum substitutes the plaintext at apply time and stores a redaction marker in state.
Why this works the way it does
Anything you put in a .strat file is committed alongside your code. Anything you put in .stratum/state.json is committed (if you commit state) or sits on your disk in plaintext (if you don't). Neither place is somewhere a database password belongs.
Stratum splits the problem: the value lives in your shell (or a file you keep out of git), and the reference lives in the config. State stores a {__secret, __secret_sha256} marker, which is enough to tell that a secret is set and whether it changed, but not what it is. See Secrets for the full mechanism.
Step 1: source the value
Decide where the value comes from. Two options:
# From an env var.
secret "pg_password" {
from_env = "PG_PASSWORD"
}
# Or from a file outside git.
secret "pg_password" {
from_file = "~/.config/stratum/pg-password"
}
from_env reads the variable with std::env::var at config-load time. from_file reads the file; ~ and ~/ expand to $HOME (or $USERPROFILE on Windows). Relative paths resolve next to the .strat file.
Pick one. Setting both, or neither, is a hard error.
Step 2: reference it in a container
host "droplet" {
addr = "root@1.2.3.4"
}
secret "pg_password" {
from_env = "PG_PASSWORD"
}
resource "docker_container" "stratum-postgres" {
host = host.droplet.addr
name = "stratum-postgres"
image = "postgres:16-alpine"
restart = "unless-stopped"
networks = ["stratum-edge"]
ports = ["127.0.0.1:5432:5432"]
volumes = ["stratum-pg-data:/var/lib/postgresql/data"]
env = {
POSTGRES_PASSWORD = secret.pg_password.value
POSTGRES_USER = "postgres"
POSTGRES_DB = "vortex"
}
}
The ref secret.pg_password.value evaluates to the plaintext during ref resolution. The provider — docker here — receives a normal string in env.POSTGRES_PASSWORD and sets -e POSTGRES_PASSWORD=<value> on docker run.
A secret ref is only allowed in single-leaf string attrs. Putting it inside system_file.content (where it would land in a config file blob stratum can't redact) is rejected at load time. See the honesty guard for the full list.
Step 3: plan it
Set the env var, then plan:
export PG_PASSWORD=$(openssl rand -hex 32)
./target/release/stratum plan \
-c examples/probes/postgres.strat \
-s /tmp/probe-postgres.json
The secret-bearing field renders with a 6-char hash prefix:
+ docker_container.stratum-postgres
...
~ env.POSTGRES_PASSWORD: null -> <secret:pg_password sha:f7c3bc>
The hash is enough to spot a rotation (the prefix changes) without leaking the value or a full attackable digest. The null -> ... is because this is a Create step; on Update you'd see both sides with their respective hashes.
Step 4: apply it
./target/release/stratum apply -y \
-c examples/probes/postgres.strat \
-s /tmp/probe-postgres.json
What happens at apply time:
- The provider receives the plaintext in
env.POSTGRES_PASSWORDand runsdocker run -e POSTGRES_PASSWORD=<value> .... The value lands inside the container's process environment. - Before stratum persists the provider's returned attrs to state, the redaction walk swaps every leaf string that matches a known plaintext for the marker object.
env.POSTGRES_PASSWORDin state ends up as{"__secret": "pg_password", "__secret_sha256": "sha256:f7c3bc..."}. state.savewrites the file. Inspect it:
./target/release/stratum state show docker_container.stratum-postgres -p /tmp/probe-postgres.json
You'll see the marker in env.POSTGRES_PASSWORD, not the password.
Step 5: rotate
Rotate by changing the source value and re-applying:
export PG_PASSWORD=$(openssl rand -hex 32)
./target/release/stratum apply -y -c examples/probes/postgres.strat -s /tmp/probe-postgres.json
The new value hashes differently, so diff_observed sees a marker change and emits an Update step on the container. Docker tears down and recreates the container with the new env var. The plan output shows the hash prefix changing:
~ docker_container.stratum-postgres
~ env.POSTGRES_PASSWORD: <secret:pg_password sha:f7c3bc> -> <secret:pg_password sha:9a1e44>
No plaintext on either side of that line, in the CLI output or in state.
Embedding a secret inside a larger string
A connection string is the common case where a bare secret.X.value doesn't fit — the password is one piece of a URL, not the whole leaf. Use ${...} interpolation:
resource "docker_container" "vortex-api" {
host = host.droplet.addr
image = "vortex-api:dev"
networks = ["stratum-edge"]
env = {
DATABASE_URL = "postgresql://app:${secret.pg_password.value}@stratum-postgres:5432/vortex"
}
}
At eval time the placeholder is replaced with the plaintext, so the provider receives a working connection string. At redaction time the substring redactor swaps the plaintext for an inline marker, so state ends up with:
"DATABASE_URL": "postgresql://app:<secret:pg_password:sha256:f7c3bc...>@stratum-postgres:5432/vortex"
Same drift behavior as exact-match: the marker on the state side compares equal to the plaintext on the observed side via the hash, and no perpetual update.
Whole-file secrets
For values too big or too binary to fit in a single env var — a Firebase service-account JSON, an age-encrypted key, a TLS bundle — use system_secret_file instead. The kind accepts a secret ref directly in content; state stores only the file's sha256 plus its permissions.
secret "firebase_sa" {
from_file = "~/.config/vortex/firebase-sa.json"
}
resource "system_secret_file" "firebase-sa" {
host = host.droplet.addr
path = "/etc/vortex/firebase-sa.json"
content = secret.firebase_sa.value
mode = "0400"
}
resource "docker_container" "vortex-api" {
host = host.droplet.addr
image = "vortex-api:dev"
volumes = ["/etc/vortex/firebase-sa.json:/app/firebase-sa.json:ro"]
env = {
GOOGLE_APPLICATION_CREDENTIALS = "/app/firebase-sa.json"
}
}
The pattern is: drop the file with system_secret_file, mount it into the container as a read-only volume, point the app at it via a non-sensitive env var. State holds sha256 + mode + owner + group for the file — never the bytes. Re-applying with the same contents is a no-op via the sha-match check; rotating the secret changes the sha and triggers a re-upload (and a container recreate if the mount is bind-mounted, which it is here).
What about plan-only review?
If you want to share a config or review one without populating the env, run plan --allow-unresolved-secrets:
./target/release/stratum plan --allow-unresolved-secrets \
-c examples/probes/postgres.strat \
-s /tmp/probe-postgres.json
Unset env vars become the placeholder string <unresolved-secret:NAME> and flow through the plan as a normal string. apply refuses to execute any plan containing such a placeholder — the flag is plan-only by design. See plan --allow-unresolved-secrets.
What's next
- Full reference for the syntax and semantics: Secrets.
- A real probe using this pattern with two secrets in one container:
examples/probes/deployd.strat(admin token + webhook secret, bothfrom_env). - For values you don't mind printing (debug toggles, public keys), add
sensitive = falseinside thesecretblock — the value still flows but is never added to the redaction map.