Setting Up a Secure VPC with Bastion Host using Terraform on AWS
In this blog, Iβll walk you through how I built a secure and modular AWS infrastructure using Terraform. The project consists of creating a VPC with public and private subnets, an internet gateway, EC2 instances, and a Bastion (jump) host to securely access the private subnet.

π§ Tools & Services Used
- Terraform (Infrastructure as Code)
- AWS EC2, VPC, Subnets, IGW, Route Tables, Key Pair, Security Groups
- GitHub (for version control)
π§± Infrastructure Design
I split the infrastructure into Terraform modules for better organization. You will find the code here.
terraform-aws-bastion-vpc/
βββ ec2/
βββ keypair/
βββ security/
βββ vpc/
βββ main.tf
βββ variables.tf
βββ outputs.tf
βββ terraform.tfvars
Modules:
π¦ VPC Module (modules/vpc/main.tf
)
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "main-vpc"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main-igw"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
availability_zone = "${var.region}a"
tags = {
Name = "main-public-subnet"
}
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.region}b"
tags = {
Name = "main-private-subnet"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main-public-rt"
}
}
resource "aws_route" "public_internet_access" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
resource "aws_route_table_association" "public_assoc" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main-private-rt"
}
}
resource "aws_route_table_association" "private_assoc" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
This module provisions a basic VPC setup including subnets, internet gateway, and route tables.
π Security Group Module (modules/security/main.tf
)
# modules/security/main.tf
resource "aws_security_group" "bastion_sg" {
name = var.bastion_sg_name
description = "Allow SSH from anywhere"
vpc_id = var.vpc_id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Change this to a more restrictive CIDR block in production (YOUR_PUBLIC_IP/32)
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = var.bastion_sg_name
}
}
resource "aws_security_group" "private_sg" {
name = var.private_sg_name
description = "Allow SSH from bastion only"
vpc_id = var.vpc_id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.1.0/24"] # Public subnet CIDR
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = var.private_sg_name
}
}
This configures two groups: one for bastion to allow SSH from the internet and one for private EC2 accessible only via the bastion.
π Key Pair Module (modules/keypair/main.tf
)
# modules/keypair/main.tf
resource "tls_private_key" "this" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "main" {
key_name = var.key_name
public_key = tls_private_key.this.public_key_openssh
}
resource "local_file" "private_key" {
filename = "${path.module}/../${var.key_name}.pem"
content = tls_private_key.this.private_key_pem
file_permission = "0600"
}
Automatically generates an SSH key pair and stores the private key locally.
π» EC2 Module (modules/ec2/main.tf
)
# modules/ec2/main.tf
resource "aws_instance" "bastion" {
ami = var.ami_id
instance_type = "t3.micro"
subnet_id = var.public_subnet_id
key_name = var.key_name
vpc_security_group_ids = [var.bastion_sg_id]
associate_public_ip_address = true
tags = {
Name = "bastion-host"
}
}
resource "aws_instance" "private" {
ami = var.ami_id
instance_type = "t3.micro"
subnet_id = var.private_subnet_id
key_name = var.key_name
vpc_security_group_ids = [var.private_sg_id]
associate_public_ip_address = false
tags = {
Name = "private-host"
}
}
Launches two EC2 instances: a bastion in the public subnet and another in the private subnet.
π Secure Access Architecture
Flow:
- Bastion host launched in public subnet with public IP
- Private EC2 instance launched in private subnet, no public IP
- Only the Bastion security group allows SSH from
YOUR_PUBLIC_IP/32
- Bastion allows SSH to the private EC2 over internal IP (10.0.X.X)
π Key Management
- Private key (
.pem
) is automatically generated and saved to the project root - AWS Key Pair is created using the corresponding public key
π Main Integration (main.tf
)
# main.tf
provider "aws" {
region = var.region
profile = var.profile
}
module "vpc" {
source = "./vpc"
region = var.region
}
module "security" {
source = "./security"
vpc_id = module.vpc.vpc_id
bastion_sg_name = "bastion-sg"
private_sg_name = "private-sg"
}
module "keypair" {
source = "./keypair"
key_name = var.key_name
}
module "ec2" {
source = "./ec2"
ami_id = var.ami_id
public_subnet_id = module.vpc.public_subnet_id
private_subnet_id = module.vpc.private_subnet_id
key_name = module.keypair.key_name
bastion_sg_id = module.security.bastion_sg_id
private_sg_id = module.security.private_sg_id
}
This is the orchestrator that wires up all the modules with appropriate inputs and ensures consistent provisioning.
π How to Deploy
β Prerequisites:
- AWS CLI configured with proper credentials and permissions
- Terraform installed
- Git (to clone the repo)
# 1. Clone the repo
git clone https://github.com/tanmay031/terraform-aws-bastion-vpc.git
cd terraform-aws-bastion-vpc
# 2. Checkout the setup branch
git checkout setup
# 3. Initialize Terraform
terraform init
# 4. Review the plan
terraform plan
# 5. Apply and provision the infrastructure
terraform apply