Cloud 09 — the flagship's database on RDS: private, reachable from one security group only, torn down the same session

In Java, talking to Postgres usually means a JDBC URL to a box someone else administers — patched, backed up, and not your shutdown to forget. terraform apply here makes you that someone else: one aws_db_instance you now own, in a subnet with no route to the internet at all (not even through a NAT gateway — cloud-04 never built one), reachable on :5432 from exactly one security group: the Fargate task's, from cloud-06/07. The smallest instance still bills by the hour once your account's free tier is spent, and a database has a failure mode compute never does — a snapshot you forgot you asked for, quietly billing storage forever. This page is that database: private by construction, and a teardown checklist that is longer than the apply.

~120 min~$8 for the session (approx.) private subnet · no NATskip_final_snapshot cloud-09

What this creates

One aws_db_instance — engine postgres 16.4, class db.t4g.micro, 20 GB gp3 — plus a aws_db_subnet_group spanning cloud-04's two private subnets (private-a 10.0.10.0/24, private-b 10.0.11.0/24 — no NAT, no internet route, only the VPC's local route), a security group that admits :5432 from the Fargate task's security group, and nothing else, and a Secrets Manager secret RDS generates and rotates for you (manage_master_user_password = true, so no password is ever typed or stored in state). Three small additions to cloud-06's own module: two outputs (the task's security group id, so this root module can reference it, and the execution role's name), an environment/secrets pair of variables the container definition renders, so the container gets DB_PASSWORD from Secrets Manager and never from an env var literal, and a task role carrying the four ssmmessages actions ECS Exec needs — the one-off CREATE EXTENSION vector runs from inside the task, because nothing outside the VPC can reach the database. publicly_accessible = false and backup_retention_period = 0 are both deliberate: there is no internet route to be reachable from anyway, and automated backups exist to restore a running mistake, not a throwaway learning database you destroy the same session.

Where the money goes: db.t4g.micro Single-AZ lists at $0.016/h (~$11.52 for a 720-hour month), 20 GB gp3 storage at ~$0.115/GB-mo (~$2.30/mo), and the Secrets Manager secret at a flat $0.40/mo (API calls at this volume round to $0). AWS's RDS free tier covers 750 instance-hours and 20 GB of both storage and backup storage a month for the first 12 months of the account — so this exact module is ~$0.40/mo (just the secret) inside the free tier, and ~$14.22/mo the day after it expires. These are published on-demand rates for us-east-1, approximate: verify them on the RDS pricing page before you apply — this session did not re-fetch them live, and ap-south-1 differs from us-east-1 besides. One more caveat: that 12-month tier is the legacy free tier. An account on AWS's current free plan (the credits-then-close model cloud-02 describes) has no 750-hour allowance — the instance-hours draw down the credits from day one at the month-13 rate, so for such an account chip 3 is the day-1 picture, not the year-2 one.

Preconditions

Do it

1. Teach cloud-06's module three things it does not know yet — modules/fargate-service/. Two outputs (this root module needs the task security group's id to reference it, and right now nothing exports it), two variables the container definition renders (so step 4 can hand the task its database settings without editing the module again), and a task role for ECS Exec (step 6 runs one SQL statement from inside the task; enable_execute_command alone does nothing — the task needs an identity that may open a Session Manager channel):

# modules/fargate-service/outputs.tf — add
output "task_security_group_id" {
  value = aws_security_group.task.id
}
output "execution_role_name" {
  value = aws_iam_role.execution.name
}

# modules/fargate-service/variables.tf — add
variable "environment" {
  type        = map(string)
  default     = {}
  description = "Plain env vars for the container, name => value"
}
variable "secrets" {
  type        = map(string)
  default     = {}
  description = "Env vars ECS injects from Secrets Manager, name => valueFrom ARN"
}

