Deploying software should be boring. Not “boring because nothing works and nobody cares” boring. Boring like a commercial airline flight. Hundreds of safety checks happen automatically, the process is the same every time, and everyone lands safely without thinking about it. When deployment is exciting, it means something isn’t automated enough.

At Kids Learn, pushing code to production takes exactly 37 minutes: 2 minutes to merge a PR, 10 minutes for CodeBuild to run tests and build artifacts, 5 minutes for CDK to synthesize CloudFormation, 15 minutes for deployment to staging and integration tests, and 5 minutes for me to click “approve” and watch the production deployment complete. The entire process is automated except for that one approval click. And the person clicking isn’t making judgement calls about the deployment — they’re confirming that the staging integration tests passed and the monitoring dashboard looks clean.

This is Part 7 of the AWS Full-Stack Mastery series. We’re automating the deployment of everything we’ve built in Parts 2-6.

CI/CD Pipeline — CodePipeline orchestrating CodeBuild, CDK synthesis, and multi-stage deployment

The Pipeline Architecture

GitHub (Source)


CodeBuild (Build + Test)
    │ npm ci, lint, unit tests, build
    │ CDK synth → CloudFormation templates
    │ Docker build → ECR push


Deploy → Dev Stage
    │ CloudFormation deployment
    │ Lambda functions updated
    │ Fargate service updated (blue/green)


Deploy → Staging Stage
    │ Same as Dev, different account


Integration Tests (CodeBuild)
    │ API endpoint tests
    │ Database connectivity
    │ AI service health checks


Manual Approval
    │ Slack notification sent
    │ Tech Lead reviews staging metrics


Deploy → Production
    │ Blue/green deployment
    │ CloudWatch alarms monitored
    │ Automatic rollback on error

CDK Pipelines — The Self-Mutating Pipeline

CDK Pipelines is a remarkable construct. The pipeline deploys itself. When you change the pipeline definition in your CDK code, the next run updates the pipeline before deploying your application. No chicken-and-egg problem.

Complete Pipeline Implementation

// lib/stacks/pipeline-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as pipelines from 'aws-cdk-lib/pipelines';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as chatbot from 'aws-cdk-lib/aws-chatbot';
import { Construct } from 'constructs';
import { ApplicationStage } from '../stages/application-stage';
import { environments } from '../config/environments';

export class PipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // =========================================
    // Notification Topic
    // =========================================
    const pipelineTopic = new sns.Topic(this, 'PipelineTopic', {
      topicName: 'kidslearn-pipeline-notifications',
    });

    pipelineTopic.addSubscription(
      new snsSubscriptions.EmailSubscription('devops@kidslearn.app')
    );

    // =========================================
    // Pipeline Definition
    // =========================================
    const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
      pipelineName: 'KidsLearn-Infrastructure',
      crossAccountKeys: true, // Enable cross-account deployments
      
      synth: new pipelines.ShellStep('Synth', {
        input: pipelines.CodePipelineSource.gitHub(
          'kidslearn/infrastructure',
          'main',
          {
            authentication: cdk.SecretValue.secretsManager('github-token'),
            trigger: pipelines.GitHubTrigger.WEBHOOK,
          }
        ),
        
        commands: [
          'npm ci',
          'npm run lint',
          'npm run test -- --ci',
          'npx cdk synth',
        ],
        
        primaryOutputDirectory: 'cdk.out',
        
        // Build environment
        buildEnvironment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
          privileged: true, // Required for Docker builds
          computeType: codebuild.ComputeType.MEDIUM,
          environmentVariables: {
            CDK_DEV_ACCOUNT: { value: environments.dev.account },
            CDK_STAGING_ACCOUNT: { value: environments.staging.account },
            CDK_PROD_ACCOUNT: { value: environments.production.account },
          },
        },
      }),
      
      // Docker support for Fargate images
      dockerEnabledForSynth: true,
      dockerEnabledForSelfMutation: true,

      // CodeBuild options
      codeBuildDefaults: {
        buildEnvironment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
          computeType: codebuild.ComputeType.MEDIUM,
        },
        timeout: cdk.Duration.minutes(30),
      },
    });

    // =========================================
    // Dev Stage — Auto-deploy
    // =========================================
    pipeline.addStage(new ApplicationStage(this, 'Dev', {
      config: environments.dev,
      env: { account: environments.dev.account, region: environments.dev.region },
    }));

    // =========================================
    // Staging Stage — Deploy + Integration Tests
    // =========================================
    const stagingStage = pipeline.addStage(new ApplicationStage(this, 'Staging', {
      config: environments.staging,
      env: { account: environments.staging.account, region: environments.staging.region },
    }));

    stagingStage.addPost(
      new pipelines.ShellStep('IntegrationTests', {
        commands: [
          'npm ci',
          'npm run test:integration',
        ],
        envFromCfnOutputs: {
          // API URL from the compute stack output
          API_URL: stagingStage.apiUrlOutput,
        },
      })
    );

    // =========================================  
    // Production Stage — Manual Approval + Deploy
    // =========================================
    pipeline.addStage(new ApplicationStage(this, 'Production', {
      config: environments.production,
      env: { account: environments.production.account, region: environments.production.region },
    }), {
      pre: [
        new pipelines.ManualApprovalStep('PromoteToProduction', {
          comment: 'Staging integration tests passed. Review CloudWatch dashboards before approving.',
        }),
      ],
    });
  }
}

CodeBuild — Build and Test Configuration

