Build a Custom VPC with Bastion Host, NAT Gateway, and Web Server using Terraform
Introduction
When building production-ready infrastructure on AWS, a simple “default VPC + EC2” setup is not enough.
We need:
- A custom VPC with proper subnets,
- A Bastion Host for secure access,
- A NAT Gateway for private instances,
- A Web Server that can be accessed through Bastion.
In this guide, we’ll build exactly that using Terraform. By the end, you’ll have a working VPC with public and private subnets, secure SSH access, and a NAT Gateway that allows private instances to reach the internet.
👉 All Terraform code is available in my GitHub repository:
aws-vpc-terraform
🔰 AWS Networking Fundamentals
Before diving into the implementation, let’s understand the core AWS networking components we’ll be using:
🏗️ What is a VPC (Virtual Private Cloud)?
A VPC is your logically isolated network in AWS — think of it as your own private data center in the cloud. With a VPC, you have complete control over:
- IP Address Range: Choose your CIDR block (e.g.,
10.0.0.0/16) - Subnets: Divide your VPC into public and private segments
- Route Tables: Control where network traffic is directed
- Security: Manage traffic with Security Groups and Network ACLs
- Connectivity: Attach gateways for internet and VPN access
💡 Key Insight: A VPC is like having your own virtual data center where you control every aspect of networking, from IP addresses to routing policies.
🌐 Internet Gateway (IGW)
An Internet Gateway is a horizontally scaled, highly available AWS component that enables communication between your VPC and the internet:
How it Works:
Internet ↔ Internet Gateway ↔ Public Subnet ↔ Your EC2 Instances
🔒 NAT Gateway
A NAT Gateway provides a secure way for instances in private subnets to access the internet while remaining protected from inbound connections:
Key Features:
- Outbound Only: Private instances can initiate internet connections (downloads, API calls)
- Inbound Protection: Internet cannot initiate connections to private instances
Traffic Flow:
Private Instance → NAT Gateway (in Public Subnet) → Internet Gateway → Internet
Common Use Cases:
- Software package updates (
yum update,apt update) - API calls to external services
- License validation
- Container image downloads
🛡️ Bastion Host (Jump Host)
A bastion host is a hardened EC2 instance strategically placed in a public subnet to serve as a single, controlled entry point for SSH access to private resources:
How SSH Jumping Works:
Your Computer → Bastion Host (Public) → Private Instance
Jump Command Example:
ssh -J user@bastion-ip user@private-ip
# or using ProxyCommand
ssh -o ProxyCommand='ssh -W %h:%p user@bastion-ip' user@private-ip🔐 Security Best Practice: Instead of opening SSH (port 22) to
0.0.0.0/0on every server, you restrict SSH to only your IP on the bastion, then jump from there to private instances.
Architecture Overview
- SSH: Laptop → Bastion → (Web/Private).
- HTTP: Internet → Web (public IP).
🔄 Traffic Flow Patterns:
Internet → IGW → Public Subnet → Web Server (HTTP ✅)
Your IP → IGW → Bastion Host (SSH ✅)
Bastion → Web Server (Internal SSH ✅)
Bastion → Private EC2 (Internal SSH ✅)
Private EC2 → NAT Gateway → IGW → Internet (Updates ✅)
📋 Requirements
Before we begin the deployment, ensure you have the following properly configured:
- AWS CLI configured with proper permissions
- Terraform v1.0+ installed
- SSH Client: Instance connectivity testing
- Git : Repository cloning
- Required AWS IAM Permissions (EC2,VPC)
🚀 Step-by-Step Deployment Guide
Step 1 — Environment Verification
First, let’s verify that our prerequisites are properly installed and configured:
# Check prerequisites
aws --version
terraform --version
# Configure AWS credentials
aws configureSample AWS Configuration:
AWS Access Key ID [****************3Y7H]: YOUR_ACCESS_KEY
AWS Secret Access Key [****************l9vV]: YOUR_SECRET_KEY
Default region name [us-east-1]: us-east-1
Default output format [json]: json
Step 2: Repository Setup and SSH Key Creation
Clone the repository and set up the required SSH key pair:
# Clone the infrastructure repository
git clone https://github.com/hakanbayraktar/aws-vpc-terraform.git
cd aws-vpc-terraform
# # Create a new EC2 key pair for secure instance access
aws ec2 create-key-pair --key-name production-vpc-key \
--query 'KeyMaterial' --output text > ~/.ssh/production-vpc-key.pem
# Set proper permissions
chmod 400 ~/.ssh/production-vpc-key.pem
# Verify key pair creation
aws ec2 describe-key-pairs --key-names production-vpc-keyExpected Key Pair Output:
{
"KeyPairs": [
{
"KeyPairId": "key-12345abcdef",
"KeyFingerprint": "aa:bb:cc:dd:ee:ff",
"KeyName": "production-vpc-key",
"KeyType": "rsa"
}
]
}Step 3:Infrastructure Configuration
Configure the Terraform variables according to your environment:
# Get your public IP
curl -s https://checkip.amazonaws.com
# Copy example configuration
cp terraform.tfvars.example terraform.tfvars
# Edit configuration file
vi terraform.tfvarsterraform.tfvars example:
# Copy this file to terraform.tfvars and customize the values
# AWS Configuration
aws_region = "us-east-1"
# Project Configuration
project_name = "production-vpc"
environment = "prod"
# Network Configuration
vpc_cidr = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24" # us-east-1a
private_subnet_cidr = "10.0.2.0/24" # us-east-1b
# EC2 Configuration
instance_type = "t3.micro"
key_pair_name = "production-vpc-key" # IMPORTANT: Make sure this key pair exists in your AWS account
# Security Configuration
allowed_ssh_cidr = "0.0.0.0/0" # SECURITY: Change this to your IP address (e.g., "203.0.113.0/32")
# Optional Features
enable_private_instance = true
# Resource Tags
common_tags = {
Project = "Production VPC Infrastructure"
Environment = "Production"
ManagedBy = "Terraform"
Owner = "DevOps Team"
CostCenter = "Engineering"
}⚠️ Critical Security Warning: Always replace
allowed_ssh_cidrwith your actual public IP address (YOUR_IP/32). Never use0.0.0.0/0for SSH access in production environments!
Step 4: Terraform Deployment
Download the AWS provider plugin and initialize the working directory
Phase 1: Initialize Terraform
# Initialize Terraform
terraform initPhase 2: Plan the Deployment
# Review execution plan
terraform planThis command will show you exactly which AWS resources will be created. Review the plan carefully before proceeding.
Phase 3: Apply the Infrastructure
# Deploy the infrastructure (with confirmation prompt)
terraform apply
# Alternative: Auto-approve for automated deployments
terraform apply -auto-approveTerraform will provision the entire environment in a few minutes. After successful deployment, Terraform will output important connection information:
Step 5 — Testing the Infrastructure
After deployment, test the security architecture with these scenarios:
5.1 Direct Web Access ✅
# Get web server public IP from Terraform output
WEB_IP=$(terraform output -raw web_server_public_ip)
# Test HTTP access (should work)
curl http://$WEB_IPExpected Result: Apache default page or custom content
5.2 Direct SSH to Web Server ❌
# This should FAIL (security group blocks direct SSH)
ssh -i ~/.ssh/production-vpc-key.pem ec2-user@$WEB_IPExpected Result: Connection timeout or refused
5.3 Bastion Host SSH Access ✅
# Get bastion public IP
BASTION_IP=$(terraform output -raw bastion_public_ip)
# Connect to bastion (should work)
ssh -i ~/.ssh/production-vpc-key.pem ec2-user@$BASTION_IPExpected Result: Successful SSH connection
5.4 Jump into the Web Server from Bastion
# Get web server private IP
WEB_PRIVATE_IP=$(terraform output -raw web_server_private_ip)
# SSH jump through bastion to web server
ssh -i ~/.ssh/production-vpc-key.pem \
-o ProxyCommand='ssh -i ~/.ssh/production-vpc-key.pem -W %h:%p ec2-user@'$BASTION_IP \
ec2-user@$WEB_PRIVATE_IPExpected Result: Access web server through bastion
5.5 Jump into the Private Server from Bastion
# Get private instance IP
PRIVATE_IP=$(terraform output -raw private_instance_ip)
# SSH jump through bastion to private instance
ssh -i ~/.ssh/production-vpc-key.pem \
-o ProxyCommand='ssh -i ~/.ssh/production-vpc-key.pem -W %h:%p ec2-user@'$BASTION_IP \
ec2-user@$PRIVATE_IPExpected Result: Access private server through bastion
5.6 Test Internet Access from Private Server (via NAT Gateway)
# From private instance (connected via bastion)
# Test internet connectivity
ping -c 3 google.com
curl -I https://httpbin.org/ip
# Test package updates (demonstrates NAT functionality)
sudo yum update -y
sudo yum install -y htop wgetThis should work, proving that NAT Gateway is correctly configured.
📝 Automated Testing Script
#!/bin/bash
# comprehensive-test.sh - Complete infrastructure testing
set -e
echo "🧪 Starting Comprehensive VPC Infrastructure Testing..."
# Get all required IPs from Terraform
BASTION_IP=$(terraform output -raw bastion_public_ip)
WEB_IP=$(terraform output -raw web_server_public_ip)
WEB_PRIVATE_IP=$(terraform output -raw web_server_private_ip)
PRIVATE_IP=$(terraform output -raw private_instance_ip)
echo "📊 Infrastructure Overview:"
echo " 🛡️ Bastion Public IP: $BASTION_IP"
echo " 🌐 Web Server Public IP: $WEB_IP"
echo " 🔒 Web Server Private IP: $WEB_PRIVATE_IP"
echo " 💼 Private Instance IP: $PRIVATE_IP"
echo
# Test 1: Web server HTTP access
echo "🌐 Test 1: Web Server HTTP Access..."
if curl -s -o /dev/null -w "%{http_code}" http://$WEB_IP | grep -q "200"; then
echo " ✅ PASS: Web server HTTP accessible"
else
echo " ❌ FAIL: Web server HTTP not accessible"
fi
# Test 2: Bastion SSH access
echo "🛡️ Test 2: Bastion Host SSH Access..."
if timeout 10 ssh -i ~/.ssh/production-vpc-key.pem -o ConnectTimeout=5 -o StrictHostKeyChecking=no \
ec2-user@$BASTION_IP "echo 'connected'" > /dev/null 2>&1; then
echo " ✅ PASS: Bastion SSH accessible"
else
echo " ❌ FAIL: Bastion SSH not accessible"
fi
# Test 3: Direct SSH to web server (should fail)
echo "🚫 Test 3: Direct SSH to Web Server (should fail)..."
if timeout 10 ssh -i ~/.ssh/production-vpc-key.pem -o ConnectTimeout=5 -o StrictHostKeyChecking=no \
ec2-user@$WEB_IP "echo 'connected'" > /dev/null 2>&1; then
echo " ❌ FAIL: Direct SSH to web server should be blocked!"
else
echo " ✅ PASS: Direct SSH to web server properly blocked"
fi
# Test 4: Jump SSH to web server
echo "🔗 Test 4: Jump SSH to Web Server..."
if timeout 15 ssh -i ~/.ssh/production-vpc-key.pem -o StrictHostKeyChecking=no \
-o ProxyCommand='ssh -i ~/.ssh/production-vpc-key.pem -o StrictHostKeyChecking=no -W %h:%p ec2-user@'$BASTION_IP \
ec2-user@$WEB_PRIVATE_IP "echo 'connected'" > /dev/null 2>&1; then
echo " ✅ PASS: Jump SSH to web server working"
else
echo " ❌ FAIL: Jump SSH to web server failed"
fi
echo
echo "🎉 Infrastructure Testing Complete!"
echo "💡 For manual testing, connect to instances using the commands in the article."Make the script executable and run it:
chmod +x comprehensive-test.sh
./comprehensive-test.shStep 6— Cleanup
When the lab is finished, it’s very important to remove all resources to avoid unnecessary costs.
- EC2 Instances (Bastion, Web Server, Private Instance): billed per running hour + attached EBS storage.
- NAT Gateway: billed per hour (even if idle) + per GB of processed data. This can become expensive if you forget to delete it.
- Elastic IP: if left unattached, it also incurs a small hourly charge.
You can destroy everything with a single command:
terraform destroyConfirm with yes when prompted.
💡 Tip: Always double-check your AWS Console after
terraform destroyto ensure all EC2 instances, NAT Gateway, and Elastic IPs are terminated.
Conclusion
With just a few Terraform scripts, we created a production-style AWS network with:
✅ Custom VPC
✅ Public & Private Subnets
✅ Bastion Host for secure access
✅ NAT Gateway for private instances
✅ Apache Web Server
This setup is perfect for learning AWS networking and Terraform basics.
👉 Full code on GitHub: aws-vpc-terraform
