Terraform in Practice #5 RDS and S3: Codifying the Data Tier with Safety Guards
The web tier is standing, so now it is the data tier’s turn. The resources in this part are not difficult by themselves, but this is where a new kind of tension enters the series for the first time: with resources that hold data, mistakes carry a different price. A web server can be thrown away and rebuilt; a database cannot. That is why the safety mechanisms we learned in Basics #7 get their first real deployment in this part.
The DB subnet group and security group #
RDS does not take subnets directly; it takes a bundle called a DB subnet group. We build it from the private subnets.
# db.tf (new file)
resource "aws_db_subnet_group" "main" {
name_prefix = "myapp-${var.env}-"
subnet_ids = [for s in aws_subnet.private : s.id]
}
resource "aws_security_group" "db" {
name_prefix = "myapp-${var.env}-db-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id] # web tier only
}
lifecycle {
create_before_destroy = true
}
}The chaining principle we established in #3 has moved one level deeper. Internet → ALB SG → web SG → db SG. The database’s port 5432 accepts traffic only from the web tier’s security group and is not open to any CIDR. The entire traffic path is now recorded in code as a chain of security group references.
The RDS instance #
resource "aws_db_instance" "main" {
identifier_prefix = "myapp-${var.env}-"
engine = "postgres"
engine_version = "17"
instance_class = "db.t4g.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "myapp"
username = "myapp"
password = var.db_password # temporary, replaced in the next part
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
backup_retention_period = 7
skip_final_snapshot = true # for practice; production should be false
lifecycle {
prevent_destroy = false # for practice; production should be true
}
}Every line here is a design decision.
- db.t4g.micro, gp3 20GB: the minimum size for practice. The criteria for choosing an instance class are laid out in the RDS instance class comparison.
- backup_retention_period = 7: seven days of automated backups. A database without backups is not production, so we build the habit of turning them on even in practice.
- skip_final_snapshot = true: skips the final snapshot on destroy. In practice we destroy and recreate repeatedly, so it is true — but in production it should be false, so that a snapshot survives even the moment of deletion.
- prevent_destroy: off in practice, on in production, for the same reason. The very fact that these two values differ by environment is a preview of environment separation in #9.
The apply takes a few minutes. RDS creation is inherently slow, and Terraform waits until the resource becomes available before moving on.
An S3 bucket for static assets #
This is the bucket where static assets like images and CSS will live. It is the same configuration we built in Basics #8 — versioning plus public access block — so the code is shown in compressed form.
# storage.tf (new file)
resource "aws_s3_bucket" "assets" {
bucket = "myapp-${var.env}-assets-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}Appending the account ID to the bucket name is a working idiom. Bucket names must be globally unique, and appending the account ID fetched with data.aws_caller_identity (Basics #4) means the same code works in any account with no worry about name collisions. You may be wondering how static assets get served with public access blocked — the answer is CloudFront in #10. Keeping the bucket closed and letting only the CDN read from it is the current standard.
The problem we leave behind: the password is in state #
var.db_password was declared as a sensitive variable.
variable "db_password" {
type = string
sensitive = true
}On the plan screen it is masked as (sensitive value). But as we confirmed in Basics #5, the password is stored in plaintext inside the state file. You can verify it yourself: run terraform state show aws_db_instance.main and the password is right there. It means every person and system with access to the state bucket can read the production DB password. If you are supplying the value through a tfvars file, that file is plaintext too. Solving this problem structurally — not with a stopgap — is the entire subject of the next part.
Recap #
What we covered in this post:
- RDS goes into private subnets via a DB subnet group, and security group chaining now extends down to the db tier. Port 5432 opens only to the web SG
- Backups stay on for seven days even in practice. skip_final_snapshot and prevent_destroy take opposite values in practice and production, and that difference shows why environment separation is needed
- The asset bucket name gets the account ID appended to avoid collisions. Public access stays blocked, and serving becomes CloudFront’s job in #10
- sensitive only masks the screen. We confirmed the DB password sits in plaintext in state and tfvars, and the next part solves it structurally
In the next post (#6 Secrets Management), we compare SSM Parameter Store with Secrets Manager and use Terraform 1.11’s write-only arguments to build a configuration where the password never lands in state at all.