Building Secure CI/CD with Terraform on AWS

Ashutosh Singh - Nov 22 - - Dev Community

Recently, I was interviewed for a DevOps role, and I was asked, "Do you use Terraform in CI/CD?" I said yes. How do you use Terraform in CI/CD? Put the AWS credentials in the GitHub secret and use it with the aws-cli tool to provision the infrastructure. Sadly, I didn't get the job, but I was compelled to explore a new way, so here I am sharing what I found.

Prerequisite: AWS & GitHub Account, Terraform

Let me tell you more about the question, he wanted to know how I handle the permission as this is the most important thing in DevOps, the answer I gave was alright, but it turns out we have more security in doing the same task, by letting the GitHub action or any other CI/CD tool to assume the role from AWS directly without us storing the aws secret or access key in the vault of our CI/CD tools. Instead, we can provision the dynamic credentials by using the AWS sts assume role & OIDC.

STEP I
To tackle this challenge securely and efficiently, the first step is to establish trust between AWS and GitHub Actions by setting up an OIDC provider. Let’s dive into how to do that. If you want to read more about OIDC click here.

OIDC

Click on the Add Provider

Enter these details

OIDC provider: https://token.actions.githubusercontent.com
Audience: sts.amazonaws.com

Now that we have established the OIDC trust between AWS and GitHub, the next step is to create an IAM role with a custom trust policy. This role will allow GitHub Actions to assume the required permissions dynamically.

STEP II

Let's Create the IAM role using a custom trust policy

IAM Role

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:your-username/your-repo:ref:refs/heads/branch-name"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

NOTE: Kindly Replace these placeholders

<your-username> with your GitHub username.
<your-repo> with your repository's name.
<branch-name> with the branch name (e.g., main or master)
Enter fullscreen mode Exit fullscreen mode

After Replacing those placeholders, let's add the permission for the services this role will allow the GitHub action to provision, for simplicity I'm giving it PowerUser permission but again change them according to needs.

IAM Policy

STEP III

With the IAM role in place, it’s time to configure our GitHub Actions workflow. Here, we’ll set up a YAML file to interact with AWS services using the dynamic credentials generated by the assumed role

name: Deploy to AWS

on:
  push:
    branches:
      - master

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout code
      - name: Checkout repository
        uses: actions/checkout@v3

      # Step 2: Configure AWS credentials using OIDC
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ secrets.AWS_REGION }}

      # Step 3: Example AWS command (S3 upload in this case)
      - name: Upload files to S3
        run: |
          aws s3 cp ./12.png s3://${{ secrets.S3_BUCKET_NAME }}/

      # Step 4: (Optional) Additional AWS CLI commands or deployment steps
      - name: Example additional AWS command
        run: |
          aws ec2 describe-instances

Enter fullscreen mode Exit fullscreen mode

There are a couple of things which you need to understand

permission block contains 2 fields id-token and content. By id-token we are setting OIDC to be used, by using content: read we are allowing the workflow to read the repo files. Rest is simple

After the setup is completed here are my workflow screenshots

The bucket containing the uploaded file from the repo
S3 Bucket

GitHub Action Logs
GitHub Action ss

At this point, your GitHub Actions pipeline is securely configured to interact with AWS services. Next, we’ll integrate Terraform into the pipeline to demonstrate how to provision infrastructure dynamically.

STEP IV

Now the last leg Running the CI/CD with Terraform For that we need to write the Terraform script and modify the action yml file.

workflow.yml file

name: Terraform EC2 Provisioning

on:
  push:
    branches:
      - master

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout the repository
      - name: Checkout repository
        uses: actions/checkout@v3

      # Step 2: Configure AWS credentials using OIDC
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ secrets.AWS_REGION }}

      # Step 3: Set up Terraform CLI
      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.5.0

      # Step 4: Initialize Terraform
      - name: Terraform Init
        run: terraform init

      # Step 5: Plan Terraform changes
      - name: Terraform Plan
        run: terraform plan -out=tfplan

      # Step 6: Apply Terraform changes
      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan
Enter fullscreen mode Exit fullscreen mode

Now some Terraform scripts

main.tf

provider "aws" {
  region = var.region
}

resource "aws_instance" "example" {
  ami           = var.ami_id
  instance_type = var.instance_type

  tags = {
    Name = "ExampleInstance"
  }
}

output "instance_id" {
  value = aws_instance.example.id
}

output "public_ip" {
  value = aws_instance.example.public_ip
}
Enter fullscreen mode Exit fullscreen mode

terraform.tfvars

region        = "eu-west-2"
ami_id        = "ami-0b2ed2e3df8cf9080" # Replace with your preferred AMI ID
instance_type = "t2.micro"

Enter fullscreen mode Exit fullscreen mode

variables.tf

variable "region" {
  description = "AWS region"
  type        = string
  default     = "eu-west-2"
}

variable "ami_id" {
  description = "AMI ID for the EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "Instance type for the EC2 instance"
  type        = string
  default     = "t2.micro"
}
Enter fullscreen mode Exit fullscreen mode

With these Terraform scripts in place, the pipeline can now provision an EC2 instance as part of the CI/CD process. Let’s run the workflow and see the results in action.

Ec2 instance

Checking the Github Logs

GitHub Log for action running terraform secripts

And my friend, now you know how to configure the pipeline without using the static credentials stored in GitHub Secrets.

Now that everything is set up, we can observe the results of our secure and dynamic CI/CD pipeline in GitHub Actions logs and AWS resources. This demonstrates the power of OIDC integration in real-world DevOps workflows.

Conclusion

By integrating OIDC (OpenID Connect) with AWS and GitHub Actions, we enhance security and simplify the CI/CD process. Instead of relying on static credentials stored in GitHub secrets, we use dynamically generated, short-lived credentials through AWS STS.

This approach offers several key benefits:

  1. Improved Security: No sensitive credentials are stored in CI/CD tools, reducing the risk of accidental exposure or misuse.
  2. Granular Access Control: Using IAM trust policies, access can be tightly scoped to specific repositories, branches, and workflows.
  3. Automation-Friendly: Dynamic credentials streamline workflows, making them more robust and easier to maintain.
  4. Reduced Attack Surface: Temporary credentials expire quickly, minimizing the impact of potential leaks.

Thank you for Reading if you face any issues feel free to ask in the comments

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .