Creating and AWS CI/CD Environment with GitHub Actions

Creating and AWS CI/CD Environment with GitHub Actions

Sep 29, 2024ยท
Andrew Wyllie
Andrew Wyllie
ยท 19 min read

Overview

Getting a development environment set up on AWS for the first time can be a bit overwhelming. Working in the cloud has a different mentality than doing everything on-prem and this is especially true when building cloud native architectures. This article runs through some of the common approaches taken to set up this type of environment, and gives examples of how to create a seamless, automated system to build production, testing/review(Q.A.), and development environments on AWS.

Using GitHub Actions is a great way to create a CI/CD pipeline to manage your AWS environment. Not only can you use this setup to manage your AWS applications and workloads, you can also use it to manage your AWS infrastructure using any number of Infrastructure as Code (IaC) applications such as Terraform, AWS CloudFormation or AWS CDK. The idea here is that you can write your IaC code and manage it on GitHub, which means that you can use pull requests, code review sessions and also track how your infrastructure has changed over time by reviewing your pull request notes, who made changes and maybe more importantly, who approved changes.

AWS Organizations

Exciting stuff right? Before you just dive in and start building stuff, you should think about what your development environment is going to look like. How will the engineering team make changes without affecting the production environment? How will code be tested and reviewed? A really nice way to handle this on AWS is to enable AWS organizations and then create separate accounts for each environment. A simple layout might look something like:

mindmap root((AWS Org Account)) Development id(SWE 1 account) id(SWE 2 account) id(SWE 3 account) Testing and Review id(Testing account) Production id(Production Accounts)

Using AWS Organizations to manage your environment is an AWS best practice. This setup is more secure as we can control who has access to each account. For example, ideally, access to production and testing accounts should be fairly limited. It also simplifies the process of suspending the account when a staff member changes roles or leaves the company. This set up can also make billing easier as you can get a sense of how much your production environment is costing without having to worry about the resources your engineering team is using. You can also put billing alerts on your non production accounts so that someone does not forget to suspend or delete a resource they are not using or to catch a runaway process that is dumping tons of data into an S3 bucket. Another great reason is innovation. Giving an engineer their own account (without them having to worry about messing up anyone else) gives them the freedom and opportunity to explore AWS offerings, and build new systems in a controlled way.

The main goal in this example is to keep development paths as simple as possible. The main branch will contain the production version of the code. So after the code is merged into main, we will typically cut a new release and push it to production. This example shows a team working on a feature and another team or individual contributor working on a hotfix.

Looking at is a git workflow, we may see something like this:

%%{init: { 'logLevel': 'debug', 'theme': 'default' , 'themeVariables': { 'git0': '#f7cd70', 'git1': '#bd64f7', 'git2': '#80a2f8', 'git3': '#ee8166', 'git4': '#ec6992', 'git5': '#ffff00', 'git6': '#ff00ff', 'git7': '#00ffff' } } }%% gitGraph commit id: "Normal" tag: "v1.0.0" commit id: "1" branch feature checkout feature commit id: "Add IaC for New S3 bucket" commit id: "Add New bucket permissions" checkout main commit branch hotfix checkout hotfix commit id: "Adjust IaC Params for CloudFront" checkout main merge hotfix commit id: "Patch Release" tag: "v1.0.1" checkout feature commit id: "Add Route53 IaC" commit id: "Add Cloudfront IaC" checkout main merge feature checkout main commit id: "Minor Release" tag: "v1.1.0"

We start on the main branch at v1.0.0. The tags on the main branch represent what is in production and the goal is to keep the main branch as clean as possible at all times. We really don’t want people creating new feature branches with non-production code. In our example, engineers create branches for features or hotfixes (or whatever), work on the code and then create pull requests to merge the code back into the main branch. Before the code can be merged back into the main branch, we need to run our automated test suite, do code review with another member of the team and potentially get a review with the product team or the system architecture team. If everything looks good, we merge the code into the main branch and cut a release by creating a new tag. In the example above we create a patch release called v1.0.1 after the hotfix branch is merged back in and a minor release when the feature branch is merged in called v1.1.0.