The buildspec for Application Code

# deployments/buildspec-app.yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 20
    commands:
      - npm ci --cache .npm --prefer-offline

  pre_build:
    commands:
      - echo "Running linting..."
      - npm run lint
      - echo "Running unit tests..."
      - npm run test -- --ci --coverage
      
  build:
    commands:
      - echo "Building application..."
      - npm run build
      
      # Build Docker image for adaptive engine
      - echo "Building Docker image..."
      - cd src/fargate/adaptive-engine
      - docker build -t $ECR_REPO_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION .
      - docker tag $ECR_REPO_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION $ECR_REPO_URI:latest
      
  post_build:
    commands:
      # Push Docker image to ECR
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REPO_URI
      - docker push $ECR_REPO_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION
      - docker push $ECR_REPO_URI:latest
      
      # Generate image definitions for ECS
      - printf '[{"name":"adaptive-engine","imageUri":"%s"}]' $ECR_REPO_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION > imagedefinitions.json

reports:
  unit-test-reports:
    files:
      - 'coverage/clover.xml'
    file-format: CLOVERXML

artifacts:
  files:
    - imagedefinitions.json
    - 'cdk.out/**/*'

cache:
  paths:
    - '.npm/**/*'
    - 'node_modules/**/*'

ECR — Container Registry

ECR Repository with CDK

// lib/constructs/ecr-repository.ts
import * as cdk from 'aws-cdk-lib';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import { Construct } from 'constructs';

export class KidsLearnECR extends Construct {
  public readonly repository: ecr.Repository;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.repository = new ecr.Repository(this, 'AdaptiveEngineRepo', {
      repositoryName: 'kidslearn/adaptive-engine',
      
      // Image scanning on push
      imageScanOnPush: true,
      
      // Lifecycle rules to control costs
      lifecycleRules: [
        {
          description: 'Keep only 10 tagged images',
          tagStatus: ecr.TagStatus.TAGGED,
          tagPrefixList: ['v'],
          maxImageCount: 10,
        },
        {
          description: 'Remove untagged images after 7 days',
          tagStatus: ecr.TagStatus.UNTAGGED,
          maxImageAge: cdk.Duration.days(7),
        },
      ],
      
      // Encryption
      encryption: ecr.RepositoryEncryption.AES_256,
    });
  }
}

Blue/Green Deployments for Fargate

When we deploy a new version of the adaptive engine, we don’t just replace the running containers. We use blue/green deployment:

  1. New tasks launch with the updated container image
  2. Health checks pass on the new tasks
  3. Load balancer shifts traffic from old tasks to new tasks
  4. Old tasks drain existing connections and terminate
  5. If anything fails, traffic shifts back to the old tasks automatically
// In the compute stack — ECS deployment configuration
import * as ecs from 'aws-cdk-lib/aws-ecs';

// Configure rolling deployment with circuit breaker
const service = new ecs.FargateService(this, 'AdaptiveService', {
  cluster,
  taskDefinition,
  desiredCount: 2,
  
  deploymentController: {
    type: ecs.DeploymentControllerType.ECS,
  },
  
  // Rolling update configuration
  minHealthyPercent: 100,  // Keep all old tasks running until new ones are healthy
  maxHealthyPercent: 200,  // Allow double the tasks during deployment
  
  // Circuit breaker — automatic rollback on failure
  circuitBreaker: {
    rollback: true,
  },
  
  // Deployment alarms — roll back if errors spike
  deploymentAlarms: {
    alarmNames: ['HighErrorRate', 'HighLatency'],
    behavior: ecs.AlarmBehavior.ROLLBACK_ON_ALARM,
  },
});

Database Migrations in the Pipeline

We run database migrations as a CodeBuild step after infrastructure deployment:

# deployments/buildspec-migrate.yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 20
    commands:
      - npm ci

  build:
    commands:
      - echo "Running database migrations..."
      - npx ts-node scripts/run-migrations.ts
      - echo "Migrations completed successfully"

  post_build:
    commands:
      - echo "Verifying migration status..."
      - npx ts-node scripts/verify-migrations.ts

The Bottom Line

The CI/CD pipeline transforms deployment from a manual, error-prone process into an automated, auditable workflow:

  • CodePipeline orchestrates the entire flow from Git push to production
  • CodeBuild handles builds, tests, and Docker image creation
  • ECR stores container images with vulnerability scanning
  • CDK Pipelines self-mutates — the pipeline updates itself
  • Blue/green deployments ensure zero-downtime releases
  • Circuit breakers automatically roll back failed deployments

In Part 8, we implement security — IAM least privilege, Cognito authentication with COPPA compliance, WAF, and secrets management.

See you in Part 8.


This is Part 7 of a 10-part series: AWS Full-Stack Mastery for Technical Leads.

Series outline:

  1. Why AWS & Getting Started (Part 1)
  2. Infrastructure as Code (CDK) (Part 2)
  3. Frontend (Amplify + CloudFront) (Part 3)
  4. Backend (API Gateway + Lambda + Fargate) (Part 4)
  5. Database (Aurora + DynamoDB + ElastiCache) (Part 5)
  6. AI/ML (Bedrock + SageMaker) (Part 6)
  7. DevOps (CodePipeline + CodeBuild) (this post)
  8. Security (IAM + Cognito + WAF) (Part 8)
  9. Observability (CloudWatch + X-Ray) (Part 9)
  10. Production (Multi-Region + DR) (Part 10)

References

Export for reading

Comments