CI/CD

Preview infra per PR (S3)

Publish at:

Goal #

We’ll 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

At a glance, the flow looks like this:

PR opened/updated                 PR closed/merged
        |                                 |
        v                                 v
+-------------------+           +-------------------+
| GitHub Actions    |           | GitHub Actions    |
| deploy workflow   |           | destroy workflow  |
+-------------------+           +-------------------+
        |                                 |
        +-----------+  OIDC  +------------+
                    v        v
              +------------------------+
              | AWS IAM role           |
              | GitHub deploy role     |
              +------------------------+
                         |
                         v
              +------------------------+
              | CDK app                |
              | Stack-PR<number>       |
              +------------------------+
                         |
                         v
              +------------------------+
              | S3 preview bucket      |
              | private, per PR        |
              +------------------------+

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 one trust stack, one preview stack, 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 so GitHub can act as a deployer without storing credentials.

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 trust policy above is scoped to GitHub environments named aws-preview and aws-teardown. We will create those environments below; if you keep environment-scoped trust, make sure your real workflow jobs run in those environments.

The per‑PR preview stack (S3 bucket) #

Create a tiny stack that names resources with the PR number and cleans up on destroy.

S3 is deliberate here: it is cheap, fast to provision, easy to verify, and enough to prove the CI/CD shape before we add a user-facing site in the next chapter.

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

Those environment names matter because they are part of the OIDC trust boundary shown earlier.

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)