Mission 4.2 · Stage 4 — Data & Secrets
Secrets from Secrets Manager into ECS — Never Through State
Goal: ShopLite 1.2.0 connects to Postgres over verified TLS using credentials ECS injects from Secrets Manager at task start, and you can prove the password appears nowhere in Terraform.
By the end of this mission
- Know the three levels of secret handling in Terraform and which to use
- Inject secrets into ECS containers with
secrets+valueFromJSON keys - Grant the execution role exactly one secret
- Verify state contains no secret values
Part 1
Understand it first
Three levels of secrets in Terraform
LEVEL 1, avoid: the secret is a Terraform value, from a variable or random_password. It ends up in state and often in CI logs or tfvars. sensitive = true only hides it from terminal output.
LEVEL 2, best when available: the cloud service generates and stores the secret itself. RDS manage_master_user_password is one example; ElastiCache, Redshift and others have equivalents. Terraform handles only a reference (an ARN).
LEVEL 3, for everything else: EPHEMERAL values (Terraform 1.10+) and WRITE-ONLY arguments (1.11+). An ephemeral resource (for example ephemeral "random_password") exists only during a run and is never written to state or plan files, and write-only arguments such as password_wo accept ephemeral values without persisting them. Use this when a resource needs a secret you supply and the service can't manage one for you.
How ECS injects secrets
A container definition's secrets list maps an environment variable name to a valueFrom ARN. When a task starts, the ECS agent, using the EXECUTION role, reads the secret and sets the variable inside the container. The value never appears in the task definition, the console, describe-task-definition, or Terraform.
For JSON secrets, append a key: <secret-arn>:password:: extracts just the password field. The two trailing colons are version stage and version ID, left empty to mean 'current'.
Rotation has a runtime consequence
RDS rotates the managed master password (every 7 days by default). ECS injects secrets only at task START, so running tasks keep the old password. Existing pooled connections keep working, but any NEW connection after rotation fails until tasks restart. Production answers: create a dedicated app database user whose credentials you rotate deliberately, use IAM database authentication, or have the app re-read the secret on authentication failure. For ShopLite in dev, a periodic redeploy is enough, but know the trap.
Part 2
Your project after this mission · 7 files change
- app/
- Dockerfilemodified
- package-lock.jsonnew
- package.jsonmodified
- server.jsmodified
- infra/
- alb.tf
- autoscaling.tf
- backend.tf
- database.tf
- ecr.tf
- ecs.tfmodified
- iam.tfmodified
- locals.tf
- logs.tf
- network.tf
- outputs.tf
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvarsmodified
- variables.tf
- versions.tf
Part 3
Build it, step by step
- 1
Teach ShopLite to talk to Postgres
Add the
pgdriver (npm install pginapp/). The pool reads connection details from environment variables and verifies the server certificate against Amazon's RDS CA bundle. Withrds.force_ssl = 1, unencrypted connections are refused anyway, and verifying the CA also protects against impersonation. A new/healthz/dbendpoint proves the whole chain. The handler becomesasync.app/server.jsadd to filejs Add the imports and pool at the top; make the handler async and add the /healthz/db route.
import fs from "node:fs"; import pg from "pg"; const pool = process.env.DB_HOST ? new pg.Pool({ host: process.env.DB_HOST, database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, port: 5432, max: 5, ssl: { ca: fs.readFileSync("/app/rds-ca.pem", "utf8") }, }) : null; const server = http.createServer(async (req, res) => { if (req.url === "/healthz") return json(res, 200, { status: "ok" }); if (req.url === "/healthz/db") { if (!pool) return json(res, 503, { db: "not configured" }); try { const { rows } = await pool.query("select version()"); return json(res, 200, { db: "ok", version: rows[0].version.split(",")[0] }); } catch (err) { return json(res, 503, { db: "error", message: err.message }); } } // ... existing /products and fallback routes - 2
Bake the CA bundle into the image
ADD <url>downloads the RDS global CA bundle at build time. Files added this way are root-owned with restrictive permissions, sochmod 644lets the unprivilegednodeuser read it. That's an easy bug to miss until the container crashes with EACCES.npm ci --omit=devinstalls exactly the lockfile's production dependencies.app/Dockerfilewhole filedocker FROM node:22-alpine WORKDIR /app ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /app/rds-ca.pem RUN chmod 644 /app/rds-ca.pem COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY server.js ./ USER node EXPOSE 8080 CMD ["node", "server.js"]terminal$ cd ../app && npm install pg && npm pkg set version=1.2.0 && cd ../infradocker buildx build --platform linux/arm64 -t "$REPO:1.2.0" --push ../app── expected output ──=> pushing 123456789012.dkr.ecr.ap-south-1.amazonaws.com/shoplite/api:1.2.0 - 3
Let the execution role read exactly one secret
The permission goes on the EXECUTION role, because ECS fetches the secret before your code starts. The resource is this one secret's ARN, not
*. The RDS-managed secret is encrypted with the AWS-managedaws/secretsmanagerkey, so no extra KMS permission is needed.infra/iam.tfadd to filehcl locals { db_secret_arn = aws_db_instance.main.master_user_secret[0].secret_arn } data "aws_iam_policy_document" "ecs_execution_secrets" { statement { actions = ["secretsmanager:GetSecretValue"] resources = [local.db_secret_arn] } } resource "aws_iam_role_policy" "ecs_execution_secrets" { name = "read-db-secret" role = aws_iam_role.ecs_execution.id policy = data.aws_iam_policy_document.ecs_execution_secrets.json } - 4
Pass connection details: config as env, credentials as secrets
Host and database name aren't secret, so they go in
environment. Username and password come from the secret's JSON keys throughsecrets.infra/ecs.tfadd to filehcl Inside container_definitions: extend environment, add a secrets list.
environment = [ { name = "PORT", value = tostring(var.app_port) }, { name = "APP_VERSION", value = var.image_tag }, { name = "DB_HOST", value = aws_db_instance.main.address }, { name = "DB_NAME", value = aws_db_instance.main.db_name }, ] secrets = [ { name = "DB_USER", valueFrom = "${local.db_secret_arn}:username::" }, { name = "DB_PASSWORD", valueFrom = "${local.db_secret_arn}:password::" }, ] - 5
Deploy 1.2.0
Set
image_tag = "1.2.0"interraform.tfvarsand apply. The plan shows the new policy, a new task definition revision, and the service update.terminal$ terraform applycurl -s "$(terraform output -raw api_url)/healthz/db"; echo── expected output ──Plan: 2 to add, 1 to change, 1 to destroy....Apply complete! Resources: 2 added, 1 changed, 1 destroyed.{"db":"ok","version":"PostgreSQL 16.6 on aarch64-unknown-linux-gnu"} - 6
Prove the password is nowhere in Terraform
Search the entire remote state and the registered task definition for the actual password. Both searches find zero matches. The state contains the secret's ARN, and that's all.
terminal$ PW=$(aws secretsmanager get-secret-value --secret-id "$(terraform output -raw db_master_secret_arn)" --query SecretString --output text | jq -r .password)terraform state pull | grep -c -- "$PW"aws ecs describe-task-definition --task-definition shoplite-dev-api | grep -c -- "$PW"unset PW── expected output ──00
Checkpoint — you should now have
- ✓
/healthz/dbreturns{"db":"ok", ...}through the ALB. - ✓The execution role can read exactly one secret; the task role has no Secrets Manager access.
- ✓
grepfor the real password finds 0 matches in state and in the task definition. - ✓You can explain why rotation affects NEW connections from long-running tasks.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Ask for a JSON key that doesn't exist
Change the password valueFrom to "${local.db_secret_arn}:passwd::" and apply. Then check why the new tasks stopped.
Break #2
Put the permission on the wrong role
Move the ecs_execution_secrets policy from the execution role to the task role (role = aws_iam_role.api_task.id) and apply.
Part 5
Interview questions from this mission
How can a resource that needs a password be managed by Terraform without the password landing in state?
How do ECS tasks get secrets, and which IAM role needs permission?
RDS rotated the master password and new database connections from your service start failing. Why, and how do you fix it long-term?