Introduction
Building a "VPC-EC2-RDS" stack is a common task, but making it production-ready requires more than just resource creation. In this post, I will share a modular Terraform setup that implements:
- Modular Design: Reusable code for VPC, EC2, and RDS.
- Bastion-less Access: Using AWS Systems Manager (SSM) instead of SSH.
- Secret Management: Managing RDS passwords via AWS Secrets Manager.
Project Structure
We follow the standard environment/module separation to ensure scalability.
terraform/
├── envs/
│ └── dev/ # Environment-specific configuration
│ ├── main.tf # Root module calling local modules
│ └── terraform.tfvars
└── modules/
├── vpc/ # Networking & Security Groups
├── ec2/ # IAM Roles & SSM-ready Instances
└── rds/ # Private Database Instances
Key Features
- Networking (modules/vpc)
We define a VPC with both public and private subnets. The RDS instance stays in the private subnet, while the EC2 is also kept private for maximum security, relying on the NAT Gateway for outbound traffic.
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project}-${var.env}-vpc" }
}
resource "aws_security_group" "web" {
name = "${var.project}-${var.env}-web-sg"
vpc_id = aws_vpc.this.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "db" {
name = "${var.project}-${var.env}-db-sg"
vpc_id = aws_vpc.this.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
}
- modules/ec2: Bastion-less Access via SSM
Build secure EC2 instances by closing all SSH ports and leveraging AWS Systems Manager (SSM) for remote access.
IAM Instance Profile for SSM Permissions
Instead of managing SSH keys, we attach an IAM role with the AmazonSSMManagedInstanceCore policy. This allows secure terminal access through the AWS Console or CLI.
Deployment in Private Subnets
The instances are placed in private subnets with no public IP addresses, significantly reducing the attack surface by making them inaccessible from the open internet.
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
iam_instance_profile = aws_iam_instance_profile.ssm_profile.name
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
tags = { Name = "${var.project}-${var.env}-ec2" }
}
resource "aws_iam_role" "ssm_role" {
name = "${var.project}-${var.env}-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" } }]
})
}
resource "aws_iam_role_policy_attachment" "ssm_managed" {
role = aws_iam_role.ssm_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
- modules/rds: Managed Database Layer
We deploy a production-grade PostgreSQL instance with a focus on maximum security and automated credential management.
Private Isolation from External Traffic
The database is placed in a dedicated private subnet with publicly_accessible = false. All direct ingress from the internet is strictly blocked, ensuring your data remains isolated.
Dynamic Password Injection via Variables
To avoid hardcoding sensitive information, the database password is parameterized. This allows for dynamic injection from higher-level environments or secret management services (like AWS Secrets Manager), ensuring a secure CI/CD pipeline.
resource "aws_db_subnet_group" "this" {
name = "${var.project}-${var.env}-rds-subnet-group"
subnet_ids = var.subnet_ids
}
resource "aws_db_instance" "this" {
identifier = var.identifier
engine = var.engine
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = 20
db_name = var.database_name
username = var.master_username
password = var.master_password
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = var.security_group_ids
publicly_accessible = false
skip_final_snapshot = true
}
- envs/dev: Environment-Specific Configuration & Secret Management
In this directory, we integrate the individual modules to build a complete "Development" environment while ensuring that sensitive information like passwords is handled securely.
Secure RDS Password Retrieval via AWS Secrets Manager
Instead of hardcoding database credentials, we dynamically fetch the RDS master password from AWS Secrets Manager at runtime.
data "aws_secretsmanager_secret" "rds_password" {
name = "${var.project}/${var.env}/rds-password"
}
data "aws_secretsmanager_secret_version" "rds_password" {
secret_id = data.aws_secretsmanager_secret.rds_password.id
}
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
# ...
}
module "rds" {
source = "../../modules/rds"
master_password = data.aws_secretsmanager_secret_version.rds_password.secret_string
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
security_group_ids = [module.vpc.db_sg_id]
}
module "ec2" {
source = "../../modules/ec2"
subnet_id = module.vpc.private_subnet_ids[0]
security_group_ids = [module.vpc.web_sg_id]
}
- Conclusion: Aiming for Production-Ready Terraform Architecture
In this walkthrough, we didn't just focus on turning resources into code. We prioritized the core pillars required in real-world operations: Maintainability, Security, and Reusability.
Key Benefits of This Architecture
Environment Portability
Need a production environment? Just create a new envs/prod/ directory. You can replicate this entire stack for production in minutes.
Moving Beyond Bastion Hosts
By leveraging AWS Systems Manager (SSM), we've eliminated the security risk of leaving SSH ports open to the world.
Proper Secret Management
With AWS Secrets Manager, sensitive passwords are decoupled from your codebase, ensuring they never leak into your Git history.
Infrastructure as Code (IaC) with Terraform is a powerful weapon that drastically reduces manual engineering hours and prevents human error. I encourage you to take this template and customize it to fit the specific needs of your own projects.
Top comments (0)