{"id":137,"date":"2025-12-20T12:00:00","date_gmt":"2025-12-20T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=137"},"modified":"2026-10-30T06:53:59","modified_gmt":"2026-10-30T06:53:59","slug":"building-secure-aws-infrastructure-vpc-bastion-hosts-and-private-ec2-instances-with-terraform","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=137","title":{"rendered":"Building Secure AWS Infrastructure: VPC, Bastion Hosts, and Private EC2 Instances with Terraform"},"content":{"rendered":"<h3 class=\"wp-block-heading\">Why I Built This Project<\/h3><p class=\"wp-block-paragraph\">When I started learning AWS, I struggled to understand how networking really works. Reading documentation wasn&#8217;t enough I needed to build something real. So I created a complete infrastructure from scratch using Terraform that shows how to properly isolate resources and secure SSH access.<\/p><p class=\"wp-block-paragraph\">This project taught me the foundation of AWS networking that every DevOps engineer needs to know.<\/p><h3 class=\"wp-block-heading\">What I Built<\/h3><p class=\"wp-block-paragraph\">I deployed a three-tier network architecture with:<\/p><ul class=\"wp-block-list\"><li>VPC with public, private, and database subnets across 2 availability zones<\/li><li>Bastion host in public subnet for secure SSH access<\/li><li>2 Private EC2 instances running Apache web servers<\/li><li>Security groups with least-privilege rules<\/li><li>NAT Gateway for private instances to access internet<\/li><li>Elastic IP for stable bastion host connection<\/li><\/ul><h3 class=\"wp-block-heading\">The Architecture<\/h3><pre class=\"wp-block-code\"><code>Internet\n    \u2193\nInternet Gateway\n    \u2193\nPublic Subnets (10.0.101.0\/24, 10.0.102.0\/24)\n    \u2193\nBastion Host (with Elastic IP)\n    \u2193\nPrivate Subnets (10.0.1.0\/24, 10.0.2.0\/24)\n    \u2193\nPrivate EC2 Instances (running Apache)<\/code><\/pre><h3 class=\"wp-block-heading\">Technical Implementation Details<\/h3><h3 class=\"wp-block-heading\">1. Understanding VPC Module Configuration<\/h3><p class=\"wp-block-paragraph\">Before writing any code, I needed to understand what a VPC actually does. A VPC (Virtual Private Cloud) is like your own private network inside AWS. Without VPC, you can&#8217;t launch EC2 instances or any other resources.<\/p><p class=\"wp-block-paragraph\">I used the official Terraform AWS VPC module (version 5.13.0). Here&#8217;s my actual configuration:<\/p><pre class=\"wp-block-code\"><code>module \"vpc\" {\n    source = \"terraform-aws-modules\/vpc\/aws\"\n    version = \"5.13.0\"\n\n    name = \"${local.name}-${var.vpc_name}\"\n    cidr = var.vpc_cidr_block\n    azs = var.vpc_availability_zones\n    public_subnets = var.vpc_public_subnets\n    private_subnets = var.vpc_private_subnets\n    database_subnets = var.vpc_database_subnets\n    \n    create_database_subnet_group = var.vpc_create_database_subnet_group\n    create_database_subnet_route_table = var.vpc_create_database_subnet_route_table\n    \n    enable_nat_gateway = var.vpc_enable_nat_gateway\n    single_nat_gateway = var.vpc_single_nat_gateway\n    \n    enable_dns_support = true\n    enable_dns_hostnames = true\n\n    tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Why these settings matter:<\/p><ul class=\"wp-block-list\"><li>single_nat_gateway = true &#8211; I use only one NAT Gateway to save costs<\/li><li>enable_dns_hostnames = true &#8211; EC2 instances get DNS names automatically<\/li><li>create_database_subnet_route_table = true &#8211; Database subnets are isolated with their own route table<\/li><\/ul><p class=\"wp-block-paragraph\">Deep dive into each parameter:<\/p><p class=\"wp-block-paragraph\">VPC Name with Local Values:<\/p><pre class=\"wp-block-code\"><code>name = \"${local.name}-${var.vpc_name}\"\n# Result: \"sap-dev-myvpc\"<\/code><\/pre><p class=\"wp-block-paragraph\">I combine business division + environment + vpc name. This makes it easy to find resources in AWS console when I have multiple VPCs.<\/p><p class=\"wp-block-paragraph\">CIDR Block:<\/p><pre class=\"wp-block-code\"><code>cidr = var.vpc_cidr_block  # 10.0.0.0\/16<\/code><\/pre><p class=\"wp-block-paragraph\">The \/16 gives me 65,536 IP addresses (2^16). This is enough for hundreds of servers. I chose 10.0.0.0 because it&#8217;s private IP range (RFC 1918) and won&#8217;t conflict with internet.<\/p><p class=\"wp-block-paragraph\">Availability Zones:<\/p><pre class=\"wp-block-code\"><code>azs = var.vpc_availability_zones  # [\"us-east-1a\", \"us-east-1b\"]<\/code><\/pre><p class=\"wp-block-paragraph\">Using 2 AZs means if one datacenter fails, my app still works. AWS best practice says use at least 2 AZs for production.<\/p><p class=\"wp-block-paragraph\">Subnet Types Explained:<\/p><pre class=\"wp-block-code\"><code>public_subnets = var.vpc_public_subnets    # Has route to Internet Gateway\nprivate_subnets = var.vpc_private_subnets  # Has route to NAT Gateway\ndatabase_subnets = var.vpc_database_subnets # No internet route at all<\/code><\/pre><p class=\"wp-block-paragraph\">The difference is in the route tables:<\/p><ul class=\"wp-block-list\"><li>Public subnet \u2192 Internet Gateway (can receive traffic from internet)<\/li><li>Private subnet \u2192 NAT Gateway (can send to internet, cannot receive)<\/li><li>Database subnet \u2192 No internet (completely isolated)<\/li><\/ul><p class=\"wp-block-paragraph\">NAT Gateway Configuration:<\/p><pre class=\"wp-block-code\"><code>enable_nat_gateway = true\nsingle_nat_gateway = true<\/code><\/pre><p class=\"wp-block-paragraph\">NAT Gateway allows private instances to download packages (yum update, yum install httpd) without exposing them to internet.<\/p><p class=\"wp-block-paragraph\">With single_nat_gateway = true, I create only ONE NAT Gateway in first AZ. This saves $32\/month but means:<\/p><ul class=\"wp-block-list\"><li>If us-east-1a fails, private instances in us-east-1b lose internet<\/li><li>For production, use single_nat_gateway = false to create NAT in each AZ<\/li><\/ul><p class=\"wp-block-paragraph\">DNS Settings:<\/p><pre class=\"wp-block-code\"><code>enable_dns_support = true\nenable_dns_hostnames = true<\/code><\/pre><p class=\"wp-block-paragraph\">Without these, EC2 instances don&#8217;t get DNS names like ec2-3-80-123-45.compute-1.amazonaws.com. Many applications need DNS to work properly.<\/p><h3 class=\"wp-block-heading\">2. Network Design<\/h3><p class=\"wp-block-paragraph\">My CIDR allocation strategy:<\/p><figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Subnet Type<\/th><th>Availability Zone<\/th><th>CIDR Block<\/th><th>Purpose<\/th><\/tr><\/thead><tbody><tr><td>Public<\/td><td>us-east-1a<\/td><td>10.0.101.0\/24<\/td><td>Bastion host<\/td><\/tr><tr><td>Public<\/td><td>us-east-1b<\/td><td>10.0.102.0\/24<\/td><td>Future load balancer<\/td><\/tr><tr><td>Private<\/td><td>us-east-1a<\/td><td>10.0.1.0\/24<\/td><td>Application servers<\/td><\/tr><tr><td>Private<\/td><td>us-east-1b<\/td><td>10.0.2.0\/24<\/td><td>Application servers<\/td><\/tr><tr><td>Database<\/td><td>us-east-1a<\/td><td>10.0.151.0\/24<\/td><td>Future RDS instances<\/td><\/tr><tr><td>Database<\/td><td>us-east-1b<\/td><td>10.0.152.0\/24<\/td><td>Future RDS instances<\/td><\/tr><\/tbody><\/table><\/figure><p class=\"wp-block-paragraph\">I chose this design because:<\/p><ul class=\"wp-block-list\"><li>Public subnets use 10.0.10X.0\/24 range (easy to remember &#8211; anything with 10X is public)<\/li><li>Private subnets use 10.0.X.0\/24 range (lowest numbers = app servers)<\/li><li>Database subnets use 10.0.15X.0\/24 range (15X = database layer)<\/li><\/ul><p class=\"wp-block-paragraph\">Why \/24 subnet mask?<\/p><ul class=\"wp-block-list\"><li>\/24 = 256 IP addresses per subnet<\/li><li>AWS reserves 5 IPs in each subnet (network, gateway, DNS, future use, broadcast)<\/li><li>Available IPs = 256 &#8211; 5 = 251 usable addresses<\/li><li>This is enough for most applications<\/li><\/ul><p class=\"wp-block-paragraph\">Subnet distribution across AZs: The module automatically distributes subnets across availability zones:<\/p><ul class=\"wp-block-list\"><li>public_subnets[0] \u2192 us-east-1a<\/li><li>public_subnets[1] \u2192 us-east-1b<\/li><li>private_subnets[0] \u2192 us-east-1a<\/li><li>private_subnets[1] \u2192 us-east-1b<\/li><\/ul><p class=\"wp-block-paragraph\">This is important for high availability. If I launch instances without specifying subnet, AWS picks randomly. But I specify exact subnet to control which AZ my instance runs in.<\/p><h3 class=\"wp-block-heading\">How Traffic Flows in This Architecture<\/h3><p class=\"wp-block-paragraph\">Understanding traffic flow helped me debug many issues. Let me explain each scenario:<\/p><p class=\"wp-block-paragraph\">Scenario 1: SSH from Internet to Bastion Host<\/p><pre class=\"wp-block-code\"><code>My Laptop (123.45.67.89)\n    \u2193 (SSH request to 54.123.45.67:22)\nInternet Gateway\n    \u2193 (forwards to 10.0.101.45)\nBastion Host Security Group (checks: is source 0.0.0.0\/0? YES, allow)\n    \u2193\nBastion Host (10.0.101.45)\n    \u2193 (response goes back same path - stateful!)\nMy Laptop<\/code><\/pre><p class=\"wp-block-paragraph\">Scenario 2: SSH from Bastion to Private Instance<\/p><pre class=\"wp-block-code\"><code>Bastion Host (10.0.101.45)\n    \u2193 (SSH to 10.0.1.23:22)\nPrivate Instance Security Group (checks: is source from 10.0.0.0\/16? YES, allow)\n    \u2193\nPrivate Instance (10.0.1.23)\n    \u2193 (response)\nBastion Host<\/code><\/pre><p class=\"wp-block-paragraph\">Scenario 3: Private Instance Downloads Package (yum install)<\/p><pre class=\"wp-block-code\"><code>Private Instance (10.0.1.23)\n    \u2193 (request to yum.repo.com:80)\nPrivate Subnet Route Table (check: destination not in VPC \u2192 use NAT Gateway)\n    \u2193\nNAT Gateway (10.0.101.75 in public subnet)\n    \u2193 (NAT translates 10.0.1.23 \u2192 NAT's public IP)\nInternet Gateway\n    \u2193\nInternet (yum.repo.com)\n    \u2193 (response)\nNAT Gateway (translates back to 10.0.1.23)\n    \u2193\nPrivate Instance<\/code><\/pre><p class=\"wp-block-paragraph\">Scenario 4: Internet User tries to access Private Instance (BLOCKED)<\/p><pre class=\"wp-block-code\"><code>Internet User\n    \u2193 (tries to reach 10.0.1.23)\nInternet Gateway (check: 10.0.1.23 is private IP, no route)\n    \u2717 BLOCKED - Private IPs not routable on internet<\/code><\/pre><p class=\"wp-block-paragraph\">Even if someone knows my private instance IP, they cannot reach it from internet because:<\/p><ul class=\"wp-block-list\"><li>Private IP (10.0.0.0\/8) is not routable on internet<\/li><li>No public IP attached to private instance<\/li><li>Security group only allows VPC CIDR<\/li><\/ul><p class=\"wp-block-paragraph\">Route Table Configuration (what the VPC module creates):<\/p><p class=\"wp-block-paragraph\">Public Subnet Route Table:<\/p><pre class=\"wp-block-code\"><code>Destination         Target\n10.0.0.0\/16        local (VPC)\n0.0.0.0\/0          igw-xxxxx (Internet Gateway)<\/code><\/pre><p class=\"wp-block-paragraph\">Private Subnet Route Table:<\/p><pre class=\"wp-block-code\"><code>Destination         Target\n10.0.0.0\/16        local (VPC)\n0.0.0.0\/0          nat-xxxxx (NAT Gateway)<\/code><\/pre><p class=\"wp-block-paragraph\">Database Subnet Route Table:<\/p><pre class=\"wp-block-code\"><code>Destination         Target\n10.0.0.0\/16        local (VPC)<\/code><\/pre><p class=\"wp-block-paragraph\">(No internet route at all!)<\/p><h3 class=\"wp-block-heading\">3. Security Groups &#8211; The Firewall Rules<\/h3><p class=\"wp-block-paragraph\">Bastion Host Security Group:<\/p><pre class=\"wp-block-code\"><code>module \"public_bastion_sg\" {\n  source  = \"terraform-aws-modules\/security-group\/aws\"\n  version = \"5.2.0\"\n\n  name = \"public-bastion-sg\"\n  description = \"Security group for public bastion hosts\"\n  vpc_id = module.vpc.vpc_id\n\n  ingress_rules = [\"ssh-tcp\"]\n  ingress_cidr_blocks = [\"0.0.0.0\/0\"]\n\n  egress_rules = [\"all-all\"]\n  tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This allows SSH (port 22) from anywhere on the internet. The bastion is our entry point.<\/p><p class=\"wp-block-paragraph\">Private Instance Security Group:<\/p><pre class=\"wp-block-code\"><code>module \"private_sg\" {\n  source  = \"terraform-aws-modules\/security-group\/aws\"\n  version = \"5.2.0\"\n\n  name = \"private-sg\"\n  description = \"Security group for private instances\"\n  vpc_id = module.vpc.vpc_id\n\n  ingress_rules = [\"ssh-tcp\", \"http-80-tcp\"]\n  ingress_cidr_blocks = [module.vpc.vpc_cidr_block]\n\n  egress_rules = [\"all-all\"]\n  tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Important: Private instances only accept SSH and HTTP from VPC CIDR (10.0.0.0\/16), not from internet. This is security best practice.<\/p><p class=\"wp-block-paragraph\">How Security Groups Actually Work:<\/p><p class=\"wp-block-paragraph\">Security groups are stateful firewalls. This means:<\/p><ul class=\"wp-block-list\"><li>If I allow incoming SSH (port 22), the response automatically goes back<\/li><li>I don&#8217;t need separate rule for outbound response<\/li><li>Security groups only have ALLOW rules, no DENY rules<\/li><\/ul><p class=\"wp-block-paragraph\">Understanding the ingress_cidr_blocks:<\/p><p class=\"wp-block-paragraph\">Bastion host:<\/p><pre class=\"wp-block-code\"><code>ingress_cidr_blocks = [\"0.0.0.0\/0\"]  # From anywhere on internet<\/code><\/pre><p class=\"wp-block-paragraph\">Private instances:<\/p><pre class=\"wp-block-code\"><code>ingress_cidr_blocks = [module.vpc.vpc_cidr_block]  # Only from 10.0.0.0\/16<\/code><\/pre><p class=\"wp-block-paragraph\">This means:<\/p><ul class=\"wp-block-list\"><li>Internet user \u2192 Can SSH to bastion (allowed)<\/li><li>Internet user \u2192 Cannot SSH to private instance (blocked)<\/li><li>Bastion host \u2192 Can SSH to private instance (allowed, because bastion is in VPC)<\/li><li>Private instance \u2192 Can SSH to another private instance (allowed, both in VPC)<\/li><\/ul><p class=\"wp-block-paragraph\">The egress_rules Explained:<\/p><pre class=\"wp-block-code\"><code>egress_rules = [\"all-all\"]<\/code><\/pre><p class=\"wp-block-paragraph\">This allows ALL outbound traffic. The instances can:<\/p><ul class=\"wp-block-list\"><li>Download packages from yum repositories<\/li><li>Call AWS APIs<\/li><li>Connect to internet via NAT Gateway<\/li><li>Send responses back to connections<\/li><\/ul><p class=\"wp-block-paragraph\">Security Group vs NACL (Network ACL):<\/p><ul class=\"wp-block-list\"><li>Security groups = instance-level firewall (what I&#8217;m using)<\/li><li>NACLs = subnet-level firewall (not configured in this project)<\/li><li>Security groups are stateful, NACLs are stateless<\/li><li>For most cases, security groups are enough<\/li><\/ul><h3 class=\"wp-block-heading\">4. EC2 Instance Deployment<\/h3><p class=\"wp-block-paragraph\">Bastion Host Configuration:<\/p><pre class=\"wp-block-code\"><code>module \"ec2_public\" {\n  source  = \"terraform-aws-modules\/ec2-instance\/aws\"\n  version = \"5.7.0\"\n\n  name = \"${var.environment}-BastionHost\"\n  ami = data.aws_ami.amzlinux2.id\n  instance_type = var.instance_type\n  key_name = var.instance_keypair\n  subnet_id = module.vpc.public_subnets[0]\n  vpc_security_group_ids = [module.public_bastion_sg.security_group_id]\n  tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Private Instances with for_each:<\/p><pre class=\"wp-block-code\"><code>module \"ec2_private\" {\n    depends_on = [ module.vpc ]\n    source  = \"terraform-aws-modules\/ec2-instance\/aws\"\n    version = \"5.7.0\"\n    \n    name = \"${var.environment}-vm\"\n    ami = data.aws_ami.amzlinux2.id\n    instance_type = var.instance_type\n    key_name = var.instance_keypair\n    \n    for_each = toset([\"0\", \"1\"])\n    subnet_id = element(module.vpc.private_subnets, tonumber(each.key))\n    vpc_security_group_ids = [module.private_sg.security_group_id]\n    user_data = file(\"${path.module}\/app1-install.sh\")\n\n    tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Why for_each instead of count?<\/p><ul class=\"wp-block-list\"><li>for_each creates instances with stable names<\/li><li>If I destroy one instance, others keep their identity<\/li><li>With count, destroying instance[0] would rename instance[1] to instance[0]<\/li><\/ul><p class=\"wp-block-paragraph\">Let me explain this problem with count:<\/p><p class=\"wp-block-paragraph\">Using count (the old way):<\/p><pre class=\"wp-block-code\"><code>count = 2\n# Creates: vm[0] and vm[1]<\/code><\/pre><p class=\"wp-block-paragraph\">If I destroy vm[0]:<\/p><ul class=\"wp-block-list\"><li>Terraform renames vm[1] \u2192 vm[0]<\/li><li>This recreates the instance!<\/li><li>All data lost<\/li><li>New IP address assigned<\/li><\/ul><p class=\"wp-block-paragraph\">Using for_each (the better way):<\/p><pre class=\"wp-block-code\"><code>for_each = toset([\"0\", \"1\"])\n# Creates: vm[\"0\"] and vm[\"1\"]<\/code><\/pre><p class=\"wp-block-paragraph\">If I destroy vm[&#8220;0&#8221;]:<\/p><ul class=\"wp-block-list\"><li>vm[&#8220;1&#8221;] stays vm[&#8220;1&#8221;]<\/li><li>No recreation<\/li><li>Data and IP preserved<\/li><\/ul><p class=\"wp-block-paragraph\">Understanding the subnet distribution:<\/p><pre class=\"wp-block-code\"><code>subnet_id = element(module.vpc.private_subnets, tonumber(each.key))<\/code><\/pre><p class=\"wp-block-paragraph\">Let me break this down:<\/p><ul class=\"wp-block-list\"><li>module.vpc.private_subnets = [&#8220;subnet-aaa&#8221;, &#8220;subnet-bbb&#8221;]<\/li><li>each.key = &#8220;0&#8221; or &#8220;1&#8221; (from for_each)<\/li><li>tonumber(each.key) = converts &#8220;0&#8221; to 0, &#8220;1&#8221; to 1<\/li><li>element(list, index) = picks subnet from list<\/li><\/ul><p class=\"wp-block-paragraph\">Result:<\/p><ul class=\"wp-block-list\"><li>Instance &#8220;0&#8221; \u2192 private_subnets[0] \u2192 us-east-1a<\/li><li>Instance &#8220;1&#8221; \u2192 private_subnets[1] \u2192 us-east-1b<\/li><\/ul><p class=\"wp-block-paragraph\">The AMI Data Source:<\/p><pre class=\"wp-block-code\"><code>data \"aws_ami\" \"amzlinux2\" {\n  most_recent = true\n  owners = [ \"amazon\" ]\n  filter {\n    name = \"name\"\n    values = [ \"amzn2-ami-hvm-*-gp2\" ]\n  }\n  filter {\n    name = \"virtualization-type\"\n    values = [ \"hvm\" ]\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This automatically finds the latest Amazon Linux 2 AMI. I don&#8217;t need to hardcode AMI ID like ami-0c55b159cbfafe1f0 which changes every month. The data source always finds the newest version.<\/p><p class=\"wp-block-paragraph\">Why depends_on is critical:<\/p><pre class=\"wp-block-code\"><code>depends_on = [ module.vpc ]<\/code><\/pre><p class=\"wp-block-paragraph\">Without this, Terraform might try to create EC2 instances before VPC exists. The terraform execution happens in parallel for speed, but some resources need others first. The depends_on forces correct order.<\/p><h3 class=\"wp-block-heading\">5. Elastic IP for Bastion Host<\/h3><pre class=\"wp-block-code\"><code>resource \"aws_eip\" \"bastion_eip\" {\n    depends_on = [ module.vpc, module.ec2_public ]\n    instance = module.ec2_public.id\n    domain   = \"vpc\"\n    tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Elastic IP gives bastion host a permanent public IP address. Even if I stop and start the instance, the IP stays the same. This is important for SSH access and security group rules.<\/p><p class=\"wp-block-paragraph\">Why Elastic IP is necessary:<\/p><p class=\"wp-block-paragraph\">Without Elastic IP:<\/p><ul class=\"wp-block-list\"><li>Instance gets random public IP when launched<\/li><li>Public IP changes every stop\/start<\/li><li>I need to update SSH config every time<\/li><li>Security group rules break<\/li><\/ul><p class=\"wp-block-paragraph\">With Elastic IP:<\/p><ul class=\"wp-block-list\"><li>Same IP always: 54.123.45.67 (example)<\/li><li>Can whitelist this IP in company firewall<\/li><li>SSH config never changes<\/li><li>Can create DNS record pointing to this IP<\/li><\/ul><p class=\"wp-block-paragraph\">Understanding the resource block:<\/p><pre class=\"wp-block-code\"><code>resource \"aws_eip\" \"bastion_eip\" {\n    depends_on = [ module.vpc, module.ec2_public ]\n    instance = module.ec2_public.id\n    domain   = \"vpc\"\n    tags = local.common_tags\n}<\/code><\/pre><ul class=\"wp-block-list\"><li>depends_on &#8211; Wait for VPC and EC2 to exist first<\/li><li>instance &#8211; Attach EIP to this specific instance<\/li><li>domain = &#8220;vpc&#8221; &#8211; EIP for VPC (not EC2-Classic which is old)<\/li><\/ul><p class=\"wp-block-paragraph\">EIP Lifecycle:<\/p><ol class=\"wp-block-list\"><li>Terraform creates EIP (gets public IP from AWS pool)<\/li><li>Terraform attaches EIP to bastion instance<\/li><li>Instance can now use this IP<\/li><li>If instance stops \u2192 EIP stays allocated to my account<\/li><li>If instance starts \u2192 EIP automatically reattaches<\/li><li>If I run terraform destroy \u2192 EIP releases back to AWS<\/li><\/ol><p class=\"wp-block-paragraph\">EIP is FREE when:<\/p><ul class=\"wp-block-list\"><li>Attached to a running instance<\/li><li>Not allocated at all<\/li><\/ul><p class=\"wp-block-paragraph\">So always run terraform destroy when done learning!<\/p><h3 class=\"wp-block-heading\">6. Automated SSH Key Distribution<\/h3><p class=\"wp-block-paragraph\">This was the tricky part. Private instances need the SSH key to connect to each other, but the key file is on my laptop. I used Terraform provisioners:<\/p><pre class=\"wp-block-code\"><code>resource \"null_resource\" \"name\" {\n    depends_on = [ module.ec2_public ]\n\n    connection {\n        type = \"ssh\"\n        host = aws_eip.bastion_eip.public_ip\n        user = \"ec2-user\"\n        password = \"\"\n        private_key = file(\"private-key\/terraform-key.pem\")\n    }\n\n    provisioner \"file\" {\n        source = \"private-key\/terraform-key.pem\"\n        destination = \"\/tmp\/terraform-key.pem\"\n    }\n\n    provisioner \"remote-exec\" {\n        inline = [ \n            \"sudo chmod 400 \/tmp\/terraform-key.pem\",\n            \"echo VPC created on `date` and VPC ID: ${module.vpc.vpc_id} &gt;&gt; creation-time-vpc-id.txt\"\n         ]\n    }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">What this does:<\/p><ol class=\"wp-block-list\"><li>Waits for bastion host to be ready (depends_on)<\/li><li>Connects via SSH to bastion host<\/li><li>Copies my private key to \/tmp\/ directory<\/li><li>Sets correct permissions (400 = read-only for owner)<\/li><li>Creates a log file with VPC ID and creation time<\/li><\/ol><p class=\"wp-block-paragraph\">Now from bastion, I can SSH to private instances:<\/p><pre class=\"wp-block-code\"><code>ssh -i \/tmp\/terraform-key.pem ec2-user@10.0.1.X<\/code><\/pre><p class=\"wp-block-paragraph\">Why null_resource is needed:<\/p><p class=\"wp-block-paragraph\">The problem:<\/p><ul class=\"wp-block-list\"><li>Private instances are in private subnet (no public IP)<\/li><li>I cannot SSH directly from my laptop<\/li><li>Private instances need SSH key to communicate with each other<\/li><li>The key file is on my laptop, not on bastion<\/li><\/ul><p class=\"wp-block-paragraph\">The solution:<\/p><ul class=\"wp-block-list\"><li>Use null_resource to run commands AFTER bastion is ready<\/li><li>Copy private key from my laptop to bastion<\/li><li>Now bastion can SSH to private instances<\/li><\/ul><p class=\"wp-block-paragraph\">Understanding each provisioner:<\/p><p class=\"wp-block-paragraph\">Connection block:<\/p><pre class=\"wp-block-code\"><code>connection {\n    type = \"ssh\"\n    host = aws_eip.bastion_eip.public_ip\n    user = \"ec2-user\"\n    password = \"\"\n    private_key = file(\"private-key\/terraform-key.pem\")\n}<\/code><\/pre><ul class=\"wp-block-list\"><li>type = &#8220;ssh&#8221; &#8211; Use SSH protocol<\/li><li>host &#8211; Connect to Elastic IP (not random public IP)<\/li><li>user = &#8220;ec2-user&#8221; &#8211; Default user for Amazon Linux 2<\/li><li>private_key &#8211; Read key file from my laptop<\/li><\/ul><p class=\"wp-block-paragraph\">File provisioner:<\/p><pre class=\"wp-block-code\"><code>provisioner \"file\" {\n    source = \"private-key\/terraform-key.pem\"\n    destination = \"\/tmp\/terraform-key.pem\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This copies file from my laptop to bastion host. Like scp command but automated.<\/p><p class=\"wp-block-paragraph\">Remote-exec provisioner:<\/p><pre class=\"wp-block-code\"><code>provisioner \"remote-exec\" {\n    inline = [ \n        \"sudo chmod 400 \/tmp\/terraform-key.pem\",\n        \"echo VPC created on `date` and VPC ID: ${module.vpc.vpc_id} &gt;&gt; creation-time-vpc-id.txt\"\n    ]\n}<\/code><\/pre><ul class=\"wp-block-list\"><li>sudo chmod 400 &#8211; Sets permissions to read-only for owner (required for SSH keys)<\/li><li>echo &#8230; &gt;&gt; file.txt &#8211; Creates log file with timestamp and VPC ID<\/li><\/ul><p class=\"wp-block-paragraph\">Why chmod 400 is critical: SSH refuses to use key files with wrong permissions:<\/p><pre class=\"wp-block-code\"><code>chmod 777 terraform-key.pem \u2192 SSH ERROR: \"permissions are too open\"\nchmod 400 terraform-key.pem \u2192 SSH works<\/code><\/pre><p class=\"wp-block-paragraph\">400 means:<\/p><ul class=\"wp-block-list\"><li>Owner can read (4)<\/li><li>Group cannot do anything (0)<\/li><li>Others cannot do anything (0)<\/li><\/ul><p class=\"wp-block-paragraph\">Alternative approaches I considered:<\/p><ol class=\"wp-block-list\"><li>AWS Systems Manager Session Manager &#8211; No SSH key needed, but costs money<\/li><li>EC2 Instance Connect &#8211; Temporary SSH keys, but more complex<\/li><li>This null_resource approach &#8211; Simple and free<\/li><\/ol><p class=\"wp-block-paragraph\">For learning, null_resource is best. For production, use Systems Manager.<\/p><h3 class=\"wp-block-heading\">7. Web Server Installation Script<\/h3><p class=\"wp-block-paragraph\">I used user data to install Apache automatically when instances launch:<\/p><pre class=\"wp-block-code\"><code>#!\/bin\/bash\nsudo yum update -y\nsudo yum install -y httpd\nsudo systemctl enable httpd\nsudo service httpd start  \nsudo echo '&lt;h1&gt;Welcome to Root Access - APP-1&lt;\/h1&gt;' | sudo tee \/var\/www\/html\/index.html\nsudo mkdir \/var\/www\/html\/app1\nsudo echo '&lt;!DOCTYPE html&gt; &lt;html&gt; &lt;body style=\"background-color:rgb(250, 210, 210);\"&gt; &lt;h1&gt;Welcome to Root Access - APP-1&lt;\/h1&gt; &lt;p&gt;Terraform Demo by Reza Chegini&lt;\/p&gt; &lt;p&gt;Application Version: V1&lt;\/p&gt; &lt;\/body&gt;&lt;\/html&gt;' | sudo tee \/var\/www\/html\/app1\/index.html\n\n# Get instance metadata\nTOKEN=`curl -X PUT \"http:\/\/169.254.169.254\/latest\/api\/token\" -H \"X-aws-ec2-metadata-token-ttl-seconds: 21600\"`\nsudo curl -H \"X-aws-ec2-metadata-token: $TOKEN\" http:\/\/169.254.169.254\/latest\/dynamic\/instance-identity\/document -o \/var\/www\/html\/app1\/metadata.html<\/code><\/pre><p class=\"wp-block-paragraph\">This script uses IMDSv2 (Instance Metadata Service version 2) for better security. The token-based approach prevents SSRF attacks.<\/p><p class=\"wp-block-paragraph\">Let me explain the script line by line:<\/p><p class=\"wp-block-paragraph\">#!\/bin\/bash<\/p><p class=\"wp-block-paragraph\">Shebang &#8211; tells Linux to run this with bash shell<\/p><p class=\"wp-block-paragraph\">sudo yum update -y<\/p><ul class=\"wp-block-list\"><li>yum update &#8211; Updates all installed packages<\/li><li>-y &#8211; Automatically answers &#8220;yes&#8221; to all prompts<\/li><li>sudo &#8211; Run with root privileges<\/li><\/ul><pre class=\"wp-block-code\"><code>sudo yum install -y httpd\nsudo systemctl enable httpd\nsudo service httpd start<\/code><\/pre><ul class=\"wp-block-list\"><li>Install Apache web server<\/li><li>enable &#8211; Start Apache automatically on boot<\/li><li>start &#8211; Start Apache now<\/li><\/ul><pre class=\"wp-block-code\"><code>sudo echo '&lt;h1&gt;Welcome to Root Access - APP-1&lt;\/h1&gt;' | sudo tee \/var\/www\/html\/index.html<\/code><\/pre><ul class=\"wp-block-list\"><li>tee &#8211; Writes to file AND shows on screen<\/li><li>Creates homepage at root URL: http:\/\/INSTANCE_IP\/<\/li><\/ul><pre class=\"wp-block-code\"><code>sudo mkdir \/var\/www\/html\/app1\nsudo echo '&lt;!DOCTYPE html&gt;...' | sudo tee \/var\/www\/html\/app1\/index.html<\/code><\/pre><ul class=\"wp-block-list\"><li>Creates \/app1 directory<\/li><li>Creates page at: http:\/\/INSTANCE_IP\/app1\/<\/li><\/ul><p class=\"wp-block-paragraph\">The IMDSv2 Security:<\/p><p class=\"wp-block-paragraph\">Old way (IMDSv1 &#8211; insecure):<\/p><pre class=\"wp-block-code\"><code>curl http:\/\/169.254.169.254\/latest\/dynamic\/instance-identity\/document<\/code><\/pre><p class=\"wp-block-paragraph\">This is vulnerable to SSRF (Server-Side Request Forgery) attacks.<\/p><p class=\"wp-block-paragraph\">New way (IMDSv2 &#8211; secure):<\/p><pre class=\"wp-block-code\"><code># Step 1: Get session token\nTOKEN=`curl -X PUT \"http:\/\/169.254.169.254\/latest\/api\/token\" -H \"X-aws-ec2-metadata-token-ttl-seconds: 21600\"`\n\n# Step 2: Use token to get metadata\nsudo curl -H \"X-aws-ec2-metadata-token: $TOKEN\" http:\/\/169.254.169.254\/latest\/dynamic\/instance-identity\/document -o \/var\/www\/html\/app1\/metadata.html<\/code><\/pre><p class=\"wp-block-paragraph\">Why IMDSv2 is more secure:<\/p><ol class=\"wp-block-list\"><li>Must use PUT request to get token (SSRF usually uses GET)<\/li><li>Token expires after 6 hours (21600 seconds)<\/li><li>Token must be in HTTP header (harder to exploit)<\/li><\/ol><p class=\"wp-block-paragraph\">What metadata contains:<\/p><pre class=\"wp-block-code\"><code>{\n  \"instanceId\": \"i-0abcd1234efgh5678\",\n  \"region\": \"us-east-1\",\n  \"availabilityZone\": \"us-east-1a\",\n  \"privateIp\": \"10.0.1.45\",\n  \"instanceType\": \"t2.micro\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This metadata is useful for:<\/p><ul class=\"wp-block-list\"><li>Application knows which AZ it&#8217;s running in<\/li><li>Dynamic configuration based on instance type<\/li><li>Monitoring and logging with instance ID<\/li><\/ul><p class=\"wp-block-paragraph\">User Data Execution:<\/p><ul class=\"wp-block-list\"><li>Runs as root user (already has sudo privileges)<\/li><li>Executes only on FIRST boot<\/li><li>Logs saved in \/var\/log\/cloud-init-output.log<\/li><li>If script fails, instance still launches (no rollback)<\/li><\/ul><p class=\"wp-block-paragraph\">Testing if script worked:<\/p><pre class=\"wp-block-code\"><code># SSH to instance\nssh -i key.pem ec2-user@INSTANCE_IP\n\n# Check Apache status\nsudo systemctl status httpd\n\n# Check if files were created\nls -la \/var\/www\/html\/\nls -la \/var\/www\/html\/app1\/\n\n# View script output\nsudo cat \/var\/log\/cloud-init-output.log<\/code><\/pre><h3 class=\"wp-block-heading\">8. Local Values for Resource Naming<\/h3><p class=\"wp-block-paragraph\">I created consistent naming across all resources:<\/p><pre class=\"wp-block-code\"><code>locals {\n  owners = var.business_division\n  environment = var.environment\n  name = \"${var.business_division}-${var.environment}\"\n  common_tags = {\n    owners = local.owners\n    environment = local.environment\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">So my resources get names like: sap-dev-BastionHost, sap-dev-myvpc<\/p><p class=\"wp-block-paragraph\">This makes it easy to identify resources in AWS console and track costs by environment.<\/p><h3 class=\"wp-block-heading\">Variables Organization<\/h3><p class=\"wp-block-paragraph\">I organized variables in separate files:<\/p><p class=\"wp-block-paragraph\">ec2instance.auto.tfvars:<\/p><pre class=\"wp-block-code\"><code>instance_type = \"t2.micro\"\ninstance_keypair = \"terraform-key\"\nprivate_instance_count = 2<\/code><\/pre><p class=\"wp-block-paragraph\">c2-generic-variables.tf:<\/p><pre class=\"wp-block-code\"><code>variable \"aws_region\" {\n  default = \"us-east-1\"\n}\n\nvariable \"environment\" {\n  default = \"dev\"\n}\n\nvariable \"business_division\" {\n  default = \"sap\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Terraform automatically loads *.auto.tfvars files. This keeps configuration clean and organized.<\/p><h3 class=\"wp-block-heading\">How to Deploy This Infrastructure<\/h3><p class=\"wp-block-paragraph\">Clone the repository and navigate to directory:<\/p><pre class=\"wp-block-code\"><code>cd AWS-EC2Instance-and-SecurityGroups\/terraform-manifests<\/code><\/pre><p class=\"wp-block-paragraph\">Create directory for SSH key:<\/p><pre class=\"wp-block-code\"><code>mkdir -p private-key\n# Copy your terraform-key.pem here\nchmod 400 private-key\/terraform-key.pem<\/code><\/pre><p class=\"wp-block-paragraph\">Initialize Terraform:<\/p><pre class=\"wp-block-code\"><code>terraform init<\/code><\/pre><p class=\"wp-block-paragraph\">Review the plan:<\/p><pre class=\"wp-block-code\"><code>terraform plan<\/code><\/pre><p class=\"wp-block-paragraph\">Apply configuration:<\/p><pre class=\"wp-block-code\"><code>terraform apply<\/code><\/pre><p class=\"wp-block-paragraph\">Access bastion host:<\/p><pre class=\"wp-block-code\"><code>ssh -i private-key\/terraform-key.pem ec2-user@&lt;ELASTIC_IP&gt;<\/code><\/pre><p class=\"wp-block-paragraph\">From bastion, access private instances:<\/p><pre class=\"wp-block-code\"><code>ssh -i \/tmp\/terraform-key.pem ec2-user@&lt;PRIVATE_IP&gt;\ncurl http:\/\/&lt;PRIVATE_IP&gt;\/app1\/<\/code><\/pre><h3 class=\"wp-block-heading\">Lessons I Learned<\/h3><h3 class=\"wp-block-heading\">1. Security Group Direction Matters<\/h3><p class=\"wp-block-paragraph\">I made mistake thinking &#8220;if I allow SSH in security group A, I can connect to security group B&#8221;. Wrong! Both source and destination security groups need rules. That&#8217;s why private security group allows traffic from VPC CIDR.<\/p><p class=\"wp-block-paragraph\">My mistake: I thought bastion security group allowing SSH is enough. Then I couldn&#8217;t connect to private instances!<\/p><p class=\"wp-block-paragraph\">What I learned: Traffic must be allowed in BOTH places:<\/p><ol class=\"wp-block-list\"><li>Source: Bastion security group must allow outbound SSH (egress)<\/li><li>Destination: Private security group must allow inbound SSH from VPC (ingress)<\/li><\/ol><p class=\"wp-block-paragraph\">My config works because:<\/p><ul class=\"wp-block-list\"><li>Bastion SG: egress_rules = [&#8220;all-all&#8221;] \u2713<\/li><li>Private SG: ingress_cidr_blocks = [module.vpc.vpc_cidr_block] \u2713<\/li><\/ul><p class=\"wp-block-paragraph\">Common mistake many people make:<\/p><pre class=\"wp-block-code\"><code># WRONG - too restrictive\ningress_cidr_blocks = [module.public_bastion_sg.security_group_id]<\/code><\/pre><p class=\"wp-block-paragraph\">This doesn&#8217;t work! ingress_cidr_blocks needs IP addresses, not security group IDs.<\/p><p class=\"wp-block-paragraph\">Correct way to reference security groups:<\/p><pre class=\"wp-block-code\"><code># Method 1: Use CIDR blocks (what I use)\ningress_cidr_blocks = [\"10.0.0.0\/16\"]\n\n# Method 2: Use security group source\ningress_with_source_security_group_id = [\n  {\n    rule                     = \"ssh-tcp\"\n    source_security_group_id = module.public_bastion_sg.security_group_id\n  }\n]<\/code><\/pre><p class=\"wp-block-paragraph\">Method 1 is simpler for learning. Method 2 is more secure for production.<\/p><h3 class=\"wp-block-heading\">2. Depends_on Is Important<\/h3><p class=\"wp-block-paragraph\">Without depends_on in private EC2 module, Terraform might try to create instances before VPC exists. This causes errors. Always think about resource dependencies.<\/p><p class=\"wp-block-paragraph\">The error I got:<\/p><pre class=\"wp-block-code\"><code>Error: Error launching source instance: InvalidSubnetID.NotFound<\/code><\/pre><p class=\"wp-block-paragraph\">Why it happened: Terraform executes resources in parallel by default. It tried to create EC2 instances while VPC module was still creating subnets.<\/p><p class=\"wp-block-paragraph\">The fix:<\/p><pre class=\"wp-block-code\"><code>module \"ec2_private\" {\n    depends_on = [ module.vpc ]  # Wait for VPC first\n    ...\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Other dependencies in this project:<\/p><pre class=\"wp-block-code\"><code># Elastic IP depends on VPC and EC2\nresource \"aws_eip\" \"bastion_eip\" {\n    depends_on = [ module.vpc, module.ec2_public ]\n    ...\n}\n\n# Null resource depends on EC2\nresource \"null_resource\" \"name\" {\n    depends_on = [ module.ec2_public ]\n    ...\n}<\/code><\/pre><p class=\"wp-block-paragraph\">When Terraform figures out dependencies automatically:<\/p><ul class=\"wp-block-list\"><li>When you reference resource attribute: module.vpc.vpc_id<\/li><li>Terraform knows VPC must exist first<\/li><\/ul><p class=\"wp-block-paragraph\">When you need explicit depends_on:<\/p><ul class=\"wp-block-list\"><li>Connection timing (SSH to just-created instance)<\/li><li>Resource recreation order<\/li><li>External dependencies<\/li><\/ul><h3 class=\"wp-block-heading\">3. User Data Runs Only Once<\/h3><p class=\"wp-block-paragraph\">The app1-install.sh script runs only when instance launches first time. If you modify the script and run terraform apply, it won&#8217;t update existing instances. You need to recreate them with terraform taint or destroy\/apply.<\/p><p class=\"wp-block-paragraph\">What happened to me:<\/p><ol class=\"wp-block-list\"><li>Deployed instances with basic user data<\/li><li>Updated user data script to add more features<\/li><li>Ran terraform apply<\/li><li>Expected: Script runs again<\/li><li>Reality: Nothing changed!<\/li><\/ol><p class=\"wp-block-paragraph\">Why: User data executes during instance initialization only. Terraform sees:<\/p><ul class=\"wp-block-list\"><li>user_data changed<\/li><li>But AWS doesn&#8217;t support updating user data on running instance<\/li><li>Terraform shows &#8220;no changes needed&#8221;<\/li><\/ul><p class=\"wp-block-paragraph\">Solutions:<\/p><p class=\"wp-block-paragraph\">Option 1: Recreate instance (destroys data)<\/p><pre class=\"wp-block-code\"><code>terraform taint module.ec2_private[\"0\"]\nterraform apply<\/code><\/pre><p class=\"wp-block-paragraph\">Option 2: Use remote-exec provisioner<\/p><pre class=\"wp-block-code\"><code>provisioner \"remote-exec\" {\n    inline = [\n        \"bash \/path\/to\/script.sh\"\n    ]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This runs every terraform apply but is less reliable.<\/p><p class=\"wp-block-paragraph\">Option 3: Configuration management<\/p><ul class=\"wp-block-list\"><li>Use Ansible, Chef, Puppet<\/li><li>Updates without instance recreation<\/li><li>But more complex to setup<\/li><\/ul><p class=\"wp-block-paragraph\">For learning, I recreate instances, use proper config management.<\/p><h3 class=\"wp-block-heading\">4. File Provisioner Path Issues<\/h3><p class=\"wp-block-paragraph\">I spent 2 hours debugging why file provisioner failed!<\/p><p class=\"wp-block-paragraph\">The error:<\/p><pre class=\"wp-block-code\"><code>Error: file: open private-key\/terraform-key.pem: no such file or directory<\/code><\/pre><p class=\"wp-block-paragraph\">What I learned:<\/p><ul class=\"wp-block-list\"><li>Terraform runs from where you execute terraform apply<\/li><li>Path is relative to current directory<\/li><li>Not relative to .tf file location<\/li><\/ul><p class=\"wp-block-paragraph\">My directory structure:<\/p><pre class=\"wp-block-code\"><code>project\/\n\u251c\u2500\u2500 terraform-manifests\/\n\u2502   \u251c\u2500\u2500 c9-nullresource-provisioners.tf\n\u2502   \u2514\u2500\u2500 private-key\/\n\u2502       \u2514\u2500\u2500 terraform-key.pem<\/code><\/pre><p class=\"wp-block-paragraph\">Wrong (what I tried first):<\/p><pre class=\"wp-block-code\"><code>private_key = file(\"terraform-key.pem\")  # Not found!<\/code><\/pre><p class=\"wp-block-paragraph\">Correct:<\/p><pre class=\"wp-block-code\"><code>private_key = file(\"private-key\/terraform-key.pem\")  # Works when running from terraform-manifests\/<\/code><\/pre><p class=\"wp-block-paragraph\">Better way using path variables:<\/p><pre class=\"wp-block-code\"><code>private_key = file(\"${path.module}\/private-key\/terraform-key.pem\")<\/code><\/pre><p class=\"wp-block-paragraph\">This works regardless of where you run terraform from!<\/p><h3 class=\"wp-block-heading\">5. SSH Connection Timeout During Provisioning<\/h3><p class=\"wp-block-paragraph\">Sometimes null_resource provisioner times out:<\/p><pre class=\"wp-block-code\"><code>Error: timeout - last error: ssh: handshake failed<\/code><\/pre><p class=\"wp-block-paragraph\">Reasons I discovered:<\/p><ol class=\"wp-block-list\"><li>Security group not yet active (AWS eventual consistency)<\/li><li>Instance not fully booted<\/li><li>SSH daemon not started<\/li><\/ol><p class=\"wp-block-paragraph\">The fix &#8211; add wait time:<\/p><pre class=\"wp-block-code\"><code>resource \"null_resource\" \"name\" {\n    depends_on = [ module.ec2_public ]\n    \n    # Wait for instance to be ready\n    provisioner \"local-exec\" {\n        command = \"sleep 30\"\n    }\n    \n    connection {\n        type = \"ssh\"\n        host = aws_eip.bastion_eip.public_ip\n        ...\n    }\n}<\/code><\/pre><h3 class=\"wp-block-heading\">6. Tagging Saved My Budget<\/h3><p class=\"wp-block-paragraph\">Without proper tags, I couldn&#8217;t track which resources belong to which project.<\/p><p class=\"wp-block-paragraph\">What happened:<\/p><ul class=\"wp-block-list\"><li>Created resources in multiple projects<\/li><li>AWS bill showed $150\/month<\/li><li>Couldn&#8217;t figure out which project costs what!<\/li><\/ul><p class=\"wp-block-paragraph\">My tagging strategy:<\/p><pre class=\"wp-block-code\"><code>locals {\n  common_tags = {\n    owners = local.owners           # \"sap\"\n    environment = local.environment  # \"dev\"\n    Project = \"terraform-learning\"\n    ManagedBy = \"Terraform\"\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Pro tip: Add terraform = true tag to all Terraform-managed resources. Makes cleanup easier!<\/p><h3 class=\"wp-block-heading\">Project Files Structure<\/h3><pre class=\"wp-block-code\"><code>terraform-manifests\/\n\u251c\u2500\u2500 c1-versions.tf                    # Terraform &amp; provider versions\n\u251c\u2500\u2500 c2-generic-variables.tf           # Region, environment variables\n\u251c\u2500\u2500 c3-local-values.tf               # Local values for naming\n\u251c\u2500\u2500 c4-01-vpc-variables.tf           # VPC input variables\n\u251c\u2500\u2500 c4-02-vpc-module.tf              # VPC module configuration\n\u251c\u2500\u2500 c4-03-vpc-outputs.tf             # VPC outputs\n\u251c\u2500\u2500 c5-03-securitygroup-bastionsg.tf # Bastion security group\n\u251c\u2500\u2500 c5-04-securitygroup-privatesg.tf # Private security group\n\u251c\u2500\u2500 c6-01-datasource-ami.tf          # Amazon Linux 2 AMI\n\u251c\u2500\u2500 c7-03-ec2instance-bastion.tf     # Bastion host config\n\u251c\u2500\u2500 c7-04-ec2instance-private.tf     # Private instances config\n\u251c\u2500\u2500 c8-elasticip.tf                  # Elastic IP resource\n\u251c\u2500\u2500 c9-nullresource-provisioners.tf  # SSH key provisioner\n\u251c\u2500\u2500 app1-install.sh                  # Web server setup script\n\u2514\u2500\u2500 ec2instance.auto.tfvars          # EC2 variable values<\/code><\/pre><h3 class=\"wp-block-heading\">Outputs After Deployment<\/h3><p class=\"wp-block-paragraph\">Terraform shows important information:<\/p><pre class=\"wp-block-code\"><code>vpc_id = \"vpc-xxxxx\"\nvpc_cidr_block = \"10.0.0.0\/16\"\npublic_subnets = [\"subnet-xxxxx\", \"subnet-yyyyy\"]\nprivate_subnets = [\"subnet-aaaaa\", \"subnet-bbbbb\"]\nnat_public_ips = [\"x.x.x.x\"]\nec2_bastion_public_id = \"y.y.y.y\"\nec2_private_private_ips = [\"10.0.1.x\", \"10.0.2.y\"]<\/code><\/pre><p class=\"wp-block-paragraph\">Save these values! You&#8217;ll need them for connecting and troubleshooting.<\/p><p class=\"wp-block-paragraph\">To save money during learning:<\/p><pre class=\"wp-block-code\"><code>vpc_enable_nat_gateway = false  # Private instances lose internet<\/code><\/pre><p class=\"wp-block-paragraph\">Or destroy infrastructure when not using:<\/p><pre class=\"wp-block-code\"><code>terraform destroy<\/code><\/pre><h3 class=\"wp-block-heading\">Troubleshooting Tips<\/h3><p class=\"wp-block-paragraph\">Problem: Can&#8217;t SSH to bastion host<\/p><ul class=\"wp-block-list\"><li>Check security group allows your IP<\/li><li>Verify key permissions: chmod 400 terraform-key.pem<\/li><li>Check Elastic IP is attached<\/li><\/ul><p class=\"wp-block-paragraph\">Problem: Can&#8217;t reach private instances from bastion<\/p><ul class=\"wp-block-list\"><li>Verify you&#8217;re using correct private IP<\/li><li>Check private security group allows SSH from VPC CIDR<\/li><li>Make sure NAT Gateway is running (for yum updates)<\/li><\/ul><p class=\"wp-block-paragraph\">Problem: Terraform apply fails with &#8220;subnet not found&#8221;<\/p><ul class=\"wp-block-list\"><li>Run terraform refresh<\/li><li>Check VPC module created subnets successfully<\/li><li>Verify availability zones are correct for your region<\/li><\/ul>","protected":false},"excerpt":{"rendered":"<p>Why I Built This Project When I started learning AWS, I struggled to understand how networking really works. Reading documentation wasn&#8217;t enough&hellip;<\/p>\n","protected":false},"author":2,"featured_media":139,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"1","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-secure-aws-infrastructure-vpc-bastion-hosts-private-chegini-ibmzc\/","footnotes":""},"categories":[85,10],"tags":[60,8,89,38,87,88],"series":[86],"class_list":["post-137","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-aws","category-devops","tag-aws","tag-devops","tag-ec2","tag-security","tag-terraform","tag-vpc","series-aws-with-terraform"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/building-secure-aws-infrastructure-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/137","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=137"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/137\/revisions"}],"predecessor-version":[{"id":138,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/137\/revisions\/138"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/139"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=137"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=137"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=137"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=137"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}