# modules/fargate-service/main.tf — add a task role (the identity the *container* runs as;
# the execution role is ECS's own), and reference it from the task definition
resource "aws_iam_role" "task" {
  name = "${var.name}-task"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "task_exec" {
  name = "ecs-exec-channel-only"
  role = aws_iam_role.task.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ssmmessages:CreateControlChannel", "ssmmessages:CreateDataChannel",
                  "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel"]
      Resource = "*" # these four cannot be scoped to a resource
    }]
  })
}

# in resource "aws_ecs_task_definition" "this", next to execution_role_arn:
  task_role_arn = aws_iam_role.task.arn
# in its container_definitions object, after portMappings:
    environment = [for k, v in var.environment : { name = k, value = v }]
    secrets     = [for k, v in var.secrets : { name = k, valueFrom = v }]
# in resource "aws_ecs_service" "this", next to launch_type:
  enable_execute_command = true

Re-apply 06-fargate with no other change. terraform plan there reads 3 to add, 1 to change, 1 to destroy: the task role and its policy, the task definition replaced (its JSON now carries two empty lists), the service updated for enable_execute_command; the outputs are not resources and do not count.

2. The root module's pins and backend — 09-fargate-f1/versions.tf. Same bucket as every prior cloud item, a fifth key:

# 09-fargate-f1/versions.tf
terraform {
  required_version = "~> 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket       = "lbv-tfstate-yourname-x7q2"
    key          = "lbv-track/09-fargate-f1/terraform.tfstate"
    region       = "ap-south-1"
    use_lockfile = true
    encrypt      = true
  }
}

provider "aws" {
  region = "ap-south-1"

  default_tags {
    tags = {
      Project = "lbv-track"
      Item    = "cloud-09"
    }
  }
}

3. Read the two states this needs, then the database itself — 09-fargate-f1/main.tf. The VPC state gives the private subnets and the VPC id; the fargate state gives the task's security group id, so the RDS security group's one ingress rule names it directly rather than a CIDR:

# 09-fargate-f1/main.tf
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "lbv-tfstate-yourname-x7q2"
    key    = "lbv-track/04-vpc/terraform.tfstate"
    region = "ap-south-1"
  }
}

data "terraform_remote_state" "fargate" {
  backend = "s3"
  config = {
    bucket = "lbv-tfstate-yourname-x7q2"
    key    = "lbv-track/06-fargate/terraform.tfstate"
    region = "ap-south-1"
  }
}

resource "aws_db_subnet_group" "f1" {
  name       = "lbv-f1"
  subnet_ids = data.terraform_remote_state.vpc.outputs.private_subnet_ids
}

resource "aws_security_group" "rds" {
  name        = "lbv-f1-rds"
  description = "postgres :5432 from the fargate task's sg only"
  vpc_id      = data.terraform_remote_state.vpc.outputs.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "rds_from_task" {
  security_group_id            = aws_security_group.rds.id
  referenced_security_group_id = data.terraform_remote_state.fargate.outputs.task_security_group_id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

resource "aws_db_instance" "f1" {
  identifier     = "lbv-f1"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.t4g.micro"

  allocated_storage = 20
  storage_type      = "gp3"

  db_name  = "f1"
  username = "f1"

  manage_master_user_password = true # RDS creates & rotates the secret — no password in state
  db_subnet_group_name        = aws_db_subnet_group.f1.name
  vpc_security_group_ids      = [aws_security_group.rds.id]
  publicly_accessible         = false # the private subnets have no internet route anyway

  multi_az                = false
  backup_retention_period = 0    # no automated backups for a throwaway learning DB
  skip_final_snapshot     = true # see Teardown — the deliberate reason is below
  deletion_protection     = false
  apply_immediately       = true
}

4. Wire the secret into the running task. This edits 06-fargate/main.tf: one new IAM policy so the execution role may read exactly this one secret, and the two maps step 1 taught the module — four plain env vars, and one secrets entry so ECS injects DB_PASSWORD at start, never as a plaintext env var, never in the task definition JSON that describe-task-definition would otherwise leak. (The flagship's config reads these four and assembles the DATABASE_URL cloud-08 passed whole — one line in its settings module.)

# 06-fargate/main.tf — appended, after 09-fargate-f1 has run once
data "terraform_remote_state" "f1db" {
  backend = "s3"
  config = {
    bucket = "lbv-tfstate-yourname-x7q2"
    key    = "lbv-track/09-fargate-f1/terraform.tfstate"
    region = "ap-south-1"
  }
}

resource "aws_iam_role_policy" "read_db_secret" {
  name = "lbv-api-read-db-secret"
  role = module.api.execution_role_name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = data.terraform_remote_state.f1db.outputs.db_secret_arn
    }]
  })
}

