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}
"""
}
}
What it does, in order:
- Removes any previously running version of the container (
|| trueso it doesn't fail if this is the first-ever deploy) - Pulls up the image that was just built, scanned, and pushed, and runs it as a live container
-
--restart unless-stoppedmeans 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:
- 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
- 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]
}
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"
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.
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)
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 applyreplaces 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?