Sitemap

Build a Custom VPC with Bastion Host, NAT Gateway, and Web Server using Terraform

8 min readSep 22, 2025

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/0 on every server, you restrict SSH to only your IP on the bastion, then jump from there to private instances.

Architecture Overview

Press enter or click to view image in full size
  • SSH: Laptop → Bastion → (Web/Private).
  • HTTP: Internet → Web (public IP).
Press enter or click to view image in full size

🔄 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 configure

Sample 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-key

Expected 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.tfvars

terraform.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_cidr with your actual public IP address (YOUR_IP/32). Never use 0.0.0.0/0 for 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 init

Phase 2: Plan the Deployment

# Review execution plan
terraform plan

This 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-approve

Terraform 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:

Press enter or click to view image in full size

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_IP

Expected 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_IP

Expected 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_IP

Expected 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_IP

Expected 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_IP

Expected 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 wget

This 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.sh

Step 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 destroy

Confirm with yes when prompted.

💡 Tip: Always double-check your AWS Console after terraform destroy to 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

--

--

No responses yet