This tag represents the code that is currently in production but how do we see what’s going on in the branches so that we can test and review the code before we move it into the main branch? This is where GitHub Actions comes in. GitHub Actions have rules which trigger custom processes which gives you an opportunity to take “Action” everytime the GitHub Repo is interacted with.

GitHub Actions

There are challenges though. How do we set up CI/CD processes so that new code goes to the correct account in a seamless/automated way?

Here are the rules and actions we care about in this example:

Rule Action
the main branch is tagged run action to push a new release into production
pull request created for hotfix branch against the main branch run action to push a release to a testing/review environment
commit created on feature or hotfix branch deploy to a development environment

You could set this up in separate GitHub actions so that each action has a single rule at the top and a specific set of steps. The problem with this approach is that you will need to make sure that all the action workflow files are kept synced up otherwise a deployment may work differently in the different environments. So, it might look great on the dev version of the site but it breaks in testing or, even worse, production!

Multi Destination Actions

To combat having to keep a number of action files all synced up, we can use a single action workflow and add some logic that decides what action needs to be taken.

The action file will look something like this:

name: Website Application

on:
  push:
    branches:
      - main    # This is your default release branch
      - '**'    # push to any branch
  pull_request:
    branches:
      - main    # This will run on pull requests targeting the main branch
  release:
    types: [published]
  workflow_dispatch: # Allows for manually triggering the workflow

jobs:
  set_provision_environment:
    runs-on: ubuntu-latest

    steps:
      - name: Set Environment
        id: set-env
        run: echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_ENV
        env: 
          ENVIRONMENT: ${{ 
            github.event_name == 'pull_request' && github.base_ref == 'main' && 'staging' || 
            (github.ref == 'refs/heads/main' && 'production' || 'development' ) 
            }} 

The first bit sets the triggers. In this case, our action will trigger when there is a push to the main branch or to any other branch. Yes, there is nothing special about the main branch and yes, it is included in ‘**’ but there is something to be said about being explicit and that this action will run on the main release branch. This action will also trigger if there is a pull request against the main branch. Finally, it can be manually triggered from the web interface in GitHub - this can be useful for debugging in which case the web interface is going to ask you what branch to run on and whet the trigger is i.e., push or pull_request in this case.

In the jobs section we will create a job to figure out what environment we are running in. The options for environment will be ‘development, staging’ or ‘production’. We can figure out what the environment is by looking at the trigger and then checking what branch we are on. So, if the trigger is a pull_request and we are running on the main branch we are going to run our actions on the staging environment. If github.ref is refs/heads/main it means we are pushing to main. If neither of those conditions are true, it must be a trigger to build in a development account.

In this next section we will modify the setup-environment job so that instead of just setting the environment to development, we can set the environment so that the deployment goes to a specific development AWS account by getting the username of the person doing the deployment. This is kind of cool if you have a number of engineers on the team but might be overkill depending on the type of work you are doing.

To do this, the only change we need to make is to replace developmet with github.actor. Now the set_provision_environment job looks like this:

jobs:
  set_provision_environment:
    runs-on: ubuntu-latest

    steps:
      - name: Set Environment
        id: set-env
        run: echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_ENV
        env: 
          ENVIRONMENT: ${{ 
            github.event_name == 'pull_request' && github.base_ref == 'main' && 'staging' || 
            (github.ref == 'refs/heads/main' && 'production' || github.actor ) 
            }} 
  build:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up Node.js (example)
      uses: actions/setup-node@v3
      with:
        node-version: '16' # Modify as per your requirement

    - name: Install dependencies
      run: npm install # Example for a Node.js project, adjust based on your project type

    - name: Run tests
      run: npm test # Adjust this to fit the testing command in your project

    - name: Build project
      run: npm run build # Adjust based on your project needs

Secure Connection to AWS

Before you can start building your infrastructure, you’ll need to configure a connection between GitHub and your AWS account. Of course, we want this to be a secure as possible and also flexible so that we can have ways to deploy to different environments like development, testing and production. We also want it to be as foolproof as possible so that we don’t have dev code accidentally being deployed to production.

