Secrets and Credential Management
The idea in one sentence
Section titled “The idea in one sentence”Provider authentication should come from Application Default Credentials or an identity mechanism rather than a downloaded key typed into a file, and even pulling an application secret from a real secrets manager at apply time does not fully keep it out of Terraform state.
Never hardcode credentials in a provider block
Section titled “Never hardcode credentials in a provider block”It is possible to authenticate the google provider with a downloaded service account key JSON file, referenced directly from the provider "google" block. It also works, in the narrow sense that terraform plan runs. It is still wrong:
# Anti-pattern — never do thisprovider "google" { project = "acme-prod-123456" region = "us-central1" credentials = file("service-account-key.json")}A key file referenced this way gets committed to git the first time someone forgets it is there, and it lives on in every clone and every commit in history from that point forward, whether or not it is later deleted from the current revision. The fix is to give the provider no credentials at all in configuration, and let it resolve them from the environment it is running in:
# The provider block declares no credentials.# Authentication comes from Application Default Credentials:# a local gcloud auth, an attached GCE/CI service account, or# Workload Identity Federation in CI.provider "google" { project = "acme-prod-123456" region = "us-central1"}Locally, that usually means Application Default Credentials set up through gcloud auth application-default login. On a GCE instance or in most managed compute, it means a service account already attached to the runtime, discovered automatically. In CI, it means the Workload Identity Federation covered in the CI/CD lesson — google-github-actions/auth populates Application Default Credentials before Terraform ever runs, so the provider block finds credentials without a single key file ever being written down anywhere.
Pulling a secret in via a data source, not a variable
Section titled “Pulling a secret in via a data source, not a variable”A separate question from provider authentication is application secrets — a database password, a third-party API key — that a resource needs as an argument. Defining that secret as a Terraform variable makes the variable declaration the thing you have to treat as the source of truth, which means someone still has to type the real value into a tfvars file, a CI secret, or a prompt at apply time.
The better pattern reads the current value directly from a real secrets manager through a data source, at apply time:
data "google_secret_manager_secret_version" "api_key" { secret = "third-party-api-key"}
resource "google_cloud_run_v2_service" "webhook_handler" { name = "webhook-handler" location = "us-central1"
template { containers { image = "us-central1-docker.pkg.dev/acme-prod-123456/app/webhook-handler:latest"
env { name = "API_KEY" value = data.google_secret_manager_secret_version.api_key.secret_data } } }}This is not a free pass, and it is worth being precise about why. The State Management module already covered that marking a variable sensitive = true only redacts it from CLI output — the value still lands in terraform.tfstate in plaintext. Reading a secret through a data source does not fully avoid that either: data source results are cached in state too, so the resolved secret_data still ends up recorded there, the same as a sensitive variable would. What the data-source pattern actually improves is the secret’s lifecycle everywhere else: the real value is never typed into a variable, never sits in a .tfvars file someone might commit by accident, and rotating it in Secret Manager needs no Terraform change at all — the next apply simply reads whatever is current. Secret Manager is the source of truth; Terraform just reads from it. Protecting the state backend, covered in the State Management module, is still the part that cannot be skipped.
Keeping the secret out of state entirely: ephemeral values and write-only arguments
Section titled “Keeping the secret out of state entirely: ephemeral values and write-only arguments”Terraform 1.10 and 1.11 closed the gap the previous section had to concede. Ephemeral resources (1.10) are read fresh during every phase of an operation and are never written to state or the plan file. Write-only arguments (1.11) let a resource accept a value that the provider uses once and then discards, instead of recording it as an attribute.
Write-only arguments are recognisable by a _wo suffix, and each is paired with a _wo_version argument. Terraform never sees the value, so it has no way to notice that the value changed — incrementing the version number is what tells it to send the value again.
# read fresh on every operation, never persisted to state or the planephemeral "google_secret_manager_secret_version" "db_password" { secret = "app-db-password"}
resource "google_sql_user" "app" { name = "appuser" instance = google_sql_database_instance.main.name
password_wo = ephemeral.google_secret_manager_secret_version.db_password.secret_data password_wo_version = 1}Compare this against the data source version above, line for line. data.google_secret_manager_secret_version caches the secret in state; ephemeral.google_secret_manager_secret_version does not. A plain password argument is recorded as a resource attribute; password_wo is not. Rotating the secret in Secret Manager and incrementing password_wo_version pushes the new value to Cloud SQL without either value ever landing in terraform.tfstate.
The same pair works in the writing direction: google_secret_manager_secret_version accepts secret_data_wo and secret_data_wo_version, so Terraform can create a secret version without storing the secret it just wrote.
Both halves are opt-in per resource, so check the provider documentation before assuming they exist — the provider has to ship the ephemeral resource and the write-only argument. Where a resource has not implemented them yet, the data source pattern above is still the fallback, and protecting the state backend stays non-negotiable under every one of these approaches.
Proving both write-only arguments really exist, with a full real end-to-end plan
Section titled “Proving both write-only arguments really exist, with a full real end-to-end plan”password_wo/password_wo_version on google_sql_user, and secret_data_wo/secret_data_wo_version on google_secret_manager_secret_version, both really exist — proven with a real terraform validate against google provider 8.4.0:
variable "db_password" { type = string ephemeral = true sensitive = true}
variable "api_key_value" { type = string ephemeral = true sensitive = true}# terraform.tfvars — harness fixture only, so this lesson's proof can run# offline; a real repo never commits secret values this way (see the# .gitignore checklist below) — supply real values via TF_VAR_* instead,# proven later in this section.db_password = "mock-secret-value"api_key_value = "mock-api-key-value"resource "google_sql_database_instance" "main" { name = "app-db" database_version = "POSTGRES_15" region = "us-central1" deletion_protection = false settings { tier = "db-f1-micro" }}
resource "google_sql_user" "app" { name = "appuser" instance = google_sql_database_instance.main.name
password_wo = var.db_password password_wo_version = 1}
resource "google_secret_manager_secret" "api_key" { secret_id = "third-party-api-key" replication { auto {} }}
resource "google_secret_manager_secret_version" "api_key" { secret = google_secret_manager_secret.api_key.id secret_data_wo = var.api_key_value secret_data_wo_version = 1}$ terraform validateSuccess! The configuration is valid.Unlike azurerm (proven in the sibling Azure course to require a real Azure session even to plan pure resource creation), google proceeds with fake-but-correctly-shaped service-account credentials — the same behavior this lesson’s cloud already showed in policy-as-code-and-static-analysis.mdx. Supplying both secrets only via TF_VAR_db_password/TF_VAR_api_key_value (no default, never committed) makes the entire proof real end to end:
$ TF_VAR_db_password=... TF_VAR_api_key_value=... terraform plan -out=tfplan -detailed-exitcode... # google_sql_user.app will be created + resource "google_sql_user" "app" { + password_wo = (write-only attribute) + password_wo_version = 1 ... }Plan: 4 to add, 0 to change, 0 to destroy.$ echo $?2And in terraform show -json of the saved plan, both write-only fields are genuinely absent — change.after.password_wo/change.after.secret_data_wo are null, after_sensitive marks both true, and searching the entire plan JSON for either literal secret value finds nothing at all:
"after": { "password_wo": null, "password_wo_version": 1 },"after_sensitive": { "password_wo": true }$ grep -o "mock-secret-value\|mock-api-key-value" plan.json# (no output — neither literal appears anywhere in the plan JSON)This is the strongest proof this course produces for write-only arguments across all three clouds: a real validate, a real full plan (not just a schema check), a real saved plan file, and a real, exhaustive search of the resulting JSON confirming both secrets are absent — made possible specifically because google is the one provider in this course pairing that can compute a genuine offline plan for pure resource creation.
A .gitignore checklist for a Terraform/Terragrunt repository
Section titled “A .gitignore checklist for a Terraform/Terragrunt repository”A short, deliberate .gitignore prevents most of the accidents above from ever reaching git in the first place:
# Terraform / Terragrunt .gitignore
# Never commit state — it can contain plaintext secrets and is the# canonical source of truth; committing it invites drift and leaks.*.tfstate*.tfstate.backup
# Local provider and module cache, regenerated by `terraform init`..terraform/
# May contain sensitive values depending on the file — judge case by# case rather than blanket-ignoring every *.tfvars in the repo.*.tfvars
# Terragrunt's local working-directory cache, regenerated automatically..terragrunt-cache/
# Downloaded service account key JSON files — should not exist in this# repo at all if Workload Identity Federation and ADC are used correctly.*-key.json*-sa.jsonA few of these deserve a one-line reason each:
*.tfstateand*.tfstate.backup— never commit state. Beyond the size and merge-conflict problems, state can contain plaintext secrets, as covered above, and it duplicates the job a remote backend already does properly..terraform/— this isinit’s local cache of downloaded providers and modules. It is large, machine-specific, and fully reproducible from.terraform.lock.hcl, so there is nothing worth versioning here.*.tfvars— treat this one case by case rather than blanket-ignoring it. Some teams deliberately commit a non-sensitiveenvironment.tfvarsthat just sets machine types or region names; the rule is “does this specific file contain a secret,” not “all.tfvarsfiles are always secret.”.terragrunt-cache/— Terragrunt’s equivalent of.terraform/, one per unit, regenerated on every run. It never needs to be committed, and committing it by accident tends to bloat a repository fast.- Downloaded service account key JSON files — a repo that follows the previous lesson’s guidance should not have any of these to begin with, but a name-pattern rule is cheap insurance against someone downloading one for a quick local test and forgetting to remove it.
flowchart LR bad["Downloaded key in a .tf / .tfvars file"] -->|committed by accident| leak["Leaked credential in git history"] sm["Secret Manager: source of truth"] -->|data source at apply time| attr["Resource attribute"] attr -->|still recorded| state["terraform.tfstate"]
Mental Model
Section titled “Mental Model”Every technique in this lesson answers the same underlying question at increasing strength: “where does this secret value exist, and for how long.” A downloaded key file exists forever, in git history, the weakest answer. A data source narrows where the value originates (Secret Manager, not a typed-in value) but not where it ends up — proven above, it still lands in state. ephemeral + write-only is the strongest answer, and this course’s GCP pairing is where it is proven most completely: a real validate, a real full plan, and a real, exhaustive search of the resulting plan JSON confirming absence — possible specifically because google can compute a genuine offline plan for pure resource creation, unlike azurerm in the sibling course.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Application Default Credentials / attached service account / Workload Identity Federation instead of a key file | No standing key exists to leak from a .tf/.tfvars file | Requires environment setup (a pool + provider + attribute condition for CI) before it works at all |
data source instead of a variable for a secret | Centralizes rotation in Secret Manager itself | Proven above: the resolved value still lands in state — a protected backend remains mandatory |
ephemeral + write-only (password_wo, secret_data_wo) | Proven above with the most complete evidence in this course: a real end-to-end plan confirming the value is absent from both the plan JSON and, by the same mechanism, state | Requires Terraform 1.10/1.11+ and per-resource provider support — not every argument on every resource has a _wo twin |
An ephemeral variable with no default, supplied only via TF_VAR_* | Proven above: the value appears nowhere in the plan JSON at all | Easy to accidentally undo by adding a default = literal for local testing convenience — the sibling AWS lesson proves exactly that mistake leaks the value back in |
Common Misconceptions
Section titled “Common Misconceptions”- “Marking a variable
sensitive = truekeeps it out of state.” Covered in this lesson’s own baseline content and the State Management module:sensitiveonly redacts CLI output. The value still lands interraform.tfstatein plaintext. - “A
datasource Secret Manager lookup fully replaces the need forephemeral/write-only.” Proven above by design:datasource results are cached in state exactly like a resource attribute —ephemeralexists specifically becausedatadoes not solve this. - “Write-only arguments work on any resource, since the feature exists in the provider.” Proven above by direct schema validation:
password_woexists ongoogle_sql_userandsecret_data_woexists ongoogle_secret_manager_secret_versionspecifically, because each resource’s schema had to add it — the feature existing in Terraform core does not mean every argument on every resource has a_wocounterpart. - “An offline
terraform plancan never fully prove a write-only argument works, since nothing is really created.” Proven above: this course’sgooglepairing produced a complete, real proof chain —validate,planwith a real exit code, a saved plan file, and an exhaustive search ofshow -jsonconfirming both secret literals are absent — without ever needing a real GCP project to exist.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing AI-generated Terraform that handles GCP secrets, check for:
- A
default =literal on a variable markedephemeral = trueorsensitive = true— the sibling AWS lesson proves this defeats the entire purpose for the ephemeral case, leaking intoshow -json. password_wo/_wo-style arguments used without checking the specific resource’s schema — proven above viaterraform validate, the argument must exist on that exact resource; a plausible-sounding_woname that isn’t real fails validation immediately.- A
password_wo/secret_data_wowith no matching_wo_versionbump in a PR that is supposed to rotate a credential — since the value itself isn’t tracked in state, an unchanged version number means the provider silently keeps the old value. - An ephemeral value assigned to an ordinary (non-write-only, non-ephemeral) argument or a normal output — the language rejects this outright, a Terraform-level type error rather than a subtle runtime leak.
- Application code that reads a secret from a
google_secret_manager_secret_versiondata source (notephemeral) and treats it as equivalent to write-only handling — proven above,dataresults are cached in state; only theephemeral/write-only pairing avoids that.
🐛 Spot the bug
variable "db_password" {type = stringephemeral = truesensitive = truedefault = "changeme-in-ci"}
resource "google_sql_user" "app" {name = "appuser"instance = google_sql_database_instance.main.namepassword_wo = var.db_passwordpassword_wo_version = 1}Show the answer
The default = "changeme-in-ci" looks like an obviously-fake placeholder, which is exactly what makes this bug easy to miss in review — but it is still a literal string committed to version control. The sibling AWS lesson proves the exact mechanism this breaks: ephemeral = true does not stop a literal default from being echoed into terraform show -json’s configuration and variables blocks. If this “placeholder” is ever left as the real default in an environment where nobody remembers to override it with -var/TF_VAR_db_password, the Cloud SQL user gets provisioned with a literal, publicly-visible password string — from a variable that looked ephemeral-safe. The fix is no default at all: supply the value only via TF_VAR_db_password or -var, exactly as this lesson’s real end-to-end plan proof does.