Using GitHub Actions, IAM, and the AWS CDK for multi-environment deployments
In this quick post, I thought I’d share the steps I took on a recent project to automate multi-environment deployments to AWS using GitHub Actions.
Introduction
Multi-environment deployments allow you to verify changes prior to release without the risk of impacting your users in the event that your changes do not work as intended. There are several ways to create these environments but this post focuses on using separate AWS accounts.
The following steps worked well for my use case. If you have any suggestions for how I can do anything better, I'd love to hear from you!
Prerequisites
- You have a GitHub repo containing a CDK app
- You have an AWS Organization with member accounts for each environment (e.g., dev, staging, and prod)
- You have the AWS CLI and AWS CDK CLI installed on your machine
- You have some familiarity with GitHub Actions
Consider reviewing these resources before continuing if you're missing any of the prerequisites:
- Creating an AWS Organization
- Installing the latest version of the AWS CLI
- Getting started with the AWS CDK
- Creating your first AWS CDK app
- Creating an example workflow with GitHub Actions
Configure your AWS accounts
Bootstrap the AWS CDK
The first thing you need to do is perform AWS CDK bootstrapping in each AWS account. Bootstrapping provisions several resources in your account that are used by the AWS CDK:
- An S3 bucket to store CDK project files
- An ECR repository for any Docker images
- Several IAM roles granted with the permissions required to perform CDK deployments
For the purposes of automating deployments with GitHub Actions, we care most about the new IAM roles. You'll use these in the next step.
For now, login to your AWS accounts from the command line (e.g., aws sso login) and run cdk bootstrap to perform bootstrapping.
The "DevAdmin", "StagingAdmin", and "ProdAdmin" profiles I'm using below were created with aws configure sso. If you already have profiles for your dev, staging, and prod account, just use those.
Dev account
aws sso login --profile DevAdmin
cdk bootstrap --profile DevAdmin
Staging account
aws sso login --profile StagingAdmin
cdk bootstrap --profile StagingAdmin
Prod account
aws sso login --profile ProdAdmin
cdk bootstrap --profile ProdAdmin
References
Create a new IAM policy
Next, you need to create a new IAM policy. This policy will be attached to a new GitHub Actions role, allowing GitHub Actions to assume the CDK roles that were created by bootstrapping and perform CDK deployments.
- Go to IAM > Access Management > Policies and click "Create policy"
- In "Policy editor" select "JSON"
- Replace the contents of the policy editor with the following, replacing
{AWS_ACCOUNT}with your AWS Account Id:{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::{AWS_ACCOUNT}:role/cdk-*"
}
]
} - Click "Next"
- Give the policy a name (e.g., AssumeCDKRoles) and click "Create policy"
- Repeat for your other AWS accounts
Add GitHub OIDC as an identity provider
Next, you need to give GitHub Actions a way to authenticate with your AWS accounts. The preferred way to do this is to use OpenID Connect (OIDC). By using OIDC, you avoid storing long-lived access tokens as GitHub secrets.
- Go to IAM > Access Management > Identity Providers and click "Add Provider"
- Select "OpenID Connect" as the Provider Type
- Enter
https://token.actions.githubusercontent.comas the Provider URL - Enter
sts.amazonaws.comas the Audience - Click "Add Provider"
- Repeat for your other AWS accounts
References
Create a new role for GitHub Actions
Next, you need to create a new role for GitHub Actions to assume. You'll give this role permission to perform a CDK deployments by attaching the "AssumeCDKRoles" policy you created earlier.
- Go to IAM > Access Management > Roles and click "Create role"
- Select "Web Identity" as the Trusted Entity Type
- Select
tokens.actions.githubusercontent.comas the Identity provider - Select
sts.amazonaws.comas the Audience - Enter your GitHub organization (.e.g., if your GitHub repo is at
https://github.com/your-nameorhttps://github.com/your-company, you would enter "your-name" or "your-company" as the organization) - Enter a GitHub repo to limit the repos within your organization that can assume this role
- Skip limiting to a specific branch. We'll address this in the next step.
- Click "Next"
- Select the "AssumeCDKRoles" policy you created previously and click "Next"
- Give the role a name (e.g., GitHubActionsRole) and click "Create role"
- Repeat for your other AWS accounts
References
(Optional) Limit access to specific branches
Although this step is optional, you probably want to do this for your staging and prod environments to limit deployments in these environments to your main branch. However, your dev environment probably doesn't need this restriction.
- Go to IAM > Access Management > Roles and select the role you just created
- Select "Trust relationships" and click "Edit trust policy"
- Replace the policy with the following:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::{AWS_ACCOUNT}:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:{ORGANIZATION/{REPO}:environment:{ENVIRONMENT}",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:ref": "refs/heads/{BRANCH}"
}
}
}
]
}{AWS_ACCOUNT}is your AWS Account Id{ORGANIZATION}is your GitHub organization{REPO}is the name of your GitHub repo{ENVIRONMENT}is the name of the environment associated with your AWS account (e.g., staging or prod){BRANCH}is the name of the branch that will be allowed to deploy in this account
References
Modify your CDK app
Leverage CDK environment variables
If you generated your CDK app using the CDK CLI (e.g., cdk init --language typescript), your main CDK app file might look something like this:
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { AppStack } from '../lib/app-stack';
const app = new cdk.App();
new AppStack(app, 'AppStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */
/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },
/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
When you use the AWS CLI to authenticate (either locally or in GitHub Actions), the CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION environment variables will be set for you. Therefore, all you need to do to deploy your CDK app into the account you're logged in to is pass these environment variables to your CDK stacks.
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { AppStack } from '../lib/app-stack';
const app = new cdk.App();
new AppStack(app, 'AppStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
(Optional) Custom domain names per environment
For my application, I wanted each environment to have a unique domain name (e.g., dev.example.com for the dev environment, staging.example.com for staging, and just example.com for prod).
To do this, I created a new DnsStack to create a hosted zone and certificate in each AWS account.
The way that I chose to create custom domains for each environment will allow anyone to access these domains. For a real application (not a side project), you probably want to keep your non-prod environments for internal use only. One way you could do this is by making your API Gateway endpoint in your dev and staging accounts private and limiting access to traffic coming from your VPC. This will limit access to people who are connected to your VPC via VPN.
import * as cdk from 'aws-cdk-lib';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
interface DnsStackProps extends cdk.StackProps {
domainName: string;
}
export class DnsStack extends cdk.Stack {
public readonly hostedZone: route53.IHostedZone;
public readonly certificate: acm.ICertificate;
constructor(scope: cdk.App, id: string, props: DnsStackProps) {
super(scope, id, props);
// Create a hosted zone for the environment-specific domain name
this.hostedZone = new route53.HostedZone(this, 'HostedZone', {
zoneName: props.domainName,
});
// Create a certificate for the environment-specific domain name
this.certificate = new acm.Certificate(this, 'Certificate', {
domainName: props.domainName,
validation: acm.CertificateValidation.fromDns(this.hostedZone),
});
}
}
Then, I updated my AppStack to use the hosted zone and certificate when creating other resources.
import * as cdk from 'aws-cdk-lib/core';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53_targets from 'aws-cdk-lib/aws-route53-targets';
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import { Construct } from 'constructs';
interface AppStackProps extends cdk.StackProps {
hostedZone: route53.IHostedZone;
certificate: acm.ICertificate;
}
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: AppStackProps) {
super(scope, id, props);
// Other resource configurations...
// Create environment-specific domain name
const domainName = new apigatewayv2.DomainName(this, 'DomainName', {
certificate: props.certificate,
domainName: props.hostedZone.zoneName,
});
// Map some API resource to the environment-specific domain name
// (e.g., dev.example.com/api/)
new apigatewayv2.ApiMapping(this, 'ApiMapping', {
api: someApiResource,
domainName,
apiMappingKey: 'api',
});
new route53.ARecord(this, 'AliasRecord', {
zone: props.hostedZone,
target: route53.RecordTarget.fromAlias(
new route53_targets.ApiGatewayv2DomainProperties(
domainName.regionalDomainName,
domainName.regionalHostedZoneId
)
),
});
}
}
Finally, I update the main CDK app to create the DnsStack and pass its outputs as props to the AppStack.
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { AppStack } from '../lib/app-stack';
import { DnsStack } from '../lib/dns-stack';
const app = new cdk.App();
const dnsStack = new DnsStack(app, 'DnsStack', {
domainName: process.env.DOMAIN_NAME,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: 'us-east-1', // Certs for CloudFront must be in us-east-1
},
});
new AppStack(app, 'AppStack', {
hostedZone: dnsStack.hostedZone,
certificate: dnsStack.certificate,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
I chose to create a separate stack for the hosted zone and certificate because that's something that you really only need to create once. Creating these resources also requires you to copy the NS records from your hosted zone to your domain registrar so that DNS knows which name servers to ask about the IP address of your subdomains. This is a manual step that I didn't want to worry about in my CI/CD pipeline.
Create a GitHub Actions workflow
Now that your CDK app is updated to use the standard environment variables set by the AWS CLI upon logging in, the next thing you need to do is create a new GitHub Actions workflow to faciliate the deployment of your app into each AWS account.
Create a .github/workflows directory and add the following file:
name: deploy
on:
push:
branches:
- main
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
type: environment
required: true
permissions:
id-token: write
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment: ${{ inputs.environment || github.event_name == 'release' && 'Production' || 'Staging' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- name: Install dependencies
run: npm install
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@main
with:
role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Deploy all CDK stacks
run: npx cdk deploy AppStack --require-approval=never
env:
# Required if you configured environment-specific domain names
DOMAIN_NAME: ${{ vars.DOMAIN_NAME }}
This workflow only deploys the AppStack. If you chose to create custom domain names per environment and created a DnsStack like I did, you'll need to deploy this DnsStack manually and add the NS records from each hosted zone to your domain registrar before this workflow will work (e.g., cdk deploy DnsStack --profile {SomeProfile}).
This workflow uses the push, release and workflow_dispatch triggers to intelligently deploy to different environments automatically.
- When code is pushed to main, the workflow will deploy to the staging environment
- When a release is created, the workflow will deploy to the prod environment
- When you run the workflow manually, you can choose the branch and environment you want to deploy to
For authentication with AWS, we use the aws-actions/configure-aws-credentials action. This action relies on the OIDC provider, role, and policy that you previously setup in your AWS accounts. All you need to do is provide it with the ARN of the role that GitHub Actions should assume and the region that the role is in. Once GitHub Actions is authenticated, the CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION environment variables that your CDK app depends on will be set.
Configure your GitHub Actions environments
With your AWS accounts configured, CDK app modified, and GitHub Actions workflow created, all that's left is to create GitHub Actions environment for each of your AWS accounts. Environments allow you to define environment-specific variables and secrets that can be referenced in your GitHub Actions workflows.
Create environments
If you chose to limit AWS account access to specific branches, the name of your environments must align with the name that you used when configuring your trust policy.
- Go to Settings > Environments and click "New environment"
- Enter "dev" as the environment name and click "Configure environment"
- Repeat for "staging" and "prod" environments
Add environment variables
Next, you need to add environment variables to each environment. These variables will be used by your GitHub Actions workflow to deploy to the correct AWS account.
Perform the following to add environment variables to each environment:
- Go to Settings > Environments and select the environment
- Under "Environment variables", click "Add environment variable"
- Add the environment variables required to deploy to the associated AWS account:
AWS_GITHUB_ACTIONS_ROLE_ARN: The ARN of the role you created in the associated AWS account (e.g.,arn:aws:iam::{AWS_ACCOUNT}:role/GitHubActions)AWS_REGION: The AWS region you want to deploy to (e.g.,us-east-1)
- Add any other app-specific environment variables that you need (e.g., an environment-specific domain name):
DOMAIN_NAME
Wrapping up
That's all you need to perform multi-environment deployments using GitHub Actions and the AWS CDK! Hopefully this guide will help simplify the process for someone out there (likely myself in the future).
As I said that the beginning of this post, if you have any recommendations for how this can be improved or if you notice anything that I've missed, don't hesitate to comment or reach out on LinkedIn!