If this is not obvious, you should never put passwords, access keys, tokens or anything else that could allow someone to gain unauthorized access to another account. There are a couple of ways to set this up but the easiest way to get up and running is to use the configure-aws-credentials action which is maintained by AWS.

GitHub Environments and Secrets

Above, we went to a lot of trouble to set an output variable called environment. The purpose of this is that GitHub has environments that can be used to store variable and secrets specific to the environment. This means that we could have different AWS credentials set up depend on which environment we are running in. This is very handy as it keeps our jobs very clean and guarantees that all of our deployment scripts run exactly the same way regardless of which environment we are running in. This helps us avoid the “it looks fine on my machine” type scenario where our different environments get out of sync.

GitHub allows you to store credentials for third party accounts in a secure manner using GitHub Secrets. Think of it kind of like a password manager. One thing to keep in mind with GitHub Secrets is that you cannot see the ‘secrets’ after they have been set and the only way to edit them is to enter new values for the secret. Generally speaking this is not a problem but some people add values to GitHub secrets that are not really secret data, just configuration parameters like AWS_REGION which does not really need to be kept secret. You can also use GitHub’s environment variables to store this type of information which may come in handy when you are debugging a deployment, check the region and realize that you are looking at the wrong region on AWS (yes, this happens to the best of us).

Using OpenId Connect

OpenID Connect1 is the preferred way to connect GitHub to your AWS account. This works by creating an OpenID provider and an IAM role on AWS and then telling GitHub which role to use to connect to the account. Since we have different environments defined in our GitHub action, we can provide a role for each account we would like to be able to deploy to.

Here’s the CDK code to set this up:

import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';

export class OpenIdStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Get the GitHub organization and repo from CDK context which can be 
    //     set on the command line when we deploy the template
    const githubOrg = this.node.tryGetContext('githubOrg') || 'default-org';  // Default to 'default-org'
    const githubRepo = this.node.tryGetContext('githubRepo') || 'default-repo';  // Default to 'default-repo'

    // Create the OIDC provider
    const oidcProvider = new iam.OpenIdConnectProvider(this, 'GitHubOidcProvider', {
      url: 'https://token.actions.githubusercontent.com',
      clientIds: ['sts.amazonaws.com'],
    });

    // Create the Oidc Role
    const githubOidcRole = new iam.Role(this, 'GitHubOidcRole', {
      assumedBy: new iam.FederatedPrincipal(
        oidcProvider.openIdConnectProviderArn,
        {
          'StringLike': {
            'token.actions.githubusercontent.com:sub': `repo:${githubOrg}/${githubRepo}:*`
          }
        },
        'sts:AssumeRoleWithWebIdentity'
      ),
      description: 'Role assumed by GitHub Actions using OIDC',
    });

    new cdk.CfnOutput(this, 'GitHubOidcRoleArn', {
      value: githubOidcRole.roleArn,
      description: 'The ARN of the role that GitHub Actions can assume via OIDC',
    });
  }
}

Ok, that’s a lot, lets break it down a bit and see what’s going on under the hood.

This bit:

const githubOrg = this.node.tryGetContext('githubOrg') || 'default-org';  // Default to 'default-org'
const githubRepo = this.node.tryGetContext('githubRepo') || 'default-repo';  // Default to 'default-repo'

is going to allow us to supply the GitHub org and repo on the command line when we deploy the template. So, the command will look something like this when we deploy:

$ cdk deploy --context YOUR-GitHub-ORG_NAME --context githubRepo=YOUR-GitHub-REPO_NAME

Now we can create the OIDC provider and establish that AWS will trust tokens issued by the GitHub Actions identity provider.

// Create the OIDC provider
const oidcProvider = new iam.OpenIdConnectProvider(this, 'GitHubOidcProvider', {
    url: 'https://token.actions.githubusercontent.com',
    clientIds: ['sts.amazonaws.com'],
});

This part defines the role that GitHub will be assigned when it connects to AWS. The StringLike part tells AWS what the token from GitHub is going to look like. It’s basically saying that if the token being sent by GitHub matches this string, that AWS will allow access.

