DEV Community

Cover image for Terraform: A Guide to Deploying
Israel
Israel

Posted on Edited on

Terraform: A Guide to Deploying

In the previous article we manually built a production VPC, subnets, security groups, NAT Gateway, Application Load Balancer, and EC2 instance through the AWS Console. We deployed our Dockerized Smash Apartment application and made it accessible to via the ALB's DNS.

It worked perfectly. But it took a lot of time, was tedious and error prone, and tearing it down involved a tedious 12-step manual process that easily failed if you got the dependency order wrong.

Enter Terraform, it's the perfect solution to our problem, we can scaffold the entire architecture, plan to see changes to our architecture and destroy it easily.

In this article, we are going to rebuild the exact same infrastructure from Part 1, but this time we'll use Infrastructure as Code (IaC). The entire architecture will live in a few code files. One command to create it. One command to destroy it.

Table of Contents


Introduction: Why Terraform?

Terraform is an open-source tool that lets you define cloud infrastructure using a declarative configuration language (HCL). Instead of clicking through the AWS Console, you write code that says "I want a VPC with this CIDR block."

Why does this beat the manual console method?

  1. Repeatable: The same configuration produces the exact same infrastructure every time. Need a staging environment? Just copy the folder, change the environment variable, and deploy.
  2. Version Controlled: Infrastructure changes go through git, pull requests, and code reviews, just like application code.
  3. Self-Documenting: The .tf files act as the ultimate source of truth. No more guessing which security group allows what traffic.
  4. Automated Dependency Management: Terraform knows the NAT Gateway needs the Elastic IP, and it knows the subnets need the VPC. It creates them in the exact right order.

Pre-requisites

Before we begin, ensure you have the following ready:

  1. AWS CLI configured: Configured with Administrator access.
  2. Terraform installed: Download and install Terraform (v1.5+).
  3. EC2 Key Pair: A key pair created in AWS (e.g., apartment-key), same as Part 1.
  4. The Application Code:
   git clone https://github.com/Israel-dot-com/apartment-deployment-main
Enter fullscreen mode Exit fullscreen mode

Step 1: Project Structure and Foundation

Let's look at the structure of our Terraform configuration. Splitting the configuration by concern makes it much easier to maintain.

terraform/
├── main.tf                  # AWS Provider config and default tags
├── variables.tf             # All configurable inputs
├── vpc.tf                   # VPC, subnets, gateways, route tables
├── security_groups.tf       # Security groups for ALB and EC2
├── alb.tf                   # Load balancer, target group, listeners
├── ec2.tf                   # EC2 instance, AMI lookup, IAM role
├── user_data.sh             # Bash script that installs Docker on boot
├── outputs.tf               # Prints the ALB URL and SSH commands after deploy
└── terraform.tfvars.example # Example variable values
Enter fullscreen mode Exit fullscreen mode

1.1 Provider Configuration (main.tf)

First, we tell Terraform we are working with AWS and define our default tags.

# main.tf
terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why default tags? The default_tags block automatically applies these tags to every resource Terraform creates. This is incredibly useful for billing and resource tracking.

1.2 Input Variables (variables.tf)

Next, we define the inputs. These are the exact same CIDR blocks and values we typed into the console in Part 1.

# variables.tf (abridged for length)
variable "aws_region" {
  description = "AWS region to deploy into"
  default     = "us-east-1"
}

variable "project_name" {
  default     = "apartment"
}

variable "vpc_cidr" {
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
  default     = ["10.0.11.0/24", "10.0.12.0/24"]
}

variable "availability_zones" {
  default     = ["us-east-1a", "us-east-1b"]
}

variable "key_pair_name" {
  description = "Name of an existing EC2 key pair for SSH access"
  type        = string
}

