Command Palette

Search for a command to run...

Hectal

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.

40 min Secrets Manager: ~$0.40/month per secret (the RDS-managed one) 6 steps 2 break-it drills

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 + valueFrom JSON 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.

Secret flow at task startdiagram
Rendering diagram…

Part 2

Your project after this mission · 7 files change

shoplite/
  • 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. 1

    Teach ShopLite to talk to Postgres

    Add the pg driver (npm install pg in app/). The pool reads connection details from environment variables and verifies the server certificate against Amazon's RDS CA bundle. With rds.force_ssl = 1, unencrypted connections are refused anyway, and verifying the CA also protects against impersonation. A new /healthz/db endpoint proves the whole chain. The handler becomes async.

    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. 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, so chmod 644 lets the unprivileged node user read it. That's an easy bug to miss until the container crashes with EACCES. npm ci --omit=dev installs 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 ../infra
    docker 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. 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-managed aws/secretsmanager key, 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. 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 through secrets.

    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. 5

    Deploy 1.2.0

    Set image_tag = "1.2.0" in terraform.tfvars and apply. The plan shows the new policy, a new task definition revision, and the service update.

    terminal
    $ terraform apply
    curl -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. 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 ──
    0
    0

Checkpoint — you should now have

  • ✓/healthz/db returns {"db":"ok", ...} through the ALB.
  • ✓The execution role can read exactly one secret; the task role has no Secrets Manager access.
  • ✓grep for 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.

terminal
$ aws ecs describe-tasks --cluster shoplite-dev --tasks $(aws ecs list-tasks --cluster shoplite-dev --desired-status STOPPED --query 'taskArns[0]' --output text) --query 'tasks[0].stoppedReason' --output text
── what you'll see ──
ResourceInitializationError: unable to pull secrets or registry auth:
execution resource retrieval failed: unable to retrieve secret from asm:
service call has been retried 1 time(s): retrieved secret from Secrets
Manager did not contain json key passwd

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.

terminal
$ aws ecs describe-tasks ... --query 'tasks[0].stoppedReason' --output text
── what you'll see ──
ResourceInitializationError: unable to pull secrets or registry auth:
execution resource retrieval failed: unable to retrieve secret from asm:
service call has been retried 1 time(s): failed to fetch secret
arn:aws:secretsmanager:ap-south-1:123456789012:secret:rds!db-7f3e...
from secrets manager: operation error Secrets Manager: GetSecretValue,
https response error StatusCode: 400, api error AccessDeniedException:
User: arn:aws:sts::123456789012:assumed-role/shoplite-dev-ecs-execution/...
is not authorized to perform: secretsmanager:GetSecretValue

Part 5

Interview questions from this mission

01

How can a resource that needs a password be managed by Terraform without the password landing in state?

02

How do ECS tasks get secrets, and which IAM role needs permission?

03

RDS rotated the master password and new database connections from your service start failing. Why, and how do you fix it long-term?

0/4 · 0%