// Create the Oidc Role
const githubOidcRole = new iam.Role(this, 'GitHubOidcRole', {
    assumedBy: new iam.FederatedPrincipal(
        oidcProvider.openIdConnectProviderArn,
        {
            'StringLike': {
                'token.actions.githubusercontent.com:sub': `repo:${githubOrg}/${githubRepo}:*`
            }
        },
        'sts:AssumeRoleWithWebIdentity'
    ),
    description: 'Role assumed by GitHub Actions using OIDC',
});

Did you notice the * on the end of the token? This is because GitHub is going to send a token that has some additional information on the end. The critical part is making sure the GitHub Org and repo are correct. We can make it more granular by adding more information on the end of the token. There is more information about this the GitHub Documentation2 and here3 and the AWS documentation here4.

The last section will output the ARN for the role. We will create a secret on GitHub with the role name so that when we try to connect to AWS, GitHub will know which role to use.

new cdk.CfnOutput(this, 'GitHubOidcRoleArn', {
    value: githubOidcRole.roleArn,
    description: 'The ARN of the role that GitHub Actions can assume via OIDC',
});

The cool part here is that we will make roles in each AWS account we want to connect to i.e., we will create separate roles and install the in our production, staging, and developer AWS accounts. Once we have created all the roles, we can create secrets in GitHub to hold the roles. You can do this in the GitHub web interface by going to the setting section of the repo you are connecting and finding either Environments or Secrets and Variables in the menu on the left side. We need to create environments for production and staging and then, depending on how you set it up, either an environment for development or individual environments for each developer using their GitHub login name! Pretty cool right?

When we save the secret in GitHub we can call it something like OPENID_GITHUB_ROLE, it will contain the Arn for the role we created. The important part is that we use the same name for each environment. So create the role in the Production AWS account and then got to your GitHub repo and create a secret in the production environment called OPENID_GITHUB_ROLE, then do the staging environment, and then the devs.

Next Level it!! Everyone knows that web interfaces are for whimps. I mean, nothing (necessarily) wrong with using a web interface but when we are doing some serious DevOps work, we don’t want to have to worry about some web interface, that at best is bound to change at some point or that we may not even have access to when we need it the most and, at worst, someone goes inthere and toggles some button somewhere and does not document what they did, or tell anyone, or gets hit by a bus. So, to take this to the next level, we can create the secret in the GitHub repo’s environment from the command line using the gh cli tool from GitHub. A quick shell script (of which, ChatGPT was a major contributor) is available on the GitHub repo for this article, here[^dx-repo]. The nice thing about this approach is that you can run the scripts multiple times and make small changes as you go along. It’s a lot better than clicking through ton of webpages, but that’s my bias and your mileage may vary. Ultimately, you know that once your script is established, everything will be configured the same way. Of course, you can also give this script to your devs and have them set up their own connections as well which is a lot easier than documenting how to do this in the web interfaces.
The GitHub CLI tool - The gh CLI utility is pretty handy for other things like creating repos and managing GitHub Actions! You can get more info https://cli.github.com/ .

Using AWS credentials

This is a very popular and fairly secure way to set up a connection. The main issue with this approach is that you need to create access keys and store them on GitHub. Not the end of the world but it just means that you have one more set of credentials that you need to manage and share with your team. We all know that no one would ever share credentials though email, text messages, or Slack but yet, it still happens. You also need to consider the fact that if a team member leaves the company, and they had access to those keys, they would still have access to the AWS account until you (remember to) rotate the credentials…and then you have to update all the places that those credentials are used, etc.

It’s still an option though. The process here is to create a new user in IAM on AWS, generate access keys for the user and then copy those into GitHub secrets in much the same way that was described in the OpenID section above.

GitHub Action Workflow

Now that we have established how we will connect to AWS, we can use the configure-aws-credentials action from AWS to get the connection configured. This small snippet of code shows how this might be set up to do something simple on AWS, like get the account_id of the account we are connected to.

jobs: ## continued
  get_aws_account_id:
    runs-on: ubuntu-latest
    needs: setup_environment
    
    environment:
      name ${{ needs.setup_environment.outputs.environment }}

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.OPENID_GITHUB_ROLE }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Get AWS Account ID
        id: aws-account-id
        run: |
          ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
          echo "AWS_ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV          

      - name: Echo AWS Account ID
        run: |
          echo "Connected to AWS account: ${{ env.AWS_ACCOUNT_ID }}"          