variable "domain_name" {
  description = "Domain name for the ALB HTTPS listener (leave empty to skip HTTPS)"
  default     = ""
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Networking — VPC and Subnets

Remember the 10+ manual console steps from Part 1? Creating the VPC, enabling DNS hostnames, creating four subnets, allocating an EIP, creating the NAT Gateway, and setting up route tables? Here is all of that, codified.

2.1 Defining the Network (vpc.tf)

# vpc.tf
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = { Name = "${var.project_name}-vpc" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project_name}-igw" }
}

# Creates 2 Public Subnets dynamically using count
resource "aws_subnet" "public" {
  count                   = length(var.public_subnet_cidrs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true
  tags = { Name = "${var.project_name}-public-${count.index + 1}" }
}

# Creates 2 Private Subnets dynamically using count
resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
  tags = { Name = "${var.project_name}-private-${count.index + 1}" }
}

resource "aws_eip" "nat" {
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id
  depends_on    = [aws_internet_gateway.main]
}
Enter fullscreen mode Exit fullscreen mode

Notice the depends_on block in the NAT Gateway. This tells Terraform it must wait for the Internet Gateway to exist before creating the NAT Gateway. Terraform maps out a dependency graph for everything automatically, but occasionally explicit dependencies are helpful.


Step 3: Security Groups

In Part 1, we made sure the EC2 instance only accepted traffic from the ALB. Let's replicate that.

3.1 Firewall Rules (security_groups.tf)

# security_groups.tf
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-sg-alb"
  description = "Allow HTTP/HTTPS inbound to ALB"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_security_group" "ec2" {
  name        = "${var.project_name}-sg-ec2"
  description = "Allow traffic from ALB and SSH"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "ec2_from_alb" {
  security_group_id            = aws_security_group.ec2.id
  from_port                    = 80
  to_port                      = 80
  ip_protocol                  = "tcp"
  referenced_security_group_id = aws_security_group.alb.id
}
Enter fullscreen mode Exit fullscreen mode

Look at referenced_security_group_id = aws_security_group.alb.id. This is the exact Terraform equivalent of selecting the ALB security group as the source in the AWS Console.


Step 4: Application Load Balancer

Our ALB configuration includes a powerful feature: conditional HTTPS.

4.1 ALB Configuration (alb.tf)

# alb.tf
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id
}

resource "aws_lb_target_group" "app" {
  name     = "${var.project_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path    = "/"
    matcher = "200-399"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = var.domain_name != "" ? "redirect" : "forward"

    # Forward to EC2 if no domain is provided
    dynamic "forward" {
      for_each = var.domain_name == "" ? [1] : []
      content {
        target_group {
          arn = aws_lb_target_group.app.arn
        }
      }
    }

    # Redirect to HTTPS if domain is provided
    dynamic "redirect" {
      for_each = var.domain_name != "" ? [1] : []
      content {
        port        = "443"
        protocol    = "HTTPS"
        status_code = "HTTP_301"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you leave domain_name = "" in your variables, it provisions a standard HTTP load balancer. Once you are ready for production, you just update that variable to "yourdomain.com". Run terraform apply again, and Terraform will dynamically generate an ACM certificate and update the ALB to redirect HTTP to HTTPS.


Step 5: EC2 Instance and Bootstrap

In Part 1, we manually SSH'd into the instance to install Docker and Git. Let's automate that using an EC2 user_data script.

5.1 User Data Script (user_data.sh)

This script runs automatically the very first time the EC2 instance boots up.

#!/bin/bash
set -euo pipefail
exec > /var/log/user-data.log 2>&1

apt-get update -y && apt-get upgrade -y

# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker ubuntu

# Install Git
apt-get install -y git

# Clone application repository
sudo -u ubuntu git clone "${app_repo_url}" /home/ubuntu/apartment-deployment
Enter fullscreen mode Exit fullscreen mode

5.2 EC2 Configuration (ec2.tf)

# ec2.tf
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }
}

resource "aws_instance" "app" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  key_name               = var.key_pair_name
  subnet_id              = aws_subnet.private[0].id
  vpc_security_group_ids = [aws_security_group.ec2.id]

  user_data = base64encode(templatefile("${path.module}/user_data.sh", {
    app_repo_url = var.app_repo_url
  }))

  tags = { Name = "${var.project_name}-server" }
}

