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:

  1. Bastion host launched in public subnet with public IP
  2. Private EC2 instance launched in private subnet, no public IP
  3. Only the Bastion security group allows SSH from YOUR_PUBLIC_IP/32
  4. 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

Leave a Reply

Your email address will not be published. Required fields are marked *.

*
*
You may use these <abbr title="HyperText Markup Language">HTML</abbr> tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>