DEV Community

Cover image for From CI to CD on AWS: Deploying to EC2 and Provisioning Infrastructure with Terraform
anitaalicloud
anitaalicloud

Posted on

From CI to CD on AWS: Deploying to EC2 and Provisioning Infrastructure with Terraform

https://dev.to/anitaalicloud/building-a-devsecops-pipeline-with-jenkins-sonarcloud-trivy-docker-hub-what-i-learned-the-hard-jo6

My last article covered the CI side of a DevSecOps pipeline — checkout, tests, SonarCloud quality gates, Trivy security scans, and pushing a Docker image to Docker Hub. That's only half the story. A pipeline that builds and scans an image but never actually runs it anywhere isn't complete.

This article covers the two pieces that finish the job: a CD (Continuous Deployment) stage that actually runs the container, and a Terraform configuration that provisions the AWS infrastructure as code instead of clicking through the console by hand.

Part 1: Adding CD — deploying to EC2

The existing pipeline stopped right after Push to Docker Hub. I added one more stage:

stage('Deploy') {
    steps {
        echo "Deploying the new container..."
        sh """
            docker rm -f devsecops-pipeline-app || true
            docker run -d \
              --name devsecops-pipeline-app \
              --restart unless-stopped \
              -p 3000:3000 \
              ${DOCKERHUB_NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}
        """
    }
}
Enter fullscreen mode Exit fullscreen mode

What it does, in order:

  1. Removes any previously running version of the container (|| true so it doesn't fail if this is the first-ever deploy)
  2. Pulls up the image that was just built, scanned, and pushed, and runs it as a live container
  3. --restart unless-stopped means if the EC2 instance ever reboots, the container comes back up automatically without manual intervention

A deliberate design choice worth explaining: this deploys onto the same EC2 instance that Jenkins itself runs on, not a separate dedicated server. For a learning project, this keeps things simple — one instance, one thing to manage. In a real production setup, you'd typically want your CI/CD orchestrator separate from your actual running application (so a busy build doesn't compete for resources with live traffic, among other reasons) — which is exactly the gap the Terraform side below starts to close.

One infrastructure change this required: opening port 3000 in the EC2 security group, so the running app is actually reachable from outside.

Part 2: Terraform — provisioning infrastructure as code

Separately, I built a Terraform configuration provisioning two AWS resources from scratch:

  1. An EC2 instance (Ubuntu 22.04) — with the AMI resolved automatically via a data source, so it always uses the latest official Canonical image rather than a hardcoded, eventually-stale AMI ID
  2. An S3 bucket — with public access blocked by default
data "aws_ami" "ubuntu_22" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "app_server" {
  ami                    = data.aws_ami.ubuntu_22.id
  instance_type          = var.instance_type
  key_name               = var.key_pair_name
  vpc_security_group_ids = [aws_security_group.instance_sg.id]
}
Enter fullscreen mode Exit fullscreen mode

What went wrong (the useful part)

terraform init failed on a mobile hotspot. A generic network read error that had nothing to do with my Terraform code — just an unstable connection interrupting the provider plugin download. A simple retry fixed it.

Wrong key pair reference. I put vockeyhng.pem (the local private key file name) into the key_pair_name variable. AWS registers key pairs by name only, no file extension:

key_pair_name = "vockeyhng"   # not "vockeyhng.pem"
Enter fullscreen mode Exit fullscreen mode

t3.nano isn't Free Tier eligible. The spec called for t3.nano, but AWS rejected it:

Error: InvalidParameterCombination: The specified instance type is not eligible for Free Tier.
Enter fullscreen mode Exit fullscreen mode

I checked AWS's actual documentation rather than assuming — confirmed this isn't account-specific, the Free Tier has only ever covered .micro sizes, never .nano. Swapped to t3.micro and stayed within free tier.

Where CI/CD and Terraform meet — and where they don't, yet

Right now, these are two separate, independently-working pieces:

  • The Jenkins pipeline deploys its container onto the same EC2 instance Jenkins runs on
  • The Terraform configuration provisions a completely separate EC2 instance and S3 bucket — currently just sitting there, provisioned but not yet a deployment target for anything

The natural next step — properly closing the loop — is having Jenkins deploy onto the Terraform-provisioned instance instead of onto itself, via SSH. That means: installing Docker on the Terraform instance (via a user_data boot script), opening the right ports in its security group, adding the SSH private key as a Jenkins credential, and changing the Deploy stage from a local docker run to a remote one over SSH.

That's the next piece I'm working on — a genuinely separated build server and deployment target, provisioned entirely as code.


Pipeline repo: github.com/AnitaAliCloud/devsecops-pipeline
Terraform repo: github.com/AnitaAliCloud/aws-terraform-infrastructure

Top comments (1)

Collapse
 
raknaos profile image
Raknaos •

The t3.nano detail is the one I'd have gotten wrong too — the Free Tier page reads like 'nano is the smallest, so it must be free', and the rejection is a parameter validation error rather than a billing warning, so nothing about the message points at tier eligibility.

The honest admission that Jenkins is deploying onto itself is more useful than most CI/CD write-ups, because it names the actual reason: one instance, one thing to manage. When you do close the loop and have Jenkins SSH onto the Terraform box, the thing I'd budget for is key rotation — a private key sitting in Jenkins credentials goes stale the moment terraform apply replaces the instance and its host key. How are you planning to pass the SSH key in: a Jenkins secret file, or user_data with SSM?