AWS - Using AMI and VPC peering

Introduction
Resources that will be created
Terraform init/plan/apply
Testing connectivity with ssh and ping
Resource removal
The reference terraform file

Introduction

In this article we will use terraform and AWS cloud infrastructure to create two Linux VMs in two separate VPCs, each VM in its separate subnet, with private and public IP addresses. The machines will use a public AWS AMI with Debian 12 and will be able to access one another (test via ping), by using VPC peering.

Resources that will be created

Let us discuss what will be used:

  • AWS VPC (Virtual Private Cloud) is a logically isolated section where you can deploy AWS resources in a customised network environment. It allows you to define IP address ranges, subnets, route tables, and network gateways
  • VPC peering in AWS allows two Virtual Private Clouds (VPCs) to communicate privately as if they were part of the same network; auto_accept = true means the connection will be established automatically, since we use the same AWS account for both VPCs
  • an AWS Subnet is a logical division within a Virtual Private Cloud (VPC); they use IP blocks such as 10.0.1.0/24 to assign addresses to computers; routing tables control flow between subnets and external networks (internet)
  • an internet gateway is attached to the VPC to allow VMs in its subnets to access the internet; each VM will have a public IP
  • a security group is attached to each VPC; it acts as a virtual firewall for the EC2 instances, controlling inbound and outbound traffic; in our case, we will allows ssh connection to the machines via port 22 and ping to test connectivity from the internet
  • each EC2 VM will have its public and private IP address, they will be displayed by terraform after it deploys the infrastructure
  • AWS AMI (Amazon Machine Image) is a pre-configured template that contains the operating system, application server, and configurations needed to launch an Amazon EC2 instance; we will use a Debian 12 image, as it is less costly
  • the resources will be deployed in the eu-west-2 London region, in eu-west-2a and eu-west-2b availability zones, for redundancy
  • the AWS CLI profile aws-sa-2 will be used; make sure you have the config and credentials files with the profile
  • the ssh keypair named my-aws2-keypair1 will be used by terraform; look in EC2 - Key pairs for available keys and choose one by name

Terraform init/plan/apply

To initialise, we will use terraform init, which will download the hashicorp/aws plugin for AWS infrastructure.

terraform $ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v5.97.0...
- Installed hashicorp/aws v5.97.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

