Optimized GitHub Flow for developing Serverless CDK apps

Photo by 想 李 on Unsplash

Optimized GitHub Flow for developing Serverless CDK apps

·

6 min read

Stop Waiting on Other Developers – Ship Features Faster!

When working in a team, you want a fast, reliable feedback loop without blocking others. I often had to wait my testing of a serverless application because others were still testing their own features on the ‘test’ environment. But there is a solution! The key? Deployment stages (sometimes called ephemeral environments). With deployment stages, each feature can be tested in isolation, preventing conflicts and delays.

Much has been written about deployment stages and ephemeral environments in AWS, but in short, they allow you to:

  • Deploy feature branches independently.

  • Test in isolation before merging.

  • Maintain production stability while iterating quickly.

What is a Deployment Stage?

A stage is an environment where your application runs with a complete copy of the infrastructure. In CloudFormation terms, its own stack. Since with serverless you only pay for what you use, you incur minimal extra cost for this. Each stage mimics production but is independent, making debugging and validation easier before changes reach production.

Implementing Deployment Stages in AWS CDK

How do you implement deployment stages using AWS CDK? Here’s a simple approach:

  1. Define a CDK Stack – This is your base infrastructure.

  2. Instantiate Stacks Per Stage – Deploy separate instances for feature, dev, and prod environments. The feature stage will differ for each developer working on a different feature.

  3. Parameterize Deployments – Use configs to ensure each stage has the right settings.

  4. Automate with CI/CD – Deploy stages using GitHub Actions.

a detailed reference implementation can be found at the bottom.


CDK Implementation

1. Define a CDK Stack (stack.ts)

Define your AWS resources.

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

export interface MyStackProps extends cdk.StackProps {
  stageName: string;
  removalPolicy: cdk.RemovalPolicy;
  autoDeleteObjects: boolean;
}

export class MyStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: MyStackProps) {
    super(scope, `${props.stageName}-stack`, { ...props, stackName: `${props.stageName}-stack` });

    // Ensure bucket name is lowercase and truncate stage name to 10 characters
    const bucketName = `${props.stageName.substring(0, 10).toLowerCase()}-my-bucket`;

    new s3.Bucket(this, 'MyBucket', {
      bucketName,
      removalPolicy: props.removalPolicy,
      autoDeleteObjects: props.autoDeleteObjects,
    });
  }
}

2. Instantiate Stacks Per Stage (app.ts)

Deploy separate instances for feature, dev, and prod environments. The feature stage will differ for each developer working on a different feature.

import * as cdk from 'aws-cdk-lib';
import { MyStack } from './stack';
import { config } from './config';

const app = new cdk.App();

// The most important part: each developer gets their own independent infra.
// featureStageName is dynamically set, ensuring that two developers working on different
// features have isolated stacks and resources, avoiding conflicts.
const rawFeatureStageName = app.node.tryGetContext('stageName') || 'feature';
const featureStageName = rawFeatureStageName.replace(/[^a-zA-Z0-9-]/g, '-').substring(0, 10);

new MyStack(app, `FeatureStack`, {
  stageName: featureStageName,
  env: { account: config.feature.account, region: config.feature.region },
  removalPolicy: config.feature.removalPolicy,
  autoDeleteObjects: config.feature.autoDeleteObjects,
});

new MyStack(app, `DevStack`, {
  stageName: 'dev',
  env: { account: config.dev.account, region: config.dev.region },
  removalPolicy: config.dev.removalPolicy,
  autoDeleteObjects: config.dev.autoDeleteObjects,
});

new MyStack(app, `ProdStack`, {
  stageName: 'prod',
  env: { account: config.prod.account, region: config.prod.region },
  removalPolicy: config.prod.removalPolicy,
  autoDeleteObjects: config.prod.autoDeleteObjects,
});

app.synth();

3. Parameterize Deployments (config.ts)

Ensure each stage has the correct settings.

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

