CI/CD
Preview infra per PR (S3)
Goal #
Ship a tiny piece of infrastructure (an S3 bucket) on every pull request, then remove it when the PR closes. This proves that:
- AWS trust via GitHub OIDC is working (no long‑lived keys).
- CDK deployments are reproducible from CI.
- Ephemeral, per‑PR environments are feasible.
We’ll use two workflows:
- PR opened/updated → create/update a per‑PR bucket via CDK
- PR closed/merged → destroy it
Prerequisites #
- You completed “Account & bootstrap” and have the
first-stepsCDK TypeScript app from the previous article (withDeployRoleStackand optionalBudgetStack). - You can assume the
CdkDeployerRolevia yourdevprofile locally (for one‑time stack deployments). - Node.js 18+, AWS CLI, CDK v2.
We will extend the same first-steps CDK app by adding two small stacks and two GitHub Actions workflows.
Create an AWS role for GitHub Actions (OIDC) #
Use CDK to configure GitHub’s OIDC provider and an assumable role. Limit trust to your repo.
In your first-steps repo, add:
lib/gha-oidc-role-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
export class GithubOidcRoleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const provider = new iam.OpenIdConnectProvider(this, 'GitHubOIDC', {
url: 'https://token.actions.githubusercontent.com',
clientIds: ['sts.amazonaws.com'],
thumbprints: ['6938fd4d98bab03faadb97b34396831e3780aea1'],
});
// Environment-scoped trust: tokens issued for this environment only
const owner = this.node.tryGetContext('owner') ?? 'OWNER';
const repo = this.node.tryGetContext('repo') ?? 'REPO';
const envName = String(this.node.tryGetContext('env') ?? 'aws-preview');
const teardownEnv = String(this.node.tryGetContext('teardownEnv') ?? 'aws-teardown');
const ghPrincipal = new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
StringEquals: {
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
},
StringLike: {
'token.actions.githubusercontent.com:sub': [
`repo:${owner}/${repo}:environment:${envName}`,
`repo:${owner}/${repo}:environment:${teardownEnv}`,
],
},
});
const role = new iam.Role(this, 'GitHubActionsRole', {
roleName: 'GitHubActionsDeployRole',
assumedBy: ghPrincipal,
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
],
description: 'Assumable by GitHub Actions via OIDC for CDK deploy/destroy',
});
new cdk.CfnOutput(this, 'RoleArn', { value: role.roleArn });
}
}
bin/gha-oidc-role.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { GithubOidcRoleStack } from '../lib/gha-oidc-role-stack';
const app = new cdk.App();
new GithubOidcRoleStack(app, 'GithubOidcRoleStack', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
Deploy (once) as your dev profile (assumes CdkDeployerRole):
npx cdk deploy GithubOidcRoleStack \\
--app "npx ts-node --prefer-ts-exts bin/gha-oidc-role.ts" \\
-c owner=YOUR_OWNER -c repo=YOUR_REPO -c env=aws-preview \\
--profile dev
Tip: if your default cdk.json points to another entry file (for example, bin/deploy-role.ts that also adds a BudgetStack), using --app ensures only the OIDC role stack is synthesized and avoids unrelated context errors.
Copy the RoleArn output for the workflows below.
The per‑PR preview stack (S3 bucket) #
Create a tiny stack that names resources with the PR number and cleans up on destroy.
lib/stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Tags } from 'aws-cdk-lib';
export class Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pr = String(this.node.tryGetContext('pr') ?? 'local');
const repo = this.node.tryGetContext('repo');
const sha = this.node.tryGetContext('sha');
const run = this.node.tryGetContext('run');
const bucket = new s3.Bucket(this, 'PreviewBucket', {
bucketName: `pr-${pr}-${cdk.Aws.ACCOUNT_ID}-${cdk.Aws.REGION}`.toLowerCase(),
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
// Tag all resources in this stack for audits/cleanup
Tags.of(this).add('managed-by', 'cdk');
Tags.of(this).add('preview', 'true');
Tags.of(this).add('pr', pr);
if (repo) Tags.of(this).add('repo', String(repo));
if (sha) Tags.of(this).add('sha', String(sha));
if (run) Tags.of(this).add('run-id', String(run));
Tags.of(bucket).add('resource', 'preview-bucket');
new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
}
}
bin/preview.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Stack } from '../lib/stack';
const app = new cdk.App();
const pr = app.node.tryGetContext('pr');
const id = pr ? `Stack-PR${pr}` : 'Stack';
new Stack(app, id, {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
Workflows #
Create the two GitHub Actions workflows in the same repository that holds first-steps.
.github/workflows/infra-preview.yml
name: PR Preview Infra
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
id-token: write # for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install deps
run: |
npm ci
npm i -g aws-cdk@2
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ vars.AWS_REGION }}
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
role-session-name: pr-${{ github.event.pull_request.number }}
audience: sts.amazonaws.com
- name: Resolve CDK env
run: |
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
echo "CDK_DEFAULT_ACCOUNT=$ACCOUNT_ID" >> $GITHUB_ENV
echo "CDK_DEFAULT_REGION=${{ vars.AWS_REGION }}" >> $GITHUB_ENV
- name: Deploy preview stack
run: |
# bin/preview.ts synthesizes only one stack for a given PR
# so we can omit the stack name and keep this workflow generic
cdk deploy \
-c pr=${PR_NUMBER} \
-c acct=${ACCOUNT_ID} \
-c reg=${{ vars.AWS_REGION }} \
-c repo=${GITHUB_REPOSITORY} \
-c sha=${GITHUB_SHA} \
-c run=${GITHUB_RUN_ID} \
--app "npx ts-node bin/preview.ts" \
--require-approval never
.github/workflows/infra-destroy.yml
name: PR Preview Teardown
on:
pull_request:
types: [closed]
permissions:
id-token: write
contents: read
jobs:
destroy:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install deps
run: |
npm ci
npm i -g aws-cdk@2
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ vars.AWS_REGION }}
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
role-session-name: pr-${{ github.event.pull_request.number }}-destroy
audience: sts.amazonaws.com
- name: Resolve CDK env
run: |
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
echo "CDK_DEFAULT_ACCOUNT=$ACCOUNT_ID" >> $GITHUB_ENV
echo "CDK_DEFAULT_REGION=${{ vars.AWS_REGION }}" >> $GITHUB_ENV
- name: Destroy preview stack (no hardcoded name)
run: |
cdk destroy \
-c pr=${PR_NUMBER} \
-c acct=${ACCOUNT_ID} \
-c reg=${{ vars.AWS_REGION }} \
-c repo=${GITHUB_REPOSITORY} \
-c sha=${GITHUB_SHA} \
-c run=${GITHUB_RUN_ID} \
--app "npx ts-node bin/preview.ts" \
-f
Repository variables and environments #
In your GitHub repository, add repository variables (Settings → Actions → Variables):
AWS_REGION(e.g.,us-east-1)AWS_ROLE_ARN(theRoleArnoutput from the stack)AWS_ACTOR(your GitHub username)
Create two environments:
aws-preview(requires your approval) — used by the deploy jobaws-teardown(no approval) — used by the destroy job
Notes and hardening #
- Role permissions: the GitHub role uses
PowerUserAccess. Service creation happens through the CDK CloudFormation execution role from bootstrap. Keep the exec role broad for simplicity now; reduce later as you add services. - Trust model: tokens are limited to your repo’s environments (
aws-previewandaws-teardown). Once stable, optionally addjob_workflow_refto pin the exact workflow file/branch. - Workflow guards: jobs skip forks, require repo variables, and restrict to your username via
AWS_ACTOR. Deploy requires environment approval; destroy does not. - Repository variables: use repository‑level variables (
AWS_ROLE_ARN,AWS_REGION,AWS_ACTOR). Environment variables are fine to override, but keep repo variables so job conditions can evaluate. - CDK env: the workflow exports
CDK_DEFAULT_ACCOUNTandCDK_DEFAULT_REGIONand passes-c acct/-c regso bucket names are concrete (no tokenized names). - Bootstrap: run
cdk bootstrapfor each profile/account/region you deploy from. “SSM parameter … not found” means you’re using an un‑bootstrapped profile/region. - Governance: protect
.github/workflows/**(and the OIDC trust stack) with CODEOWNERS + branch protection so changes require your review.
What you should see #
- Opening a PR triggers the “PR Preview Infra” workflow. It assumes the OIDC role and deploys
Stack-PR<number>into the same account/region you bootstrapped. - Closing/merging the PR triggers the teardown workflow, which destroys the stack and deletes the bucket (thanks to
autoDeleteObjects+RemovalPolicy.DESTROY).
Verify with AWS CLI:
# Replace with your PR number and region
PR=123
REGION=us-east-1
STACK=Stack-PR${PR}
# 1 Check CloudFormation stack status
aws cloudformation describe-stacks \
--stack-name "$STACK" \
--region "$REGION" \
--query 'Stacks[0].StackStatus' \
--output text
# 2 Get the bucket name from stack resources
BUCKET=$(aws cloudformation list-stack-resources \
--stack-name "$STACK" \
--region "$REGION" \
--query "StackResourceSummaries[?ResourceType=='AWS::S3::Bucket'].PhysicalResourceId | [0]" \
--output text)
echo "Bucket: $BUCKET"
# 3 Confirm the bucket exists
aws s3api head-bucket --bucket "$BUCKET" --region "$REGION" && echo OK
# 4 Inspect tags (should include pr, repo, sha, run-id)
aws s3api get-bucket-tagging --bucket "$BUCKET" --region "$REGION"
# 5 Verify security settings
aws s3api get-public-access-block --bucket "$BUCKET" --region "$REGION"
aws s3api get-bucket-encryption --bucket "$BUCKET" --region "$REGION"
# After closing the PR (teardown finished), both should fail/not exist
aws cloudformation describe-stacks --stack-name "$STACK" --region "$REGION"
aws s3api head-bucket --bucket "$BUCKET" --region "$REGION"
Well‑Architected Framework #
- Security: eliminate long‑lived credentials by using GitHub OIDC to assume an AWS role; keep the role least‑privilege over time; S3 buckets are private, encrypted, and destroyed on teardown; per‑PR isolation limits blast radius.
- Operational Excellence: infrastructure and CI are code (CDK + Actions); preview environments are created/destroyed automatically; workflows are small, observable, and repeatable; a single entrypoint (
bin/preview.ts) simplifies operations. - Reliability: CloudFormation state and CDK plans provide idempotent deployments and safe rollbacks; deterministic stack/bucket naming by PR number avoids collisions; automated teardown reduces drift and orphaned resources.
- Cost Optimization: ephemeral per‑PR infrastructure keeps spend near zero outside reviews; buckets self‑delete (including objects); pair with the optional Budget stack from 402 for alerts.
- Performance Efficiency: S3 is a minimal “hello world” resource with very fast deploys; you can parallelize workflow jobs across PRs and regions if needed; keep install layers lean (
npm ci, no global caches). - Sustainability: short‑lived environments reduce idle resources and waste; automated cleanup prevents forgotten artifacts; the same patterns can be extended to on‑demand test stacks only when required.