Breaking this apart, we need to include the environment that was identified in the first part of the workflow and then set the environment:

  get_aws_account_id:
    runs-on: ubuntu-latest
    needs: setup_environment

    environment:
      name ${{ needs.setup_environment.outputs.environment }}

This is how we can tell GitHub actions which set of credentials to use.

This section connects GitHub Actions to AWS. This is what it looks like if you are using the OpenID method to authenticate. Also notice that you can include the region here by creating a variable in the environment on GitHub. In GitHub environments, variables are similar to secrets (they work the same way) but you can actually see what the variable is set to while secrets are, well, secret.

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.OPENID_GITHUB_ROLE }}
    aws-region: ${{ vars.AWS_REGION }}

and this is what it looks like if you are using the access keys:

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ vars.AWS_REGION }}
Just remember that best practice would be to use OpenID Connect for this although, it is very common to see people using the access keys

The last bit is just a sanity check that we have connectivity and to make sure we are connecting to the correct account.

- name: Get AWS Account ID
  id: aws-account-id
  run: |
    ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
    echo "AWS_ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV    

- name: Echo AWS Account ID
  run: |
    echo "Connected to AWS account: ${{ env.AWS_ACCOUNT_ID }}"    

Putting it all together

Ok great! We have a secure way to connect to AWS, we have a GitHub action workflow that knows how to deploy to different accounts based on what is going on in the repo.

Adding a new developer account

in the examples above we created production and staging environments. If all your developers have their own AWS accounts you can have them configure their own environment in the GitHub repo they want to work using their GitHub login name for the name of the environment and then add their OpenID Role to their environment. Now when the commit code to the repo, their dev account will update!

Installing the Files

As you are building the repo you need to connect to AWS, you can put the GitHub actions files in the <your-repo>/.github/workflows directory. I would create another repo for the OpenID connection CDK apps and shell scripts. This will make it easier to keep your scripts consistent across all of your repos.

TroubleShooting

One common issue with getting the OpenID connection to work is that the token being sent by GitHub to AWS does not match what AWS is expecting. You can print out the toke that GitHub is sending with the following code sectino in your GitHub action:

      - name: Get Account ID
        run: |
          echo "ENVIRONMENT:  ${{ needs.set_provision_environment.outputs.environment }}"
          echo "ACCOUNT ID: ${{ secrets.AWS_ACCOUNT_ID }}"
          echo "OPENID ROLE: ${{ secrets.OPENID_GITHUB_ROLE }}"
          echo "AWS REGION: ${{ vars.AWS_REGION }}"          

      - name: Install jq
        run: |
          curl -L -o /usr/local/bin/jq https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
          chmod +x /usr/local/bin/jq          

      - name: Retrieve OIDC token
        id: get-oidc-token
        run: |
          # Function to add base64 padding
          add_padding() {
            case $((${#1} % 4)) in
              2) echo "$1==";;
              3) echo "$1=";;
              *) echo "$1";;
            esac
          }

          # Request the OIDC token from the GitHub OIDC provider
          OIDC_TOKEN=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
                              -H "Accept: application/json" \
                              "${ACTIONS_ID_TOKEN_REQUEST_URL}" \
                              | jq -r '.value')

          # Split and decode the OIDC token's header and payload
          DECODED_HEADER=$(echo "$(add_padding $(echo "$OIDC_TOKEN" | cut -d '.' -f1))" | base64 -d)
          DECODED_PAYLOAD=$(echo "$(add_padding $(echo "$OIDC_TOKEN" | cut -d '.' -f2))" | base64 -d)

          echo "OIDC Token Payload: $DECODED_PAYLOAD"

          # Extract and print the 'sub' claim from the payload
          SUB_CLAIM=$(echo "$DECODED_PAYLOAD" | jq -r '.sub')
          echo "OIDC Token Subject (sub): $SUB_CLAIM"          

Conclusion

I hope you found this article interesting and maybe even useful.

Thanks for reading!