export const config = {
  feature: {
    account: '111111111111',
    region: 'eu-central-1',
    removalPolicy: cdk.RemovalPolicy.DESTROY,
    autoDeleteObjects: true,
  },
  dev: {
    account: '222222222222',
    region: 'eu-central-1',
    removalPolicy: cdk.RemovalPolicy.RETAIN,
    autoDeleteObjects: false,
  },
  prod: {
    account: '333333333333',
    region: 'eu-central-1',
    removalPolicy: cdk.RemovalPolicy.RETAIN,
    autoDeleteObjects: false,
  },
};

4. Automate with CI/CD (feature-deployment.yml and feature-destroy.yml)

GitHub Actions workflows to deploy and clean up feature branches.

Deployment (feature-deployment.yml)

name: Feature Deployment

on:
  push:
    branches:
      - 'feature/*'

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: feature
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup node/npm
        uses: actions/setup-node@v4
        with:
          always-auth: true
          node-version: 18.x

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@master
        with:
          role-to-assume: ${{ env.DEPLOYMENT_ROLE_ARN }}
          aws-region: eu-central-1

      - name: Deploy Feature Stack
        run: npm run cdk deploy -- "feat-stack" --require-approval never -c stageName=${{ github.head_ref }}

Cleanup (feature-destroy.yml)

name: Cleanup - Feature Stack

on:
  pull_request:
    branches:
      - main
    types:
      - closed

jobs:
  cleanup_feat:
    if: startsWith(github.head_ref, 'feature/')
    runs-on: ubuntu-latest
    environment:
      name: feature
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Set env var STAGE_NAME
        run: echo "STAGE_NAME=$(echo "${{ github.head_ref }}" | tr '/' '-')" >> $GITHUB_ENV

      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup node/npm
        uses: actions/setup-node@v4
        with:
          always-auth: true
          node-version: 18.x

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@master
        with:
          role-to-assume: ${{ env.DEPLOYMENT_ROLE_ARN }}
          aws-region: eu-central-1

      - name: CDK destroy
        run: npm run cdk destroy -- "feat-stack" --force -c stageName=$STAGE_NAME

Common Mistakes and How to Avoid Them

1. Stack or Resource Name Collisions

Problem: Your stack cannot be deployed twice in the same AWS account without unique names.
Solution: Implement helper functions for stack and resource naming based on stage.

2. Incorrect Automated Resource Naming

Problem: AWS services have different naming conventions (e.g., S3 requires lowercase, SSM prefers path-style).
Solution: Standardize resource names in a helper function per service.

3. Unable to destroy your feature stack

Problem: resources cannot be destroyed, so your feature stage must be manually deleted
Solution: set removal policies to destroy, and deletion protection to ‘off’ for all resources in a feature stage.

4. Unable to set up connections to other systems

Problem: Your serverless app needs connections to other systems.
Solution: This can be challenging, depending on the issue. If possible, set up a dedicated user or reuse resources from the 'dev' stack. For example, if your stack creates a role ARN that should be used for the connection in the other system, reuse the static role from the dev environment instead of creating a new role for your feature stack.

What if I don’t even use feature branches?

Some teams argue for trunk-based development—just push to main and deploy to production continuously. While this works for small teams, enterprise teams often:

  • Work on features for days or weeks.

  • Have multiple features in progress simultaneously.

  • Need feature isolation to avoid production disruptions.

Deployment stages allow teams to iterate safely without blocking each other while still enabling fast releases.

But stages can benefit those without feature branches, too! Having multiple instances of your infrastructure isolated from each other in the same AWS account also allows for better fault-tolerance in multi-tenant architectures. If one set of infrastructure fails, only a part of the customers is impacted.


Conclusion

By implementing CDK deployment stages, you ensure:
Fast, reliable deployments without breaking production.
Independent testing of features before merging.
A scalable approach for teams working on multiple features simultaneously.

This guide outlined the minimum viable setup for CDK deployment stages, but you can extend it further by:

  • Adding automated integration tests before deployment.

  • Using feature flags for dynamic releases.

  • Helper functions for consistent resource naming, stack name generation etc.