# and inside the existing module "api" { ... } block, two new arguments:
  environment = {
    DB_HOST = data.terraform_remote_state.f1db.outputs.db_endpoint
    DB_PORT = "5432"
    DB_USER = "f1"
    DB_NAME = "f1"
  }
  secrets = {
    DB_PASSWORD = "${data.terraform_remote_state.f1db.outputs.db_secret_arn}:password::"
  }
# the ":password::" suffix selects the "password" key out of the secret's JSON (key, then an
# empty version-stage, then an empty version-id). RDS's generated secret carries username, host,
# port and dbname under the same JSON the same way, but those are not secret — plain env vars
# are easier to read back with describe-task-definition when something does not connect.
# 09-fargate-f1/outputs.tf
output "db_endpoint" {
  value = aws_db_instance.f1.address
}
output "db_secret_arn" {
  value = aws_db_instance.f1.master_user_secret[0].secret_arn
}

5. Plan, then apply.

cd ~/lbv-cloud/09-fargate-f1
export AWS_PROFILE=lbv-admin
terraform init
terraform fmt && terraform validate
terraform plan -out tfplan
# Plan: 4 to add, 0 to change, 0 to destroy.
# (db_subnet_group, security_group, the ingress rule, db_instance)
terraform apply tfplan
# ... Apply complete! Resources: 4 added.
# aws_db_instance.f1: Still creating... [8m0s elapsed]   (a fresh Postgres instance takes 5-10 min)
# aws_db_instance.f1: Creation complete after 9m14s
cd ~/lbv-cloud/06-fargate && terraform apply   # the secret-reading policy + the task def's env vars and secrets entry
# Plan: 2 to add, 1 to change, 1 to destroy.   (new IAM policy + new task def revision; service updated; old revision)

6. Enable pgvector, once, from inside the running task. The private subnets have no NAT, so there is no bastion host and no path from your Mac to :5432. The Fargate task already has a route to the database over the VPC's local network (that traffic never touches the internet, which is exactly why no NAT was ever needed for this) — so the migration runs from inside the cluster, using ECS Exec against the same task that already has the Postgres driver installed as an application dependency (the task role from step 1 is what makes execute-command allowed; the tasks running now were started after that apply, so they carry it):

