Sensitive Data and Team Workflows
The idea in one sentence
Section titled “The idea in one sentence”Marking a variable or output sensitive = true hides its value from CLI output, but the value is still stored in plaintext inside the state file, so protecting state itself is a separate, and more important, problem.
What sensitive = true actually does
Section titled “What sensitive = true actually does”You can mark a variable or an output as sensitive:
variable "db_password" { type = string sensitive = true}
output "db_password" { value = var.db_password sensitive = true}With this in place, terraform plan and terraform apply print (sensitive value) instead of the real password in the terminal, and it is redacted from CI logs the same way. This is a genuinely useful feature — it stops a secret from ending up in a build log that a dozen people can read, or on a screen during a pairing session.
It is also frequently misunderstood. sensitive = true is a display-redaction feature, not an encryption feature. The actual value is still written into terraform.tfstate in plaintext, exactly as covered in the first lesson of this module. Anyone who can read the raw state file can read the password regardless of how many resources or variables were marked sensitive along the way.
Actually protecting the state file
Section titled “Actually protecting the state file”Since redaction does not protect the file at rest, protection has to happen at the storage layer instead:
- Rely on Cloud Storage’s server-side encryption for the state bucket. GCS encrypts objects at rest by default, and you can layer a customer-managed Cloud KMS key on top via the backend’s
kms_encryption_keyargument if your organization requires it. - Restrict who can read the bucket with IAM, scoped as tightly as possible. Grant
roles/storage.objectVieweron the specific state bucket to the specific principals who need it — a CI service account, a small platform team — rather than granting broad access at the project level. Treat the state file itself as a secret, because for practical purposes it is one.
Where possible, avoid putting long-lived secrets into Terraform configuration or state at all. Instead of a variable holding a raw password, pull the value in at apply time from a secrets manager:
data "google_secret_manager_secret_version" "db_password" { secret = "db-password"}
resource "google_sql_user" "app" { name = "app_admin" instance = google_sql_database_instance.main.name password = data.google_secret_manager_secret_version.db_password.secret_data}This does not make the value vanish from state entirely — the resource attribute Terraform sets still gets recorded the same way it always does. What it does change is where the secret’s long-term source of truth lives: Secret Manager, with its own access policies, audit logging, and rotation story, instead of a Terraform variable that someone has to remember to pass in securely every single run.
nonsensitive(): deliberately un-redacting a value
Section titled “nonsensitive(): deliberately un-redacting a value”Sometimes sensitive = true propagates further than you want — any expression that touches a sensitive value becomes sensitive too, even if the result genuinely is not secret. The nonsensitive() function strips that marking from a single value, deliberately, for exactly that case. Real proof with terraform console, first showing the redaction, then removing it:
$ echo 'var.db_password' | terraform console(sensitive value)$ echo 'nonsensitive(var.db_password)' | terraform console"S3cretPassword!"That is the entire mechanism: nonsensitive() does not add any protection and does not remove the value from state either way — it only changes whether Terraform’s redaction applies to this particular expression’s result going forward. Reach for it only when you are certain the value is not actually secret in this context; calling it on an actual secret just to quiet a warning defeats the one guardrail sensitive = true provides.
Ephemeral values: excluded from state and plan by construction
Section titled “Ephemeral values: excluded from state and plan by construction”Terraform 1.10 added ephemeral resources, variables, and outputs — values that exist only for the duration of a single Terraform operation and are never written to state or a saved plan file, not “written but redacted” the way sensitive = true values are. Mark a variable ephemeral like this:
variable "session_token" { type = string ephemeral = true}The google provider ships a matching ephemeral resource for reading a secret straight out of Secret Manager without ever recording it, documented with a real, complete example (registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/secret_manager_secret_version): ephemeral "google_secret_manager_secret_version" "example" { project = "my-project"; secret = "my-secret"; version = "latest" }, whose .secret_data is then routed into another resource’s write-only argument. This course has no live Secret Manager secret to read from, so the offline proof below uses the built-in pattern instead — a short-lived value generated fresh on every run, never persisted anywhere:
ephemeral "random_password" "db" { length = 16}Because an ephemeral value is never persisted, Terraform enforces where it is allowed to flow, at plan time, with a real error — not just a convention. Proof: passing ephemeral.random_password.db.result into an ordinary (non-write-only) resource argument:
$ terraform planError: Invalid use of ephemeral value
with terraform_data.marker, on main.tf line 15, in resource "terraform_data" "marker": 15: input = ephemeral.random_password.db.result
Ephemeral values are not valid for "input", because it is not a write-onlyattribute and must be persisted to state.That error names the actual rule directly: an ephemeral value may only flow into an attribute the provider has explicitly declared write-only, because a write-only attribute is the one kind of argument Terraform guarantees it will never persist. A second real constraint, proven the same way: an ephemeral output is not allowed at all in the root module —
$ terraform planError: Ephemeral outputs are not allowed in context of a root module— ephemeral outputs exist so a child module can hand an ephemeral value back up to whatever called it (to be consumed by that caller’s own write-only argument or further ephemeral chain), not so the root module can print one.
Write-only arguments (_wo): the other half of the mechanism
Section titled “Write-only arguments (_wo): the other half of the mechanism”Terraform 1.11 added write-only arguments — resource attributes, conventionally suffixed _wo and paired with a companion _wo_version integer, that a provider promises never to write into state or a saved plan at all. This is not a hypothetical for the google provider: google_secret_manager_secret_version genuinely declares the pair, per the real provider docs — “secret_data_wo - (Optional, Write-Only) The secret data. … Note: This property is write-only and will not be read from the API.” / “secret_data_wo_version - (Optional) Triggers update of secret_data_wo write-only. Increment this value when an update to secret_data_wo is needed.” — and the resource’s own top-level note confirms it directly: “All arguments marked as write-only values will not be stored in the state: secret_data_wo.”
Confirmed directly against the pinned hashicorp/google 8.4.0 provider schema — no GCP credentials needed, terraform providers schema -json only reads what init already downloaded, and this same check found 7 resources across the whole provider with a write-only attribute (google_secret_manager_secret_version, google_sql_database_instance, google_sql_user, google_alloydb_user, google_compute_ssl_certificate, google_compute_region_ssl_certificate, google_compute_vpn_tunnel):
$ terraform providers schema -json | python3 -c "import json,sysd = json.load(sys.stdin)attrs = d['provider_schemas']['registry.terraform.io/hashicorp/google']['resource_schemas']['google_secret_manager_secret_version']['block']['attributes']for k, v in attrs.items(): if v.get('write_only') or k.endswith('_wo_version'): print(k, v)"secret_data_wo {'type': 'string', 'description_kind': 'plain', 'optional': True, 'write_only': True}secret_data_wo_version {'type': 'number', 'description_kind': 'plain', 'optional': True}What genuinely does need real credentials is applying it — creating a real Secret Manager secret version needs a live GCP project. Here is the honest question instead: is write-only support universal across every resource, or opt-in? Checking hashicorp/random’s random_password — a resource this course can fully apply offline — with the identical query:
$ terraform providers schema -json | python3 -c "import json,sysd = json.load(sys.stdin)attrs = d['provider_schemas']['registry.terraform.io/hashicorp/random']['resource_schemas']['random_password']['block']['attributes']print([k for k,v in attrs.items() if v.get('write_only')])"[]Zero. random_password has no write-only attribute at all, and neither does the built-in terraform_data. The real picture, proven both ways above: write-only support is not a Terraform-wide guarantee every resource gets for free at 1.11 — a provider author adds it one resource, one attribute at a time, and as of these pinned versions exactly 7 google resources have it while none of this course’s fully-offline-appliable resources do. What is fully provable offline end-to-end, proven above, is the other half of the mechanism that makes write-only arguments useful in the first place: the ephemeral-value enforcement that decides what is even allowed to flow into a _wo argument once a resource has one.
Where a shared backend stops being enough
Section titled “Where a shared backend stops being enough”Earlier lessons in this module got state onto a shared GCS backend with built-in locking, which already fixes the worst team problems — divergent local files and corrupted concurrent writes. But it does not fully solve how a growing team should work together. If everyone still runs terraform apply from their own laptop whenever they feel like it, concurrent applies simply queue up behind the lock instead of colliding, and there is still no natural point where a second person reviews a change before it takes effect against real infrastructure.
That gap — a reviewable, auditable place for changes to be approved before apply runs against shared state — is exactly what CI/CD for infrastructure closes, and it is covered later in this course.
flowchart LR varInput["variable db_password (sensitive = true)"] -->|redacted in CLI output| cli["terraform plan / apply output"] varInput -->|value still written| state["terraform.tfstate (plaintext)"] secretManager["Secret Manager secret version"] -->|read via data source at apply time| resource["google_sql_user.app"] resource --> state
Mental Model
Section titled “Mental Model”Three mechanisms in this lesson look similar from a distance — sensitive, ephemeral, and write-only arguments — but they solve genuinely different problems, and the difference is exactly what gets written to state. sensitive = true writes the value to state in full, and only redacts your terminal — proven above, the raw value is one state pull | grep away. ephemeral values are never written to state at all, and Terraform enforces that at plan time with a real error the moment you try to route one into an ordinary attribute — proven above. Write-only arguments are the provider-side half of that same guarantee: a _wo attribute is the one place an ephemeral value is allowed to land, specifically because the provider has promised never to persist it — proven above, google_secret_manager_secret_version.secret_data_wo (and six sibling resources across the provider) really exist in the pinned schema. Read in that order — display-only, structurally-excluded, provider-promised-never-persisted — the three stop looking like three flavors of “secret” and start looking like three different answers to “does this reach disk.”
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
sensitive = true | Stops a value scrolling past in a shared terminal or CI log (proven above: (sensitive value)) | Zero effect on state — proven above, the raw value is still one state pull away |
ephemeral variable/resource | Structurally cannot reach state or a saved plan — enforced by a real error, not a convention (proven above); google even ships ephemeral "google_secret_manager_secret_version" to read a secret without ever recording it | Only usable in a narrow set of contexts (write-only arguments, provider config, other ephemeral values); a root-module ephemeral output is rejected outright (proven above) |
Write-only (_wo) arguments | The value genuinely never touches state, even transiently — proven above, 7 real google resources declare one | Opt-in per resource per provider, not a blanket 1.11 guarantee — proven above, random_password has none at all; real use needs a live GCP project and credentials |
nonsensitive() | Lets a value that is not actually secret escape unnecessary redaction (proven above via console) | Removes the one guardrail sensitive = true provides if misapplied to something that actually is secret |
Common Misconceptions
Section titled “Common Misconceptions”- “
sensitive = truekeeps a value out of the state file.” Proven above twice: the originalstate pullclaim from earlier in this module, and the baseline redaction example — it only changes CLI display, never storage. - “
ephemeral = trueis just a stricter version ofsensitive = true.” Proven above: they are different mechanisms entirely.sensitiveredacts display but still writes to state;ephemeralis structurally barred from state and plan, enforced by a real “Ephemeral values are not valid for …” error the moment you try to persist one. - “Any resource can accept an ephemeral value if you just reference it directly.” Proven above:
terraform planrefuses with a specific, named reason — the target attribute is “not a write-only attribute and must be persisted to state.” Only a_woattribute a provider has explicitly built qualifies. - “Write-only arguments are a general Terraform 1.11 feature you can use with any resource.” Proven above by dumping two real provider schemas:
google_secret_manager_secret_versiongenuinely declaressecret_data_wo/secret_data_wo_version(one of 7googleresources that do), whilerandom_passwordin the exact same pinned toolchain declares zero write-only attributes — it is opt-in, per resource, per provider, not a capability every resource gets for free at 1.11.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that touches secrets or ephemeral data, check for:
sensitive = truepresented as “the fix” for a credential, with no mention of the backend still holding it in plaintext — proven above, that is exactly the gap this lesson closes.- An
ephemeralvalue referenced directly in a plain (non-_wo) resource attribute, assumed to “just work” because it compiles — proven above,terraform planrejects this with a specific error; it never gets toapply. - A root-module
outputmarkedephemeral = true, intended to hand a generated secret to something outside Terraform — proven above, this is rejected outright; ephemeral outputs only exist for a child module handing a value back up to its caller. nonsensitive()wrapped around a value that genuinely is secret, just to silence a “sensitive value in output” error, instead of marking the outputsensitive = true— this defeats the one protectionsensitiveprovides for no real gain.- A
secret_data_wo-style argument used without its pairedsecret_data_wo_versionbump ongoogle_secret_manager_secret_version, then a report that “the new secret value never gets applied” — proven above, the version number is the only signal Terraform has that a new write-only value needs sending, since it never stores the value itself to diff against.
🐛 Spot the bug
ephemeral "random_password" "db" {length = 16}
resource "google_secret_manager_secret_version" "db" {secret = google_secret_manager_secret.db.idsecret_data = ephemeral.random_password.db.result}Show the answer
Proven above with the real error for the same mistake on terraform_data.marker: secret_data on google_secret_manager_secret_version is an ordinary, persisted attribute, not a write-only one — Terraform rejects this plan with “Ephemeral values are not valid for …, because it is not a write-only attribute and must be persisted to state.” The fix is not to give up on generating the secret ephemerally; it is to route it through the resource’s actual write-only pair instead: secret_data_wo = ephemeral.random_password.db.result plus a secret_data_wo_version that increments whenever the value should change. Writing to the plain secret_data argument would not even get as far as reaching GCP — the plan itself fails first, which is the safety net working as designed, not a bug to route around with sensitive = true (that would only redact display; the ephemeral value still could not be persisted to a non-write-only attribute).