Keyless CI/CD with GitHub OIDC + ECS
Deploy a containerized app to AWS with zero stored credentials using OIDC.
Introduction
30 Second Summary
Every time a CI/CD pipeline stores a cloud credential, it becomes a target. One leaked access key can open an entire AWS account to attackers. The industry has moved to a better model where pipelines prove their identity without secrets ever touching disk.
In this project, you will build a fully automated pipeline that deploys a containerized Python API to AWS. The pipeline authenticates using OIDC (zero stored credentials), scans the container image for vulnerabilities, and deploys to ECS Fargate behind a load balancer.
What You'll Build
When you push code to your repository, the pipeline automatically builds a Docker image, runs Trivy to scan for security issues, and deploys the result to a live public URL. If the scan finds HIGH or CRITICAL vulnerabilities, the pipeline stops dead and refuses to deploy.
By the end of this project, you'll have:
- A keyless CI/CD pipeline where you push a code change and watch it flow through build, scan, and deploy automatically with zero stored AWS credentials.
- A live public endpoint where you hit a URL and see your API responding from ECS Fargate, provisioned entirely by Terraform.
- A security gate where you introduce a vulnerable dependency and watch the pipeline block the deployment before it reaches production.
- Secret Mission: Add environment promotion with a manual approval gate so changes flow from dev to prod just like a real team.
Are there any prerequisites?
This is an advanced project. You need an AWS account, a GitHub account, and Docker, Terraform, AWS CLI, and Git installed on your machine. Familiarity with Docker, Terraform, and CI/CD concepts is expected.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Repository and AWS OIDC Trust
Traditional CI/CD pipelines store AWS access keys as repository secrets. If those keys leak, an attacker gets persistent access to your cloud account. OIDC authentication eliminates this risk by letting GitHub Actions request short-lived credentials on every pipeline run.
In this step, you will create your repository, configure AWS to trust GitHub Actions as an identity provider, and set up the remote backend for Terraform state storage.
In this step, get ready to:
- Create a GitHub repository with the project directory structure.
- Configure AWS to trust GitHub Actions via OIDC and create an IAM role.
- Set up an S3 bucket and DynamoDB table for Terraform state.
Create the GitHub repository and project structure
- Go to github.com/new in your browser.
- Enter devops-pipeline as the repository name.
- Leave the repository set to Public and click Create repository.
GitHub displays a quick setup page with the clone URL for your new repository.
- Open your terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone the repository and create the project directories by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline.git
cd devops-pipeline
mkdir -p app terraform .github/workflows
- Confirm the directory structure was created by running:
ls -la
You should see app, terraform, and .github listed in the output.
Don't see all three directories?
Make sure you ran the mkdir -p command from inside the devops-pipeline folder. If you see a "fatal: repository not found" error during clone, double-check the repository URL matches the one shown on your GitHub repo page.
help me debug my git clone or directory creation
Configure the AWS IAM OIDC Identity Provider
AWS needs to know that tokens from GitHub Actions are trustworthy. You establish this trust by registering GitHub's OIDC endpoint as an identity provider in IAM.
- Open the IAM console at https://console.aws.amazon.com/iam/.
- In the left navigation pane, click Identity providers.
- Click Add provider.
- For Provider type, select OpenID Connect.
- For Provider URL, enter https://token.actions.githubusercontent.com.
- Click Get thumbprint to let AWS retrieve the certificate automatically.
- For Audience, enter sts.amazonaws.com.
- Click Add provider.
You should see a green success banner confirming the provider was added.
What is an OIDC Identity Provider?
An IAM OIDC provider tells AWS to trust tokens issued by a specific external service. When GitHub Actions runs your workflow, it mints a short-lived JWT signed by token.actions.githubusercontent.com. Because you registered that URL as a trusted provider, AWS accepts the token and issues temporary credentials instead of requiring static access keys.
The audience sts.amazonaws.com tells AWS that these tokens are intended for assuming AWS roles via the Security Token Service.
Now you will create the IAM role that GitHub Actions assumes during pipeline runs. This role needs a trust policy restricting access to your specific repository.
- In your terminal, create a trust policy file by running this command:
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[[GITHUB_USERNAME="your-github-username"]]@*/[[GITHUB_REPO="devops-pipeline"]]@*:*"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}
EOF
Why does the sub pattern use @* wildcards?
Repositories created after July 15, 2026 use immutable subject claims. The OIDC token's sub claim includes numeric IDs appended with @ (for example, repo:myuser@123456/myrepo@789012:ref:refs/heads/main). The @* wildcard matches these IDs while still restricting trust to your specific username and repository name.
AWS requires the token.actions.githubusercontent.com:sub condition for any role trusting GitHub Actions. Without it, the role creation will fail.
- Create the IAM role using the trust policy by running:
aws iam create-role \
--role-name github-actions-role \
--assume-role-policy-document file://trust-policy.json
You should see JSON output containing the role's ARN, for example arn:aws:iam::123456789012:role/github-actions-role.
- Create the permissions policy file by running:
cat > permissions-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:*",
"ecs:*",
"ec2:*",
"elasticloadbalancing:*",
"logs:*",
"iam:PassRole",
"s3:*",
"dynamodb:*"
],
"Resource": "*"
}
]
}
EOF
- Attach the permissions policy to the role by running:
aws iam put-role-policy \
--role-name github-actions-role \
--policy-name github-actions-policy \
--policy-document file://permissions-policy.json
- Confirm the role exists with the correct ARN by running:
aws iam get-role --role-name github-actions-role --query 'Role.Arn' --output text
You should see your role ARN printed, confirming the role is ready for GitHub Actions to assume.
What do these permissions cover?
- ecr:* allows pushing Docker images to Elastic Container Registry.
- ecs:* allows deploying and managing ECS services.
- ec2:* and elasticloadbalancing:* let Terraform create VPCs, subnets, and load balancers.
- iam:PassRole allows ECS tasks to use their execution role.
- s3:* and dynamodb:* let Terraform read and write its remote state.
Seeing a MalformedPolicyDocument error?
AWS enforces that any role trusting GitHub Actions OIDC must include the token.actions.githubusercontent.com:sub condition. Double-check that your trust-policy.json includes both the StringLike block for sub and the StringEquals block for aud.
help me fix my OIDC trust policy for GitHub Actions
Create the Terraform state backend
Terraform needs a remote backend to store its state file. This prevents conflicts when the pipeline runs and keeps state durable across machines.
- Create an S3 bucket for Terraform state by running:
aws s3 mb s3://[[TFSTATE_BUCKET="your-unique-terraform-state-bucket-name"]] --region us-east-1
You should see make_bucket: followed by your bucket name, confirming creation.
Why does the bucket name need to be unique?
S3 bucket names are globally unique across all AWS accounts. Choose a name that includes your username or a random suffix (for example, devops-tfstate-jsmith-2026). Remember this name because you will reference it in your Terraform backend configuration later.
- Create a DynamoDB table for state locking by running:
aws dynamodb create-table \
--table-name terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
You should see JSON output with TableStatus showing CREATING or ACTIVE.
What is state locking?
When Terraform runs, it acquires a lock in this DynamoDB table so that two processes cannot modify the same infrastructure at the same time. The partition key LockID is what Terraform uses to identify and claim the lock.
- Verify both resources exist by running:
aws s3 ls | grep [[TFSTATE_BUCKET="your-unique-terraform-state-bucket-name"]]
aws dynamodb list-tables --region us-east-1
You should see your bucket name in the first command's output and terraform-locks listed in the DynamoDB tables output.
Bucket creation failed?
If you see a "BucketAlreadyExists" error, the name is taken globally. Try a different name with a unique suffix. If the DynamoDB command fails with an access denied error, confirm your AWS CLI is configured with credentials that have DynamoDB permissions.
help me troubleshoot my S3 or DynamoDB creation
Before you run the final check, predict what you expect to see. Will the output list zero providers, or will it include the GitHub OIDC endpoint you just registered?
- Verify the OIDC provider was registered by running:
aws iam list-open-id-connect-providers
You should see an ARN containing oidc-provider/token.actions.githubusercontent.com in the output. This confirms AWS trusts GitHub Actions as an identity provider.
- Clean up the temporary policy files by running:
rm trust-policy.json permissions-policy.json
Your trust foundation is in place. AWS now recognizes GitHub Actions as a legitimate identity provider, and the pipeline role is ready to assume. Next up, you will build the containerized application and Docker image that the pipeline will deploy.
Build the Application and Docker Image
Your pipeline's foundation is in place. AWS trusts GitHub Actions through OIDC, and your Terraform state backend is ready. Now you need something for that pipeline to actually deploy.
In this step, you'll build a Flask API and package it into a production-grade Docker image. The multi-stage build pattern keeps the final image small. Running as a non-root user is the security baseline that ECS Fargate expects.
In this step, get ready to:
- Create a Flask API with a health check endpoint.
- Build a multi-stage Docker image with a non-root user.
- Push the application code to GitHub.
Create the Flask API
The pipeline needs an application to deploy. You'll create a minimal Flask API with two endpoints: a health check for the load balancer and a root endpoint that returns service info.
- Create app/requirements.txt to pin your Python dependency by adding this content:
flask==3.1.1
- Create the Flask application in app/app.py by adding this code:
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "healthy"})
@app.route("/")
def index():
return jsonify({
"service": "devops-api",
"version": "1.0.0",
"message": "Deployed via keyless CI/CD pipeline"
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
- /health returns a simple JSON status. The ALB will use this endpoint later to know whether your container is alive.
- / returns service metadata including the version and a deployment message. This is the endpoint you'll hit to prove the full pipeline works.
- host="0.0.0.0" makes Flask listen on all network interfaces inside the container, not just localhost.
- Save both files.
- Confirm the files exist by running:
ls app/
You should see app.py and requirements.txt in the output.
Don't see both files?
- Make sure you created the files inside the app/ directory, not in the project root.
- Check for typos in the filename. The requirements file must be named exactly requirements.txt (not requirement.txt).
Need more help? Why aren't my files showing up in the app directory?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is what each file should contain:
flask==3.1.1
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "healthy"})
@app.route("/")
def index():
return jsonify({
"service": "devops-api",
"version": "1.0.0",
"message": "Deployed via keyless CI/CD pipeline"
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Create the multi-stage Dockerfile
A multi-stage Docker build separates the dependency installation from the final runtime image. Only the compiled dependencies and your application code end up in the production image, keeping it small and reducing the attack surface.
- Create a file called Dockerfile in your project root (not inside app/) by adding this code:
# Stage 1: install dependencies into a temporary location
FROM python:3.12-slim AS builder
WORKDIR /build
COPY app/requirements.txt .
RUN pip install --no-cache-dir --target=/build/deps -r requirements.txt
# Stage 2: production image with only what we need
FROM python:3.12-slim
RUN adduser --disabled-password --no-create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /build/deps /usr/local/lib/python3.12/site-packages
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
What does this Dockerfile do?
- The builder stage installs Python packages into /build/deps. This stage is thrown away after the build completes.
- The production stage starts fresh from python:3.12-slim and copies only the installed packages and your application code.
- adduser creates a non-root user (appuser, UID 10001). Running as non-root means that if someone exploits your app, they cannot escalate to system-level access inside the container.
- USER appuser switches the runtime user so the CMD runs without root privileges.
- Save the Dockerfile.
Before you build, what do you think will happen when Docker encounters the two FROM statements?
- Build the Docker image by running:
docker build -t devops-api .
You should see each stage execute in sequence. The output ends with a line confirming the image was tagged as devops-api:latest.
- Start the container by running:
docker run -p 5000:5000 devops-api
What is happening in your terminal?
This command runs in the foreground and takes over your terminal. You'll see Flask's startup output indicating it is serving on port 5000. Leave this running and open a second terminal window to test the endpoint.
- Open a new terminal window.
- Test the health endpoint by running:
curl http://localhost:5000/health
You should see {"status":"healthy"} in the response.
- Return to the terminal running the container and press Ctrl+C to stop it.
Container not starting or health check failing?
- If you see port is already allocated, another process is using port 5000. Stop it or use docker run -p 5001:5000 devops-api and curl port 5001 instead.
- If the build fails on COPY app/requirements.txt, make sure you're running docker build from the project root (where the Dockerfile lives), not from inside app/.
- If curl returns Connection refused, give the container a few seconds to start, then try again.
Still stuck? Help me debug my Docker build or container startup issue.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the Dockerfile and confirmed the health endpoint responds.
ⓧ I'd like to double check the full code
Your Dockerfile at the project root should contain:
FROM python:3.12-slim AS builder
WORKDIR /build
COPY app/requirements.txt .
RUN pip install --no-cache-dir --target=/build/deps -r requirements.txt
FROM python:3.12-slim
RUN adduser --disabled-password --no-create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /build/deps /usr/local/lib/python3.12/site-packages
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
Push to GitHub
- Stage, commit, and push all your new files by running these commands:
git add .
git commit -m "Add Flask API and Dockerfile"
git push origin main
- Open your repository page on GitHub in your browser.
You should see the app/ directory containing app.py and requirements.txt, plus the Dockerfile at the project root.
Push rejected or authentication failed?
- If Git asks for credentials, make sure you have a personal access token or SSH key configured for GitHub.
- If the push is rejected because the remote has changes you don't have locally, run git pull --rebase origin main first, then push again.
Need help pushing? Help me fix my git push issue.
Your application is containerized and proven to work locally. Next up, you'll build the CI/CD pipeline that scans this image for vulnerabilities before it ever reaches production.
Add the Security Scanning Pipeline Gate
Your containerized app runs locally and you verified it responds on /health.
Deploying without scanning is the naive approach. A single vulnerable dependency can slip into production unnoticed. In this step, you create a GitHub Actions workflow that authenticates to AWS via OIDC, builds your Docker image, and runs Trivy as a security gate. If Trivy finds HIGH or CRITICAL vulnerabilities, the pipeline refuses to continue.
In this step, get ready to:
- Create a GitHub Actions workflow that authenticates to AWS using OIDC with zero stored credentials.
- Test the Trivy vulnerability gate by deliberately introducing a vulnerable dependency.
- Push your scanned image to Amazon ECR.
Create the GitHub Actions workflow
GitHub Actions workflows live in .github/workflows/. You already created this directory. Now you need a YAML file that defines what happens on every push to main.
- Create a new file at .github/workflows/deploy.yml in your code editor.
- Add the workflow header and authentication steps by pasting this code:
name: Build, Scan, and Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: devops-api
ECS_CLUSTER: devops-api-cluster
ECS_SERVICE: devops-api-service
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:role/github-actions-role
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
What does this code do?
This sets up the workflow's identity and authentication.
- permissions: id-token: write allows GitHub Actions to request an OIDC token. This is the key to keyless authentication.
- aws-actions/configure-aws-credentials@v6 exchanges the OIDC token for temporary AWS credentials. No access keys stored anywhere.
- aws-actions/amazon-ecr-login@v2 authenticates Docker to your ECR registry so you can push images later.
- Below the ECR login step (still inside the steps: list), add the build and scan steps:
- name: Build Docker image
run: |
docker build -t ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} .
docker tag ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
format: table
exit-code: "1"
severity: HIGH,CRITICAL
What does the Trivy step do?
Trivy scans the container image for known vulnerabilities in OS packages and application dependencies.
- exit-code: "1" means the step fails (and the pipeline stops) if any vulnerabilities are found at the specified severity.
- severity: HIGH,CRITICAL limits the gate to only the most dangerous vulnerabilities. LOW and MEDIUM findings are reported but do not block deployment.
This is the shift-left security pattern: catching problems early in the pipeline rather than discovering them in production.
- Save deploy.yml.
- Commit and push to GitHub by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD workflow with OIDC and Trivy scan"
git push origin main
- Open your GitHub repository in a browser.
- Click the Actions tab.
You should see a workflow run in progress for the "Build, Scan, and Deploy" workflow. Wait for it to complete with a green checkmark on the Trivy scan step.
Workflow failed at the OIDC step?
- Verify your IAM role's trust policy includes a Condition block matching token.actions.githubusercontent.com:sub to your repository's subject claim.
- Confirm the role-to-assume ARN in your workflow matches the exact role name github-actions-role and your 12-digit account ID.
- Check that you set permissions: id-token: write at the top level of the workflow file, not inside the job.
Still stuck? Why is my GitHub Actions OIDC authentication failing?
Test the vulnerability gate with a known-bad dependency
The scan passed because flask==3.1.1 has no known HIGH or CRITICAL vulnerabilities. To prove the gate actually works, you will intentionally introduce a vulnerable version.
- In app/requirements.txt, find this line:
flask==3.1.1
- Replace 3.1.1 with 1.0, so the line now looks like this:
flask==1.0
- Push the vulnerable version by running:
git add app/requirements.txt
git commit -m "Test: use vulnerable Flask version"
git push origin main
Before you check the Actions tab, predict: do you think the pipeline will pass or fail this time? Why?
- Switch back to the Actions tab in your GitHub repository.
- Click on the latest workflow run.
The pipeline fails at the "Run Trivy vulnerability scanner" step with a red X. Trivy detected HIGH or CRITICAL vulnerabilities in flask==1.0 and its outdated dependencies, and the exit-code: "1" setting killed the pipeline. This is exactly what you want. No vulnerable code gets past this gate.
Why intentionally break the pipeline?
A security gate you never see fire is a gate you cannot trust. By deliberately triggering it, you proved the pipeline genuinely blocks unsafe deployments rather than silently passing everything through.
- Now revert app/requirements.txt back to the safe version. Change flask==1.0 back to:
flask==3.1.1
- Push the fix by running:
git add app/requirements.txt
git commit -m "Revert to safe Flask version"
git push origin main
- Return to the Actions tab and confirm the workflow passes with a green checkmark on all steps.
Pipeline still failing after reverting?
- Open app/requirements.txt and confirm it says exactly flask==3.1.1 with no extra spaces or characters.
- Check that you committed and pushed the change. Run git status to confirm there are no uncommitted changes.
Need help? Why is my Trivy scan still failing after I reverted the Flask version?
Add the ECR push steps
The scan gate works. Now you need the pipeline to push the verified image to Amazon ECR so it can be deployed later.
- First, create the ECR repository so the pipeline has somewhere to push. Run this command:
aws ecr create-repository --repository-name devops-api --region us-east-1
You should see JSON output containing a repositoryUri that includes your account ID and devops-api.
Seeing RepositoryAlreadyExistsException?
This means the repository already exists from a previous attempt. That is fine. The pipeline will push to the existing repository.
- Open .github/workflows/deploy.yml in your editor.
- Below the Trivy scanner step, add the push step:
- name: Push to ECR
run: |
docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
What does this step do?
This pushes two tags of the same image to ECR. The github.sha tag gives you an immutable reference to the exact commit. The latest tag is a convenience pointer that always references the most recent build.
The push step appears AFTER the Trivy scan. If Trivy fails, the pipeline never reaches this step. Only vulnerability-free images make it into your registry.
- Save deploy.yml.
- Push the updated workflow by running:
git add .github/workflows/deploy.yml
git commit -m "Add ECR push step after Trivy scan"
git push origin main
Before you check the Actions tab, predict: will the new Push to ECR step succeed?
- Return to the Actions tab in GitHub and wait for the workflow to complete.
All steps should show green checkmarks, including the new "Push to ECR" step. Your image is now stored in Amazon ECR.
- Verify the image landed in ECR by running:
aws ecr describe-images --repository-name devops-api --region us-east-1
You should see at least one image entry with imageTags containing latest and a commit SHA.
Push to ECR step failed?
- Confirm you ran aws ecr create-repository --repository-name devops-api --region us-east-1 earlier and it succeeded.
- Check that your IAM role github-actions-role has permissions for ecr:PutImage and ecr:InitiateLayerUpload.
Still stuck? Why is my GitHub Actions pipeline failing to push to Amazon ECR?
✔️ Awesome, I've got everything!
Great. Double check you have saved .github/workflows/deploy.yml and that your latest pipeline run shows all green.
ⓧ I'd like to double check the full code
Your .github/workflows/deploy.yml should contain these sections in order. Scroll up and confirm each chunk is present:
- Workflow header: name, on, permissions, and env blocks.
- Job start: jobs: deploy: runs-on: ubuntu-latest.
- Steps: Checkout code, Configure AWS credentials via OIDC, Login to Amazon ECR.
- Steps: Build Docker image (with github.sha and latest tags), Run Trivy vulnerability scanner.
- Step: Push to ECR (pushes both tags).
Your app/requirements.txt should contain only:
flask==3.1.1
Your pipeline now builds, scans, and pushes container images automatically on every push to main. No vulnerable image can reach your registry. Next up, you will provision the infrastructure that actually runs these containers in production.
Provision Infrastructure with Terraform
Your app is built and scanned, but it has nowhere to run. Right now, the pipeline builds a Docker image and pushes it to ECR, but there is no compute infrastructure waiting to receive it.
In this step, you will use Terraform to provision the entire runtime environment as code. That means a VPC, an Application Load Balancer, an ECS Fargate cluster and service, and the networking that ties them together.
In this step, get ready to:
- Write Terraform configuration for VPC networking, ALB, ECS, and ECR.
- Initialize, validate, and plan the infrastructure.
- Apply the infrastructure and note the ALB DNS name.
Write the Terraform provider and networking configuration
Every Terraform project starts with a provider and backend configuration. The backend stores your state remotely in the S3 bucket and DynamoDB table you created in Step 1.
- Create the file terraform/main.tf and paste the following configuration:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.55"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "devops-pipeline/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
default = "us-east-1"
}
variable "app_name" {
default = "devops-api"
}
What does this configuration do?
The backend "s3" block tells Terraform to store its state file in your S3 bucket with DynamoDB locking. This means multiple team members (or your pipeline) can safely run Terraform without state corruption.
The two variables (aws_region and app_name) are reused across all other files to keep resource naming consistent.
- Save terraform/main.tf.
- Initialize Terraform by running this command from the terraform/ directory:
cd terraform
terraform init
You should see Terraform has been successfully initialized! along with a confirmation that the S3 backend is configured.
Seeing a backend configuration error?
- Confirm the S3 bucket name in main.tf matches the bucket you created in Step 1 exactly.
- Verify the DynamoDB table name is terraform-locks and that its partition key is LockID (case-sensitive).
- Check that your AWS CLI credentials have S3 and DynamoDB permissions.
Help me debug my terraform init error
Now you will build the VPC networking layer. This creates the virtual network where your ECS tasks will run, with public subnets for the load balancer and private subnets for the containers.
- Create the file terraform/vpc.tf and paste the VPC, subnet, and availability zone configuration:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.app_name}-vpc" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.app_name}-public-${count.index + 1}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.app_name}-private-${count.index + 1}" }
}
- Continue adding the internet gateway, NAT gateway, and Elastic IP to the same terraform/vpc.tf file below the private subnet resource:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.app_name}-igw" }
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = { Name = "${var.app_name}-nat-eip" }
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = { Name = "${var.app_name}-nat" }
depends_on = [aws_internet_gateway.main]
}
Why both an internet gateway and a NAT gateway?
The internet gateway gives your public subnets (where the ALB lives) direct internet access. The NAT gateway lets your private subnets (where ECS tasks run) pull container images from ECR without being directly exposed to the internet.
- Save terraform/vpc.tf, then validate the configuration so far:
terraform validate
You should see Success! The configuration is valid. confirming no syntax errors.
- Now add the route tables and their subnet associations to the bottom of terraform/vpc.tf:
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.app_name}-public-rt" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = { Name = "${var.app_name}-private-rt" }
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
What do the route tables do?
The public route table sends outbound traffic through the internet gateway. The private route table routes outbound traffic through the NAT gateway instead, keeping the ECS tasks unreachable from the public internet.
- Save terraform/vpc.tf and run terraform validate again to confirm the complete networking configuration is valid.
terraform validate
You should see Success! The configuration is valid. again.
✔️ Awesome, I've got everything!
Great. Confirm you have saved terraform/vpc.tf with all three sections: VPC/subnets, gateways, and route tables.
ⓧ I'd like to double check the full code
Scroll up and confirm your terraform/vpc.tf contains these three blocks in order:
- The data source, VPC resource, and public/private subnet resources.
- The internet gateway, Elastic IP, and NAT gateway resources.
- The public and private route tables plus their four association resources.
Define the application infrastructure
With the network in place, you will now add the resources that serve and run your application: the ALB, ECR repository, and ECS cluster.
- Create terraform/alb.tf and paste the security group and load balancer configuration:
resource "aws_security_group" "alb" {
name = "${var.app_name}-alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.app_name}-alb-sg" }
}
resource "aws_lb" "main" {
name = "${var.app_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
tags = { Name = "${var.app_name}-alb" }
}
- Add the target group and HTTP listener to the bottom of terraform/alb.tf:
resource "aws_lb_target_group" "app" {
name = "${var.app_name}-tg"
port = 5000
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 30
timeout = 5
}
tags = { Name = "${var.app_name}-tg" }
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
How does the health check work?
The target group uses target_type = "ip" because Fargate tasks register by IP address, not instance ID. The health check hits your Flask app's /health endpoint every 30 seconds. If it fails 3 consecutive checks, the ALB stops sending traffic to that task.
- Save terraform/alb.tf and validate:
terraform validate
You should see Success! The configuration is valid. confirming the ALB resources reference the VPC correctly.
✔️ Awesome, I've got everything!
Great. Confirm you have saved terraform/alb.tf with the security group, load balancer, target group, and listener.
ⓧ I'd like to double check the full code
Scroll up and confirm your terraform/alb.tf contains these two blocks in order:
- The ALB security group (port 80 ingress) and the aws_lb resource.
- The target group (IP type, port 5000, /health check) and the HTTP listener on port 80.
- Create terraform/ecr.tf for the container registry:
resource "aws_ecr_repository" "app" {
name = var.app_name
image_tag_mutability = "MUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
tags = { Name = "${var.app_name}-ecr" }
}
- Save terraform/ecr.tf.
- Create terraform/ecs.tf and paste the ECS cluster and its security group:
resource "aws_ecs_cluster" "main" {
name = "${var.app_name}-cluster"
tags = { Name = "${var.app_name}-cluster" }
}
resource "aws_security_group" "ecs" {
name = "${var.app_name}-ecs-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5000
to_port = 5000
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.app_name}-ecs-sg" }
}
Why does the ECS security group only allow port 5000 from the ALB?
The ingress rule restricts traffic to only come from the ALB security group on port 5000. This means your containers are unreachable from the public internet directly. All traffic must flow through the load balancer.
- Save terraform/ecs.tf and validate:
terraform validate
You should see Success! The configuration is valid. again.
- Add the IAM execution role and CloudWatch log group to terraform/ecs.tf below the security group:
resource "aws_iam_role" "ecs_execution" {
name = "${var.app_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.app_name}"
retention_in_days = 7
}
- Continue adding the Fargate task definition to terraform/ecs.tf:
resource "aws_ecs_task_definition" "app" {
family = var.app_name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([{
name = var.app_name
image = "${aws_ecr_repository.app.repository_url}:latest"
portMappings = [{
containerPort = 5000
protocol = "tcp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
}])
}
What does the task definition specify?
The task definition tells Fargate exactly how to run your container: 256 CPU units (0.25 vCPU), 512 MB memory, and the awsvpc network mode that Fargate requires. It references the ECR image URL so ECS knows where to pull the container from.
- Save terraform/ecs.tf and validate to catch any issues before adding the service:
terraform validate
You should see Success! The configuration is valid. confirming all resource references resolve correctly.
- Add the ECS service resource to the bottom of terraform/ecs.tf:
resource "aws_ecs_service" "app" {
name = "${var.app_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = var.app_name
container_port = 5000
}
depends_on = [aws_lb_listener.http]
}
Why does the service depend on the listener?
The depends_on = [aws_lb_listener.http] ensures the ALB listener exists before the service registers targets with it. Without this, Terraform might try to attach the service to a target group that is not yet connected to a listener, causing a deployment error.
- Save terraform/ecs.tf.
- Create the final file terraform/outputs.tf to expose the key values you will need:
output "alb_dns_name" {
value = aws_lb.main.dns_name
description = "DNS name of the Application Load Balancer"
}
output "ecr_repository_url" {
value = aws_ecr_repository.app.repository_url
description = "URL of the ECR repository"
}
output "ecs_cluster_name" {
value = aws_ecs_cluster.main.name
description = "Name of the ECS cluster"
}
output "ecs_service_name" {
value = aws_ecs_service.app.name
description = "Name of the ECS service"
}
What are these outputs for?
The alb_dns_name is the public URL where you will access your deployed app. The ecr_repository_url, ecs_cluster_name, and ecs_service_name are the identifiers your GitHub Actions workflow needs to push images and trigger deployments.
- Save terraform/outputs.tf and validate the complete configuration:
terraform validate
You should see Success! The configuration is valid. confirming all six files are syntactically correct and internally consistent.
Seeing a validation error?
- Check for missing closing braces } or brackets ] in your HCL files. Terraform's error message will point to the file and line number.
- Confirm that ecr.tf exists and defines aws_ecr_repository.app. The task definition in ecs.tf references this resource.
- Ensure all files are saved inside the terraform/ directory, not in the project root.
Help me fix my terraform validate error
✔️ Awesome, I've got everything!
Great. Confirm you have saved all six files: main.tf, vpc.tf, alb.tf, ecs.tf, ecr.tf, and outputs.tf.
ⓧ I'd like to double check the full code
Scroll up and confirm each file contains all its chunks in order:
- main.tf: terraform block (required_version, required_providers, backend), provider, two variables.
- vpc.tf: data source, VPC, 2 subnet resources, internet gateway, EIP, NAT gateway, 2 route tables, 4 route table associations.
- alb.tf: ALB security group, load balancer, target group, HTTP listener.
- ecr.tf: ECR repository with scan_on_push.
- ecs.tf: cluster, ECS security group, IAM role, policy attachment, log group, task definition, service.
- outputs.tf: alb_dns_name, ecr_repository_url, ecs_cluster_name, ecs_service_name.
Plan and apply the infrastructure
Before you think about what Terraform will create, take a moment to predict. Based on the six files you wrote, how many resources do you expect Terraform to create?
- Preview the infrastructure Terraform will create by running:
terraform plan
The plan output shows every resource Terraform will create, including the VPC, subnets, gateways, route tables, ALB, target group, ECS cluster, service, task definition, ECR repository, security groups, and IAM roles. You should see a summary line at the bottom reading something like Plan: X to add, 0 to change, 0 to destroy with no errors.
Why raw resources instead of modules?
Terraform modules abstract away the individual resources, which is great for teams moving fast. For learning, writing each resource explicitly shows you exactly what is being created and how the pieces connect. Employers reviewing portfolios want to see that you understand the underlying infrastructure, not just that you can call a module.
- Apply the infrastructure by running:
terraform apply -auto-approve
This takes 3-5 minutes
The NAT gateway and ALB take the longest to provision. Wait for the command to complete. You will see each resource being created in sequence.
When the apply finishes, Terraform prints your outputs. Look for the alb_dns_name value in the output.
- Copy the alb_dns_name value from the Terraform output and save it somewhere accessible. You will need it in the next step to verify your deployment.
What should I see?
The final output includes four values: the ALB DNS name (something like devops-api-alb-123456789.us-east-1.elb.amazonaws.com), the ECR repository URL, and the ECS cluster and service names. The ALB will not serve your app yet because no container image has been pushed to ECR.
Seeing errors during apply?
- If you see a permissions error, confirm that your AWS CLI credentials have VPC, ECS, ECR, ELB, and IAM permissions.
- If a resource already exists (name conflict), check for leftover resources from a prior attempt. Run terraform destroy -auto-approve and then terraform apply -auto-approve again.
- If the NAT gateway fails to create, confirm your account has available Elastic IPs (the default limit is 5 per region).
Help me debug my terraform apply error
Your entire runtime environment now exists as code. Next up, you will complete the pipeline so that a push to main automatically deploys your container to this infrastructure.
Complete the Pipeline and Deploy to ECS
In the previous step, you provisioned the entire runtime environment with Terraform. Your VPC, ALB, ECS cluster, and Fargate service all exist in AWS.
The pipeline currently stops after pushing the image to ECR. It never tells ECS to pull that new image and run it. This step closes the loop so a push to main flows all the way from code change to live deployment.
In this step, get ready to:
- Add deploy and wait steps to the GitHub Actions workflow.
- Push the complete workflow and verify the app is live behind the ALB.
- Make a code change and confirm the full pipeline cycle deploys it automatically.
Add the deploy steps to your workflow
Your workflow currently builds the image, scans it with Trivy, and pushes it to ECR. The final piece is telling ECS to pull the new image and launch it.
- Open .github/workflows/deploy.yml in your editor.
- Find the last step, which currently ends with the "Push to ECR" block.
- Add the following two steps at the bottom of the steps: list, directly after the "Push to ECR" step:
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster ${{ env.ECS_CLUSTER }} \
--service ${{ env.ECS_SERVICE }} \
--force-new-deployment
- name: Wait for deployment
run: |
aws ecs wait services-stable \
--cluster ${{ env.ECS_CLUSTER }} \
--services ${{ env.ECS_SERVICE }}
What do these steps do?
- aws ecs update-service --force-new-deployment tells ECS to stop the currently running task and launch a new one that pulls the :latest image from ECR. Since you just pushed a fresh image, the new task picks it up.
- aws ecs wait services-stable polls the service every 15 seconds until the new task is running and healthy. The pipeline only reports success after the deployment is confirmed stable.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your .github/workflows/deploy.yml should contain these steps in order under the jobs.deploy.steps key:
- Checkout code (actions/checkout@v4).
- Configure AWS credentials via OIDC (aws-actions/configure-aws-credentials@v6).
- Login to Amazon ECR (aws-actions/amazon-ecr-login@v2).
- Build Docker image.
- Run Trivy vulnerability scanner.
- Push to ECR.
- Deploy to ECS (aws ecs update-service --force-new-deployment).
- Wait for deployment (aws ecs wait services-stable).
Make sure the env: block at the top defines ECS_CLUSTER: devops-api-cluster and ECS_SERVICE: devops-api-service.
Push the complete workflow and verify end-to-end
This push is the moment the full pipeline runs for the first time. It will build, scan, push, and deploy your app to ECS in one automated sweep.
- Commit and push the updated workflow by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add deploy and wait steps to pipeline"
git push origin main
- Open your repository on GitHub.
- Click the Actions tab.
- Watch the workflow run through all steps: checkout, OIDC auth, ECR login, build, Trivy scan, ECR push, deploy, and wait.
How long does the deployment take?
The deploy and wait steps typically take 2-3 minutes. ECS needs to stop the old task, start a new one, pull the image from ECR, and pass the health check on /health before the service is declared stable.
Before you test the endpoint, what do you think curl will return when you hit the ALB URL?
- Once the workflow shows a green checkmark, test your live API by running:
curl http://[[ALB_DNS_NAME="your ALB DNS name from terraform output"]]/health
You should see:
{"status": "healthy"}
- Now test the root endpoint:
curl http://[[ALB_DNS_NAME="your ALB DNS name from terraform output"]]/
You should see:
{"service": "devops-api", "version": "1.0.0", "message": "Deployed via keyless CI/CD pipeline"}
Not getting a response from the ALB?
- Confirm the pipeline completed with a green check on all steps. If the "Wait for deployment" step is still running, give it another minute.
- Check that you are using http:// (not https://). The ALB listener is configured on port 80 only.
- If you see a 503, the ECS task may still be starting. Wait 30 seconds and try again.
help me troubleshoot why my ALB is not returning a response after ECS deployment
- Now confirm zero stored credentials. In your GitHub repository, go to Settings.
- In the left sidebar under Security, click Secrets and variables, then Actions.
- Verify there are no AWS access keys or secrets listed. The OIDC trust handles authentication entirely through the IAM role.
Why is this significant?
Most CI/CD credential leaks happen because long-lived access keys are stored in a repository's secrets. Your pipeline has zero stored credentials. GitHub Actions gets temporary session tokens via OIDC on every run, and those tokens expire automatically.
Make a code change and watch the full cycle
The pipeline worked once. Now prove it works every time by pushing a visible code change and watching it flow through the entire cycle automatically.
- In app/app.py, find the index() function's message value.
- Find this line:
"message": "Deployed via keyless CI/CD pipeline"
- Change the message text to anything you like. For example, change it to "Deployed via keyless CI/CD pipeline v2".
- Save app/app.py.
- Commit and push the change by running:
git add app/app.py
git commit -m "Update API message to test full pipeline cycle"
git push origin main
- Switch back to the Actions tab in GitHub and watch the new workflow run execute all stages: build, scan, push, deploy, and wait.
Before you curl again, do you expect to see the old message or the new one? How long will it take?
- Once the workflow completes, test the endpoint again by running:
curl http://[[ALB_DNS_NAME="your ALB DNS name from terraform output"]]/
You should see your updated message in the JSON response. The full cycle took your code change from commit to live production without you touching the AWS console once.
Still seeing the old message?
- ECS takes 2-3 minutes to drain the old task and start the new one. Wait for the pipeline's "Wait for deployment" step to complete before testing.
- Some terminals and HTTP clients cache DNS. Try adding a trailing path or query string to bust the cache.
help me figure out why my ECS deployment is not showing the updated code after a push
You now have a fully automated, keyless CI/CD pipeline. Every push to main triggers a build, security scan, and deployment with zero manual intervention and zero stored credentials.
Secret mission
Add Environment Promotion with Manual Approval
Production teams never deploy directly to prod. In this extension, you'll add a dev-to-prod promotion flow with a manual approval gate. Your pipeline will deploy to dev automatically on every push, then pause and wait for a human to approve before touching production.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses ECS Fargate, an Application Load Balancer, and a NAT Gateway, all of which incur hourly charges while active.
Cost warning
Your ECS Fargate services, ALB, and NAT Gateway cost approximately $2-4/day while running. If you are done with the project, destroy all resources immediately to avoid unexpected charges.
Resources you used:
- AWS ECS Fargate cluster and services (dev and prod).
- Application Load Balancer with path-based routing.
- NAT Gateway and VPC networking (subnets, internet gateway, route tables).
- ECR repository containing Docker images.
- S3 bucket and DynamoDB table for Terraform state.
- IAM OIDC provider and GitHub Actions role.
- CloudWatch Log Group for ECS task logs.
- Local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or testing your pipeline.
- Your ECS services, ALB, and NAT Gateway will continue to incur charges (~$2-4/day).
- Your GitHub Actions pipeline remains functional for future pushes.
- Monitor your AWS billing dashboard to stay aware of costs.
Pause - I'll come back to this later
ECS Fargate does not have a traditional pause option. Services are either running or destroyed. However, you can stop all running tasks to eliminate compute charges while keeping the infrastructure in place.
- Set the desired count to 0 on both ECS services to stop all running tasks:
aws ecs update-service --cluster devops-api-cluster --service devops-api-dev-service --desired-count 0
aws ecs update-service --cluster devops-api-cluster --service devops-api-prod-service --desired-count 0
What still costs money?
Even with zero tasks running, the ALB (~$0.02/hour) and NAT Gateway (~$0.05/hour) still incur charges. Only a full destroy eliminates all costs.
- To resume later, set the desired count back to 1 on each service and push a new commit to trigger a fresh deployment.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow every step below to avoid lingering charges.
First, destroy all Terraform-managed resources (VPC, ALB, ECS, ECR, NAT Gateway, security groups, CloudWatch logs):
- Navigate to your Terraform directory and destroy all resources by running:
cd terraform
terraform destroy -auto-approve
Next, delete the Terraform state backend (these are not managed by Terraform itself):
- Remove the S3 state bucket and DynamoDB lock table by running:
aws s3 rb s3://[[STATE_BUCKET="your-terraform-state-bucket"]] --force
aws dynamodb delete-table --table-name terraform-locks
Next, delete the IAM OIDC provider and the GitHub Actions role:
- Remove the inline policy, delete the role, and remove the OIDC provider by running:
aws iam delete-role-policy --role-name github-actions-role --policy-name github-actions-policy
aws iam delete-role --role-name github-actions-role
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn arn:aws:iam::[[AWS_ACCOUNT_ID="your-account-id"]]:oidc-provider/token.actions.githubusercontent.com
Finally, verify nothing is left running:
- Run these commands to confirm all resources are gone:
aws ecs list-clusters
aws ec2 describe-nat-gateways --filter Name=state,Values=available
aws elbv2 describe-load-balancers
Each command should return an empty list. If any resources remain, delete them manually via the AWS console to avoid ongoing charges.
- Delete your local project folder by running:
cd ~/Desktop
rm -rf devops-pipeline
- Optionally, delete the GitHub repository by going to your repository on GitHub, clicking Settings, scrolling to the bottom, and clicking Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade, keyless CI/CD pipeline that deploys a containerized application to AWS without a single stored credential.
You've learned how to:
- Build a keyless CI/CD pipeline using GitHub Actions OIDC to authenticate to AWS with zero stored credentials.
- Integrate Trivy as a security gate that blocks deployment when HIGH or CRITICAL vulnerabilities are detected in your container image.
- Provision complete AWS infrastructure (VPC, ALB, ECS Fargate) using Terraform and deploy a containerized app end-to-end where a push to main triggers build, scan, and deploy automatically.
- Secret Mission: Added environment promotion with manual approval gates to separate dev and prod deployments.
Ready to quiz yourself?