CI/CD

Preview infra per PR (S3)

Publish at:

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-steps CDK TypeScript app from the previous article (with DeployRoleStack and optional BudgetStack).
  • You can assume the CdkDeployerRole via your dev profile 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 (the RoleArn output from the stack)
  • AWS_ACTOR (your GitHub username)

Create two environments:

  • aws-preview (requires your approval) — used by the deploy job
  • aws-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-preview and aws-teardown). Once stable, optionally add job_workflow_ref to 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_ACCOUNT and CDK_DEFAULT_REGION and passes -c acct/-c reg so bucket names are concrete (no tokenized names).
  • Bootstrap: run cdk bootstrap for 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.

Source code #

Reference implementation (opens in a new tab)