Terraform in Practice #6 Secrets Management: SSM vs Secrets Manager and Write-Only Arguments
The final check in the last part was uncomfortable. Even with sensitive set, the DB password sits in state as plaintext, and in tfvars as plaintext too. This part solves the problem in three steps: decide where the secret lives (the store), make Terraform handle the secret without recording it (write-only), and set up the path by which the application receives it (runtime lookup). Drop any one of the three and plaintext survives somewhere.
Choosing a store: SSM Parameter Store vs Secrets Manager #
On AWS there are two candidates for a secret store.
| Aspect | SSM Parameter Store | Secrets Manager |
|---|---|---|
| Pricing | Standard parameters are free | Monthly fee per secret + API calls |
| Encryption | SecureString (KMS) | KMS encryption by default |
| Automatic rotation | None (build it yourself) | Built-in rotation integrated with RDS and others |
| Best fit | Config values, small-scale secrets | DB credentials that need rotation |
The deciding factor is rotation. If you have a requirement to rotate passwords automatically on a schedule, Secrets Manager has that capability built in; if not, the free SSM SecureString is enough. Following the cost principle, this practice series goes with SSM — and since the code structure is the same either way, migrating later is not hard.
ephemeral and write-only: values that leave no record #
Terraform 1.10 and 1.11 shipped language features aimed squarely at this problem. There are two pieces.
- Ephemeral resources: one-shot values that are never recorded in plan or state.
ephemeral "random_password"creates a password that exists only during the run. - Write-only arguments: arguments that only pass a value to the provider without recording it.
aws_db_instancehaspassword_wo, which — unlike the regularpasswordargument — is never stored in state.
Combine the two and the password’s entire journey ends outside the record.
# secrets.tf (new file)
ephemeral "random_password" "db" {
length = 24
special = false
}
resource "aws_ssm_parameter" "db_password" {
name = "/myapp/${var.env}/db-password"
type = "SecureString"
value_wo = ephemeral.random_password.db.result
value_wo_version = 1
}# db.tf, updated
resource "aws_db_instance" "main" {
# ... everything else stays as in the last part ...
password_wo = ephemeral.random_password.db.result
password_wo_version = 1
}Delete password = var.db_password and variable "db_password" from the last part. The password is now neither chosen by a human nor written in any file. It is generated during the run, delivered to RDS and SSM, and gone. Run terraform state show aws_db_instance.main again and the password is nowhere to be seen.
It is worth understanding why the unfamiliar _wo_version companion exists. A write-only value is not in state, so Terraform cannot detect changes to it. When you want to rotate the password, you do not change the value — you bump password_wo_version from 1 to 2. The version number change shows up in the plan, which is what makes the provider deliver the new value.
The application reads at runtime #
Even with the secret safely in SSM, if you pull it out in user_data and plant it in a file, a plaintext copy appears inside the instance and you are back where you started. The principle is that the app reads directly from the store when it starts.
# In the application startup script
DB_PASSWORD=$(aws ssm get-parameter \
--name "/myapp/dev/db-password" \
--with-decryption \
--query Parameter.Value --output text)The advantage of this structure is that secret rotation is decoupled from deployment. Change the value in SSM and restart the app — done, with no need to rebuild the image or the launch template. The remaining puzzle piece is permissions. For the instance to call ssm:GetParameter it needs an IAM role, and right now the instance has none. That is exactly the subject of the next part, where SSH-free access (Session Manager) gets solved along the way.
Remaining records and risks #
Let us be honest about what this configuration erased and what remains.
- Erased: plaintext passwords in code, tfvars, plan, and state. There is no longer even a step where a human knows the password.
- Remaining: the SSM parameter (stored KMS-encrypted) and the inside of RDS. These two are where a secret is supposed to live, and access is controlled with IAM.
- Still true: the state bucket access control from Basics #9 matters as much as ever. Write-only removed the DB password, but state still contains the entire structure of your infrastructure.
Recap #
What we covered in this post:
- The store selection criterion is rotation. If you need it, Secrets Manager; if not, the free SSM SecureString is enough
- Ephemeral resources create values that exist only during the run, and write-only arguments (password_wo, value_wo) only pass values through without leaving them in state. Both are stable features as of Terraform 1.11
- Rotating a write-only value is triggered by bumping
_wo_version, not by changing the value — with no value in state, change detection is impossible - The app reads secrets from the store at runtime. Planting them in user_data resurrects plaintext copies
- The secret now exists only inside SSM and RDS, and the remaining task is the IAM that grants read access
In the next post (#7 IAM as Code), we complete SSM access and Session Manager connectivity with an instance role, and build the OIDC federation that lets GitHub Actions into AWS without access keys.