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.
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.
terraform apply before that e-mail.06-fargate's tfplan first):
cd ~/lbv-cloud/06-fargate && terraform output cluster_name answers. This module reads
its state for the task's security group id.cd ~/lbv-cloud/04-vpc && terraform output
private_subnet_ids lists two subnets. Nothing has ever been created in them before this.compose.yaml once — the schema this
RDS instance holds is the same f1 database, same pgvector extension, the local
Docker Postgres stood in for.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
terraform show -json tfplan | jq '[.resource_changes[] |
select(.type == "aws_nat_gateway" or .type == "aws_eip")] | length' reads 0 — this
module needed neither.aws rds describe-db-instances --db-instance-identifier lbv-f1 --query
'DBInstances[0].[DBInstanceStatus,PubliclyAccessible]' --output text reads
available False.pg_extension —
None there means the CREATE EXTENSION did not land.nc -zv -w3 <db endpoint> 5432
times out — there is no route from outside the VPC, and the security group would refuse it even if
there were.curl "http://$(cd ~/lbv-cloud/06-fargate && terraform output -raw
alb_dns_name)/healthz" still answers {"status":"ok"} after the task definition
replacement in step 5.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.
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)aws rds describe-db-instances --db-instance-identifier lbv-f1
# An error occurred (DBInstanceNotFound)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'
# []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-recoveryaws 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.aws elbv2
describe-load-balancers, aws ec2 describe-network-interfaces,
aws ec2 describe-addresses all read empty exactly as on that page.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.