To see what changes will be implemented, let’s use terraform plan:

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.vm1 will be created
  + resource "aws_instance" "vm1" {
      + ami                                  = "ami-0306865c645d1899c"
      + arn                                  = (known after apply)
      + associate_public_ip_address          = (known after apply)
      + availability_zone                    = (known after apply)
      + cpu_core_count                       = (known after apply)
[..]
  # aws_security_group.sg1 will be created
  + resource "aws_security_group" "sg1" {
      + arn                    = (known after apply)
      + description            = "Allow SSH and Ping access for VPC1"
      + egress                 = [
          + {
              + cidr_blocks      = [
                  + "0.0.0.0/0",
                ]
              + from_port        = 0
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "-1"
              + security_groups  = []
              + self             = false
              + to_port          = 0
                # (1 unchanged attribute hidden)
            },
        ]

To apply the infra changes and auto-confirm, use terraform apply -auto-approve.
The result is the public and private IPs of the two VMs created. We can use the public IPs to connect to the machines via ssh.

Apply complete! Resources: 19 added, 0 changed, 0 destroyed.

Outputs:

vm1_private_ip = "10.1.1.18"
vm1_public_ip = "18.x.y.251"
vm2_private_ip = "10.2.1.194"
vm2_public_ip = "13.x.y.69"

Testing connectivity with ssh and ping

Let’s connect to one of the VMs:

~ $ ssh -i aws/my-aws2-keypair1.pem admin@18.x.y.251
The authenticity of host '18.x.y.251 (18.x.y.251)' can't be established.
ED25519 key fingerprint is SHA256:yv8QsF9AgW/xxx.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '18.x.y.251' (ED25519) to the list of known hosts.
Linux ip-10-1-1-18 6.1.0-32-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.129-1 (2025-03-06) x86_64
[..]
admin@ip-10-1-1-18:~$ 

We can see its private IP address is 10.1.1.18/24, which is in the VPC1 subnet 1 10.1.1.0/24 that we defined in the .tf file.

admin@ip-10-1-1-18:~$ ip a s
2: enX0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc fq_codel state UP group default qlen 1000
    link/ether 06:db:0b:cb:35:f3 brd ff:ff:ff:ff:ff:ff
    inet 10.1.1.18/24 metric 100 brd 10.1.1.255 scope global dynamic enX0
       valid_lft 3221sec preferred_lft 3221sec
    inet6 fe80::4db:bff:fecb:35f3/64 scope link 
       valid_lft forever preferred_lft forever

From the terraform output, we see that the second VM’s IP vm2_private_ip is 10.2.1.194, let’s try to ping it:

admin@ip-10-1-1-18:~$ ping 10.2.1.194
PING 10.2.1.194 (10.2.1.194) 56(84) bytes of data.
64 bytes from 10.2.1.194: icmp_seq=1 ttl=64 time=1.85 ms
64 bytes from 10.2.1.194: icmp_seq=2 ttl=64 time=1.29 ms
^C
--- 10.2.1.194 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 1.287/1.568/1.849/0.281 ms

Resource removal

To destroy the instantiated infrastructure, we will use terraform destroy --auto-approve, which will auto-confirm the resource removal. This will delete all items created (VMs, VPCs, etc.), with confirmation at the end.

aws_vpc.vpc1: Destruction complete after 1s
aws_vpc.vpc2: Destruction complete after 1s

Destroy complete! Resources: 19 destroyed.

The reference terraform file

The terraform file, name it main.tf or linux-vpc-peering.tf or similar.

# Configure the AWS Provider
# Replace "eu-west-2" with your desired region
# Replace "aws-sa-2" with your AWS CLI profile name
provider "aws" {
  region  = "eu-west-2"
  profile = "aws-sa-2"
}

# Define variables for user-specific values
variable "key_pair_name" {
  description = "The name of the SSH key pair to use for the EC2 instances."
  type        = string
  default     = "my-aws2-keypair1" # Updated key_pair_name
}

variable "ami_id" {
  description = "The AMI ID for the Linux instances."
  type        = string
  default     = "ami-0306865c645d1899c" # Updated ami_id
}

# --- VPC 1 and associated resources ---

# Create VPC 1
resource "aws_vpc" "vpc1" {
  cidr_block = "10.1.0.0/16" # Replace with your desired CIDR block
  tags = {
    Name = "MyVPC1-Terraform"
  }
}

# Create Subnet 1 in VPC 1
resource "aws_subnet" "subnet1" {
  vpc_id                  = aws_vpc.vpc1.id
  cidr_block              = "10.1.1.0/24" # Replace with your desired CIDR block within VPC1
  availability_zone       = "eu-west-2a" # Corrected AZ specification
  map_public_ip_on_launch = true # Automatically assign public IPs to instances in this subnet

  tags = {
    Name = "MySubnet1-Terraform"
  }
}

# Create Internet Gateway for VPC 1
resource "aws_internet_gateway" "igw1" {
  vpc_id = aws_vpc.vpc1.id
  tags = {
    Name = "MyVPC1-IGW-Terraform"
  }
}

# Create Route Table for VPC 1
resource "aws_route_table" "rt1" {
  vpc_id = aws_vpc.vpc1.id
  tags = {
    Name = "MyVPC1-RT-Terraform"
  }
}

# Create default route to Internet Gateway in Route Table 1
resource "aws_route" "default_route1" {
  route_table_id         = aws_route_table.rt1.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw1.id
  # Ensure this route is created before associating the route table
  depends_on = [aws_internet_gateway.igw1]
}

# Associate Route Table 1 with Subnet 1
resource "aws_route_table_association" "rta1" {
  subnet_id      = aws_subnet.subnet1.id
  route_table_id = aws_route_table.rt1.id
}

# Create Security Group for VPC 1
resource "aws_security_group" "sg1" {
  name        = "my-vpc1-sg-terraform"
  description = "Allow SSH and Ping access for VPC1"
  vpc_id      = aws_vpc.vpc1.id

  # Ingress rule for SSH (port 22) from anywhere
  ingress {
    description = "SSH from Internet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # WARNING: Allowing SSH from anywhere (0.0.0.0/0) is not recommended for production. Restrict to known IPs.
  }

  # Ingress rule for ICMP (Ping) from VPC2's CIDR block
  ingress {
    description = "Ping from VPC2"
    from_port   = -1 # -1 indicates all ICMP types and codes
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = [aws_vpc.vpc2.cidr_block] # Allow ping from VPC2's CIDR
  }

  # Egress rule (allow all outbound traffic)
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "MyVPC1-SG-Terraform"
  }
}

# Create EC2 Instance 1 in Subnet 1
resource "aws_instance" "vm1" {
  ami           = var.ami_id
  instance_type = "t2.micro" # Or your desired instance type
  subnet_id     = aws_subnet.subnet1.id
  key_name      = var.key_pair_name
  vpc_security_group_ids = [aws_security_group.sg1.id] # Associate the security group

  tags = {
    Name = "LinuxVM1-VPC1-Terraform"
  }
}

# --- VPC 2 and associated resources ---

# Create VPC 2
resource "aws_vpc" "vpc2" {
  cidr_block = "10.2.0.0/16" # Replace with your desired CIDR block (must not overlap with VPC1)
  tags = {
    Name = "MyVPC2-Terraform"
  }
}

# Create Subnet 2 in VPC 2
resource "aws_subnet" "subnet2" {
  vpc_id                  = aws_vpc.vpc2.id
  cidr_block              = "10.2.1.0/24" # Replace with your desired CIDR block within VPC2
  availability_zone       = "eu-west-2b" # Corrected AZ specification
  map_public_ip_on_launch = true # Automatically assign public IPs to instances in this subnet

  tags = {
    Name = "MySubnet2-Terraform"
  }
}

# Create Internet Gateway for VPC 2
resource "aws_internet_gateway" "igw2" {
  vpc_id = aws_vpc.vpc2.id
  tags = {
    Name = "MyVPC2-IGW-Terraform"
  }
}

# Create Route Table for VPC 2
resource "aws_route_table" "rt2" {
  vpc_id = aws_vpc.vpc2.id
  tags = {
    Name = "MyVPC2-RT-Terraform"
  }
}

# Create default route to Internet Gateway in Route Table 2
resource "aws_route" "default_route2" {
  route_table_id         = aws_route_table.rt2.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw2.id
   # Ensure this route is created before associating the route table
  depends_on = [aws_internet_gateway.igw2]
}

# Associate Route Table 2 with Subnet 2
resource "aws_route_table_association" "rta2" {
  subnet_id      = aws_subnet.subnet2.id
  route_table_id = aws_route_table.rt2.id
}

# Create Security Group for VPC 2
resource "aws_security_group" "sg2" {
  name        = "my-vpc2-sg-terraform"
  description = "Allow SSH and Ping access for VPC2"
  vpc_id      = aws_vpc.vpc2.id

  # Ingress rule for SSH (port 22) from anywhere
  ingress {
    description = "SSH from Internet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # WARNING: Allowing SSH from anywhere (0.0.0.0/0) is not recommended for production. Restrict to known IPs.
  }

  # Ingress rule for ICMP (Ping) from VPC1's CIDR block
  ingress {
    description = "Ping from VPC1"
    from_port   = -1 # -1 indicates all ICMP types and codes
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = [aws_vpc.vpc1.cidr_block] # Allow ping from VPC1's CIDR
  }

  # Egress rule (allow all outbound traffic)
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "MyVPC2-SG-Terraform"
  }
}