# the Session Manager plugin must be installed on the Mac: brew install --cask session-manager-plugin
TASK=$(aws ecs list-tasks --cluster lbv-api --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster lbv-api --task "$TASK" --container api --interactive \
  --command "python -c \"import psycopg, os as o; e=o.environ; c=psycopg.connect(host=e['DB_HOST'], port=e['DB_PORT'], user=e['DB_USER'], password=e['DB_PASSWORD'], dbname=e['DB_NAME'], autocommit=True); c.execute('CREATE EXTENSION IF NOT EXISTS vector'); print(c.execute('select extversion from pg_extension where extname = %s', ('vector',)).fetchone())\""
# ('0.8.0',)   — or whatever pgvector version this RDS Postgres ships; any tuple means the extension exists

Verify

Teardown

This is the longest section on this page on purpose. Compute stops billing the second a task is gone; a database can keep billing quietly after you think you have destroyed it, through exactly the mechanism step 3 turned off. Read skip_final_snapshot = true again: without it, terraform destroy does not delete the data — it renames it into a manual snapshot named lbv-f1-final-snapshot first, and manual snapshots are not covered by the free tier's backup storage once the instance that made them is gone. On 20 GB that is ~$1.90/mo, forever, on a resource terraform destroy already told you it deleted — the "nokeep" chip below shows exactly that leftover. That is the one line in this whole track that a clean destroy can still leave behind, and it is why this module sets the flag instead of leaving Terraform's default.

  1. Unwire the task first, then destroy the database — the order matters, because step 4 made 06-fargate read 09-fargate-f1's state. Destroy the database first and 06-fargate's very next plan fails on a db_secret_arn output that no longer exists, and you are editing Terraform under an error message. So: delete step 4's three additions from 06-fargate/main.tf (the remote-state block, the policy, the two maps), apply there — 1 to add, 1 to change, 2 to destroy, the task definition back to no secrets — and only then:
    cd ~/lbv-cloud/09-fargate-f1
    terraform destroy
    # Plan: 0 to add, 0 to change, 4 to destroy. ... Destroy complete! Resources: 4 destroyed.
    # (no "final snapshot" line in the output — skip_final_snapshot suppressed it)
  2. Confirm the instance is actually gone, not just gone from state:
    aws rds describe-db-instances --db-instance-identifier lbv-f1
    # An error occurred (DBInstanceNotFound)
  3. Confirm no final (or manual) snapshot was left — the check that catches a skip_final_snapshot regression or a snapshot someone took by hand mid-session:
    aws rds describe-db-snapshots --db-instance-identifier lbv-f1 --query 'DBSnapshots[].DBSnapshotIdentifier'
    # []
  4. The secret. The RDS user guide is explicit that deleting an instance that manages its secret deletes the secret and its metadata too. What this page could not confirm from the Secrets Manager pricing page is whether a secret still inside its deletion recovery window (7–30 days) bills for those days — treat that as a claim to verify, not a settled fact, and force it if you want the $0.40/mo line provably gone today. Note the flag: a secret scheduled for deletion is hidden from list-secrets by default, so without it this check always reads empty:
    aws secretsmanager list-secrets --include-planned-deletion --query 'SecretList[?starts_with(Name, `rds!db-`)].[Name,DeletedDate]'
    # if anything lists: aws secretsmanager delete-secret --secret-id <name> --force-delete-without-recovery
  5. The compute stays applied (cloud-06/07 outlives this item, per its own page) — but confirm the one thing this item added to it is clean: aws ecs describe-task-definition --task-definition lbv-api --query 'taskDefinition.containerDefinitions[0].secrets' reads [] (item 1's apply). If it still names a valueFrom ARN, you destroyed the database before unwiring the task: nothing is billing, but the next ECS deployment will fail to start a task, because ECS cannot fetch a secret that no longer exists — go back to item 1's edit now, not the day it bites.
  6. Everything cloud-07's own teardown already checks — this item touched none of it, so a stale ALB/ENI/EIP here is cloud-07's regression, not this one's: aws elbv2 describe-load-balancers, aws ec2 describe-network-interfaces, aws ec2 describe-addresses all read empty exactly as on that page.
  7. Tomorrow: Cost Explorer, grouped by service, daily. You should find one day with a line under Amazon RDS (the instance-hours and storage-GB this item ran) and, if the secret's recovery window has not elapsed, a small Secrets Manager line — and nothing under either the day after.

This is self-attestation. The site cannot see your AWS account, so checking the box and pressing the button is you telling The Path that describe-db-instances, describe-db-snapshots and the ALB/ENI/EIP checks all came back empty.

Takeaways: a private subnet with no NAT is not a compromise here — the database never needed the internet, only the VPC's local route to the one task that talks to it, so the security group naming that task's group by id is the entire access story. The trade-off this page keeps deferring — a NAT gateway (~$32/mo, cloud-04's own number) or VPC interface endpoints, so something in a private subnet could reach the internet — only matters the day something in that subnet needs to pull an image or call an AWS API from outside the VPC; read cloud-04 again when that day comes. A database's failure mode is different from compute's: a Fargate task that is not running costs nothing, but a snapshot Terraform made on your behalf can keep billing storage long after you believe you have destroyed everything, silently, until Cost Explorer or a bill says otherwise.