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:

  1. The provider receives the plaintext in env.POSTGRES_PASSWORD and runs docker run -e POSTGRES_PASSWORD=<value> .... The value lands inside the container's process environment.
  2. 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_PASSWORD in state ends up as {"__secret": "pg_password", "__secret_sha256": "sha256:f7c3bc..."}.
  3. state.save writes 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, both from_env).
  • For values you don't mind printing (debug toggles, public keys), add sensitive = false inside the secret block — the value still flows but is never added to the redaction map.