# Create EC2 Instance 2 in Subnet 2
resource "aws_instance" "vm2" {
  ami           = var.ami_id
  instance_type = "t2.micro" # Or your desired instance type
  subnet_id     = aws_subnet.subnet2.id
  key_name      = var.key_pair_name
  vpc_security_group_ids = [aws_security_group.sg2.id] # Associate the security group

  tags = {
    Name = "LinuxVM2-VPC2-Terraform"
  }
}

# --- VPC Peering Configuration ---

# Request and auto-accept a VPC peering connection from VPC1 to VPC2
resource "aws_vpc_peering_connection" "vpc_peering" {
  peer_vpc_id   = aws_vpc.vpc2.id
  vpc_id        = aws_vpc.vpc1.id
  auto_accept   = true # Set to true to auto-accept within the same account

  tags = {
    Name = "vpc1-to-vpc2-peering"
  }
}

# Add a route in VPC1's route table to send traffic for VPC2's CIDR block through the peering connection
resource "aws_route" "vpc1_to_vpc2_route" {
  route_table_id            = aws_route_table.rt1.id
  destination_cidr_block    = aws_vpc.vpc2.cidr_block
  vpc_peering_connection_id = aws_vpc_peering_connection.vpc_peering.id # Reference the peering connection directly
  # Ensure the peering connection is active before adding the route
  depends_on = [aws_vpc_peering_connection.vpc_peering]
}

# Add a route in VPC2's route table to send traffic for VPC1's CIDR block through the peering connection
resource "aws_route" "vpc2_to_vpc1_route" {
  route_table_id            = aws_route_table.rt2.id
  destination_cidr_block    = aws_vpc.vpc1.cidr_block
  vpc_peering_connection_id = aws_vpc_peering_connection.vpc_peering.id # Reference the peering connection directly
   # Ensure the peering connection is active before adding the route
  depends_on = [aws_vpc_peering_connection.vpc_peering]
}


# Output the public IPs of the instances
output "vm1_public_ip" {
  description = "Public IP address of VM 1"
  value       = aws_instance.vm1.public_ip
}

output "vm2_public_ip" {
  description = "Public IP address of VM 2"
  value       = aws_instance.vm2.public_ip
}

# Output the private IPs of the instances (for pinging within VPCs)
output "vm1_private_ip" {
  description = "Private IP address of VM 1"
  value       = aws_instance.vm1.private_ip
}

output "vm2_private_ip" {
  description = "Private IP address of VM 2"
  value       = aws_instance.vm2.private_ip
}