# Instance Connect Endpoint for secure, bastion-less SSH
resource "aws_ec2_instance_connect_endpoint" "main" {
  subnet_id          = aws_subnet.private[0].id
  security_group_ids = [aws_security_group.ec2.id]
}
Enter fullscreen mode Exit fullscreen mode

The data "aws_ami" block dynamically fetches the latest Ubuntu 24.04 AMI ID. You no longer have to hardcode AMI IDs that break when you switch AWS regions.


Step 6: Outputs and Deployment

6.1 Outputs Configuration (outputs.tf)

Outputs print useful information to your terminal after the deployment finishes.

# outputs.tf
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

output "ssh_command" {
  description = "Command to SSH into the EC2 instance"
  value       = "aws ec2-instance-connect ssh --instance-id ${aws_instance.app.id} --os-user ubuntu"
}
Enter fullscreen mode Exit fullscreen mode

6.2 Variable Assignment (terraform.tfvars)

Copy the example file to .tfvars (which is ignored by Git, keeping your secrets safe):

cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars
Enter fullscreen mode Exit fullscreen mode

Update it with your values:

aws_region    = "us-east-1"
key_pair_name = "apartment-key"
app_repo_url  = "https://github.com/your-repo/apartment-deployment.git"
# domain_name = "yourdomain.com"
Enter fullscreen mode Exit fullscreen mode

6.3 Deploying the Infrastructure

Initialize Terraform. This downloads the necessary AWS provider plugins.

terraform init
Enter fullscreen mode Exit fullscreen mode

Run a plan to see exactly what Terraform is going to create.

terraform plan
Enter fullscreen mode Exit fullscreen mode

Plan Terraform

You'll see a list of 31 resources that Terraform plans to create (VPC, Subnets, ALB, EC2, etc.).

Finally, apply the configuration:

terraform apply
Enter fullscreen mode Exit fullscreen mode

Type yes when prompted.

Terraform will create everything in the right order. It automatically waits the 2 minutes required for the NAT Gateway to provision before attaching routes to it.

When it finishes, it will print your outputs:

Outputs:

alb_dns_name = "apartment-alb-xxxxxxxxxx.us-east-1.elb.amazonaws.com"
ssh_command = "aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --os-user ubuntu"
Enter fullscreen mode Exit fullscreen mode

Copy that SSH command, connect to your server, configure your .env files (just like we did in Part 1), and run docker compose up -d --build.

Everything we spent an entire article building manually was just created in about 2 minutes.

Apply Terraform


Step 7: The Cleanup — One Command

In Part 1, tearing down this infrastructure required manually deleting 12 different resources in exactly the right order, waiting for timeouts, and dealing with DependencyViolation errors.

Here is the Terraform version:

terraform destroy
Enter fullscreen mode Exit fullscreen mode

Type yes.

Terraform looks at its state file, maps the reverse dependencies, and destroys everything flawlessly. It detaches the IGW before deleting the VPC. It waits for the NAT Gateway to delete before releasing the EIP.

Terraform destroy


Conclusion & Next Steps

This setup gets you a very solid production foundation. But cloud architecture never stops evolving. Our next steps for this platform would include:

Auto-Scaling (ASG): Moving from a single EC2 instance to an Auto Scaling Group behind the ALB to handle traffic spikes.

CI/CD Pipeline: Using GitHub Actions to automatically build the Docker images and trigger rolling updates on EC2 via AWS CodeDeploy.

If you found this helpful, drop a like! You can find the full source code (including all the Terraform files) on the terraform directory of Github.

Top comments (0)