DEV Community

Engr.Hamza
Engr.Hamza

Posted on

From Zero to Continuous Delivery: Automating AWS EC2 and Infrastructure with Terraform

Cover Image

From Zero to Continuous Delivery: Automating AWS EC2 and Infrastructure with Terraform

Architecture Overview

Above: High-level architecture overview of the topic covered in this article.

How many times have you clicked around the AWS Console at 2 AM, desperately trying to remember which security group is attached to that failing production instance? Manual infrastructure management feels fast on day one, but by day one hundred, it is a ticking time bomb of drifted configurations and undocumented changes.


The Problem Everyone Ignores

Most engineering teams start their cloud journey the same way: a few hastily written bash scripts, some manual clicks in the AWS Management Console, and a prayer that nobody deletes the production database. It works fine when you have two instances and a single developer. But the moment your team scales past five people, manual infrastructure becomes an absolute bottleneck.

Infrastructure drift creeps in silently. Developer A tweaks a security group rule to test a hotfix, forgets to document it, and three weeks later, a routine deployment wipes out the change, taking down user authentication. You spend three hours in a war room comparing screenshot logs trying to figure out why staging looks nothing like production.

The traditional ticket-based provisioning model is equally painful. A developer submits a Jira ticket for a new EC2 instance, an infrastructure engineer spends two days manually spinning it up, and by the time it lands in the developer's hands, the sprint is already over. You are left choosing between velocity and stability, constantly paying a heavy tax in operational overhead and deployment anxiety.

Continuous delivery cannot exist in a world where infrastructure deployment requires human intervention and manual validation. If your code pipeline is fully automated but your servers are pets rather than cattle, your deployment pipeline will inevitably bottleneck at the infrastructure layer. We need a way to treat infrastructure with the exact same rigor, version control, and automated testing as our application code.


What Actually Works

To break free from manual infrastructure hell, we need to shift our mindset entirely toward Infrastructure as Code, or IaC. Instead of clicking buttons in a web browser, we define our desired state in declarative configuration files that live right alongside our application source code. When we want to change something, we update the code, run a preview, and apply it through a standardized pipeline.

Terraform has become the industry standard for this exact workflow because of its provider-driven architecture and robust state management. Unlike imperative scripting tools that execute a linear sequence of steps, Terraform looks at your desired state, compares it against your actual cloud environment via a state file, and calculates an exact execution plan to bridge the gap.

By pairing Terraform with a continuous delivery pipeline, you remove human error from the equation entirely. Every infrastructure change goes through pull requests, automated linting, security scanning via tools like tfsec, and peer reviews before it ever touches a live environment. Let's look at how we define a foundational AWS provider and a secure networking layer using modern Terraform syntax.

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket         = "my-company-terraform-state-prod"
    key            = "ec2-pipeline/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region

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

This configuration establishes our core provider constraints and sets up a secure, remote S3 state backend backed by DynamoDB for state locking. State locking is non-negotiable in a team environment; it prevents two engineers or CI/CD runners from applying conflicting changes simultaneously and corrupting the state file.


Step-by-Step: Let's Build It Together

Now that our foundation is set, let's build out a production-grade EC2 deployment architecture step by step. We will provision a dedicated Virtual Private Cloud, configure security boundaries, and launch an EC2 instance running a bootstrap script via user data.

First, we need to create our networking layer to ensure our instances sit safely inside isolated subnets with proper internet gateways and routing tables.

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.environment}-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "${var.aws_region}a"

  tags = {
    Name = "${var.environment}-public-subnet"
  }
}

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.environment}-igw"
  }
}
Enter fullscreen mode Exit fullscreen mode

This snippet provisions a clean VPC with DNS support enabled and a public subnet attached to an internet gateway, giving our future workloads the network pathways they need.

Next, we define our security group and compute resources, ensuring we lock down inbound traffic while allowing our application to receive HTTP and SSH traffic safely.

resource "aws_security_group" "web" {
  name        = "${var.environment}-web-sg"
  description = "Allow inbound HTTP and SSH traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "Allow HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow SSH from Corporate VPN"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["203.0.113.50/32"]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.public.id

  vpc_security_group_ids      = [aws_security_group.web.id]
  associate_public_ip_address = true

  user_data = <<-EOF
              #!/bin/bash
              apt-get update -y
              apt-get install -y nginx
              systemctl start nginx
              systemctl enable nginx
              EOF

  tags = {
    Name = "${var.environment}-web-node"
  }
}
Enter fullscreen mode Exit fullscreen mode

This code creates a tightly scoped security group that restricts SSH access to a trusted corporate IP while opening HTTP traffic to the world, and launches an Ubuntu EC2 instance running Nginx via automated user data.

Finally, we need to wire this all into a GitHub Actions continuous delivery pipeline so that pull requests automatically run validation checks, and merges to main apply infrastructure updates seamlessly.

name: Continuous Infrastructure Delivery

on:
  push:
    branches: [ main ]
    paths:
      - 'terraform/**'
  pull_request:
    branches: [ main ]
    paths:
      - 'terraform/**'

jobs:
  terraform:
    name: "Terraform Validate and Deploy"
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./terraform

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.5.7

      - name: Terraform Init
        run: terraform init

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -no-color

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

This workflow automates the entire lifecycle of your infrastructure, running validation checks on every pull request and safely applying changes to production the moment code lands on the main branch.


The Mistakes That Will Burn You

Even with robust tools like Terraform and GitHub Actions, rushing into continuous delivery for cloud infrastructure without guardrails is a recipe for disaster. I have personally watched teams burn production environments to the ground because they skipped basic safety checks.

  • Mistake 1: Storing your Terraform state file locally on your machine or inside your git repository. This inevitably leads to concurrent modification conflicts, broken state locks, and exposed cloud secrets leaking onto public GitHub repositories.
  • Mistake 2: Running terraform apply directly from a developer's local laptop against production environments without review. This bypasses team visibility, peer review gates, and automated security policy validation entirely.
  • Mistake 3: Hardcoding AMI IDs, VPC identifiers, and environment secrets directly into your resource blocks. When those underlying AMIs are deprecated or you need to spin up a staging environment, your code instantly breaks and requires manual refactoring.

Production Checklist

Before you push your shiny new infrastructure pipeline to production, run through this final checklist to ensure your setup is resilient, secure, and ready for enterprise scale.

  • Enable State Encryption: Ensure your S3 backend bucket has server-side encryption enabled and public access completely blocked.
  • Implement Plan Reviews: Require mandatory pull request reviews and status checks that display terraform plan output before any merge is allowed.
  • Never do this: Never use -auto-approve in pull request check workflows where human eyes haven't verified the exact resource diff being executed.
  • Lock Down IAM Permissions: Restrict your CI/CD runner AWS credentials to the absolute minimum required IAM permissions needed for provisioning your specific resources.
  • Use Dynamic Data Sources: Always fetch AMI IDs dynamically using AWS SSM parameter store or Terraform data sources rather than static IDs.

Key Takeaways

  • Manual infrastructure management creates drift, reduces deployment velocity, and invites human error into critical systems.
  • Treating infrastructure as code using Terraform allows you to apply software engineering best practices to cloud environments.
  • Remote state management with S3 and DynamoDB is mandatory for secure team collaboration and preventing state corruption.
  • Automating your Terraform execution via CI/CD pipelines ensures every change is previewed, tested, and reviewed before hitting production.

Engr. Hamza | AI & MLOps Engineer | Building autonomous systems at the edge of possibility

Top comments (0)