TutorialsDec 17, 202514 min read

Build a cost-optimized CI/CD pipeline with CircleCI and AWS CDK

Benito Martin

Data Scientist

As organizations adopt cloud-native development, there is a growing need for scalable, automated, and cost-effective CI/CD pipelines. This tutorial guides you through how to use AWS CDK to define Infrastructure as Code and CircleCI to automate deployments, all while keeping cost optimization a top priority.

Traditional infrastructure management can lead to over-provisioning, forgotten resources, and unexpected bills. By combining Infrastructure as Code (IaC) with automated CI/CD practices, you can create a system that:

  • Scales resources based on actual demand
  • Monitors costs in real-time
  • Cleans up unused resources

You will build an end-to-end workflow that provisions AWS resources, sets up auto-scaling, implements monitoring, and tears down unused infrastructure automatically. This approach ensures you can maintain high availability and performance, while paying only for what you use while .

Prerequisites

Before you begin, you will need:

Once you have these items in place, you can set up your project and implement the serverless workflow.

Setting up the project

What is AWS CDK? The AWS Cloud Development Kit (CDK) lets you define infrastructure using familiar programming languages (e.g., Python). Instead of writing raw CloudFormation templates or clicking through the AWS Console, you get several key advantages:

  • Type safety: Catch errors at compile time rather than deployment time
  • IDE support: Get autocompletion, refactoring, and debugging capabilities
  • Reusability: Create reusable components and share them across projects
  • Version control: Track infrastructure changes alongside application code

CDK compiles your code into CloudFormation templates and deploys them, combining developer productivity with infrastructure best practices. This means you get the reliability of CloudFormation with the expressiveness of modern programming languages.

Set up the environment

Start by cloning the repository and setting up your development environment:

git clone https://github.com/CIRCLECI-GWP/cost-optimized-cicd-circleci-aws-cdk.git
cd cost-optimized-cicd-circleci-aws-cdk

Set up a virtual environment in the cdk folder and install dependencies:

cd cdk
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

If you started the project from scratch with an empty repository, you’ll need to create the cdk folder structure. From the root directory, run:

mkdir cdk
cd cdk
cdk init app --language python --profile <profile-name>

This will create all necessary dependencies, files and folders similar to the structure of the next section.

Set up the project structure

Here is the basic structure of the project if you cloned the repository. Under cdk you can find the AWS CDK Infrastructure. Each of the Stack that will be deployed can be found under infrastructure. The app.py file just imports each of them. To deploy the stack you need to run the corresponding command from the cdk directory where the app.py is located.

├── LICENSE
├── Makefile
├── README.md
├── cdk
│   ├── README.md
│   ├── app.py
│   ├── .env.example
│   ├── infrastructure
│   │   ├── __init__.py
│   │   ├── compute_stack.py
│   │   ├── monitoring_stack.py
│   │   └── vpc_stack.py
│   ├── requirements.txt
└── circleci
    ├── config.yml

If you initialized the project from scratch you would have a different structure under the main cdk folder. Your infrastructure folder, where the Stack is located, will have the name cdk. For this tutorial, I recommend that you change it to infrastructure, like in the cloned version because app.py take imports from that folder. The next sections of the tutorial use code based on that structure, but you are free to adapt it if you’d like. The requirements-dev.txt file and tests folder are not necessary for this project.

.
├── app.py
├── cdk
│   ├── cdk_stack.py
│   └── __init__.py
├── cdk.json
├── README.md
├── requirements-dev.txt
├── requirements.txt
├── source.bat
└── tests
    ├── __init__.py
    └── unit
        ├── __init__.py
        └── test_cdk_stack.py

You also need to add an environment variable into the .env.example file in the main cdk folder. Your email is used to receive cost alerts and notifications from AWS Budgets and SNS topics. Rename the file to .env:

EMAIL=your_email@example.com

Create a CDK stack

You will create three CDK stacks that work together to provide a complete, cost-optimized infrastructure:

  • VPC stack: Defines a custom VPC with public and private subnets for network isolation.
  • Compute stack: Launches EC2 instances using an Auto Scaling Group with intelligent scaling policies.
  • Monitoring stack: Adds cost budgets, billing alarms, and dashboards for financial visibility.

This separation follows the single responsibility principle and makes it easier to manage each component independently.

CDK app entry point

When deploying the infrastructure, AWS CDK uses the app entry point under the app.py file. This file deploys all the stacks in the correct order. The next section of the tutorial has an explanation of each stack.

import aws_cdk as cdk
from infrastructure.compute_stack import ComputeStack
from infrastructure.monitoring_stack import MonitoringStack
from infrastructure.vpc_stack import VpcStack

app = cdk.App()

# Deploy infrastructure stacks
vpc_stack = VpcStack(app, "VpcStack")
compute_stack = ComputeStack(app, "ComputeStack", vpc=vpc_stack.vpc)
monitoring_stack = MonitoringStack(app, "MonitoringStack")

app.synth()

As shown in the previous code sample, the ComputeStack depends on the VPC created by VpcStack. CDK automatically handles the dependency order during deployment, ensuring the VPC is created before the compute resources that depend on it.

VPC stack (infrastructure/vpc_stack.py)

This stack is based on some key design decisions:

  • Limited availability zones: Using only 2 AZs reduces NAT Gateway costs while maintaining high availability.
  • Subnet types: Public subnets for load balancers and bastion hosts, private subnets for application servers.
  • CIDR planning: /16 provides 65,536 IP addresses, allowing for future growth while using standard private IP ranges.

The VPC Stack creates the foundational network infrastructure keeping security and cost optimization top priorities:

from typing import Any

from aws_cdk import (
    Stack,
)
from aws_cdk import (
    aws_ec2 as ec2,
)
from constructs import Construct

class VpcStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Create VPC with public and private subnets
        self.vpc = ec2.Vpc(
            self, "CostOptimizedVPC",
            max_azs=2,
            cidr="10.0.0.0/16",
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="PublicSubnet",
                    subnet_type=ec2.SubnetType.PUBLIC,
                    cidr_mask=24
                ),
                ec2.SubnetConfiguration(
                    name="PrivateSubnet",
                    subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
                    cidr_mask=24
                )
            ]
        )

The VPC creates Internet Gateways, NAT Gateways, and route tables. Limiting AZs to 2 reduces the number of NAT Gateways (which cost ~$35/month each) while still achieving high availability.

Compute stack (infrastructure/compute_stack.py)

The Compute Stack handles all EC2-related resources with built-in auto-scaling and cost optimization. It provides these key cost optimization features:

  • t3.micro instances: Burstable performance instances that provide baseline performance and can burst when needed
  • Security group: The security group allows HTTP (port 80) and SSH (port 22) access.
  • IAM role: The IAM role enables CloudWatch monitoring without hardcoding credentials.
  • Auto scaling: Automatically adjusts capacity based on demand, scaling down during low traffic periods
  • Launch templates: Enables easy updates and version control for instance configurations
  • User data script: The user data script sets up a basic web server and installs stress-ng for load testing, which will be used to verify auto-scaling functionality.
  • Target tracking: Maintains 70% CPU utilization, adding up to 3 instances to optimize the balance between performance and cost
from typing import Any

from aws_cdk import Duration, Stack, Tags
from aws_cdk import aws_autoscaling as autoscaling
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_iam as iam
from constructs import Construct

class ComputeStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, vpc: ec2.Vpc, **kwargs: Any) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Security Group
        security_group = ec2.SecurityGroup(
            self, "WebServerSG",
            vpc=vpc,
            description="Security group for web servers",
            allow_all_outbound=True
        )

        security_group.add_ingress_rule(
            ec2.Peer.any_ipv4(),
            ec2.Port.tcp(80),
            "Allow HTTP traffic"
        )

        security_group.add_ingress_rule(
            ec2.Peer.any_ipv4(),
            ec2.Port.tcp(22),
            "Allow SSH access"
        )

        # IAM Role for EC2 instances
        role = iam.Role(
            self, "EC2Role",
            assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"),
            managed_policies=[
                iam.ManagedPolicy.from_aws_managed_policy_name("CloudWatchAgentServerPolicy")
            ]
        )

        # Launch Template
        launch_template = ec2.LaunchTemplate(
            self, "LaunchTemplate",
            instance_type=ec2.InstanceType.of(
                ec2.InstanceClass.T3,
                ec2.InstanceSize.MICRO
            ),
            machine_image=ec2.AmazonLinuxImage(
                generation=ec2.AmazonLinuxGeneration.AMAZON_LINUX_2
            ),
            security_group=security_group,
            role=role,
            user_data=ec2.UserData.for_linux()
        )

        # Add simple web server setup
        launch_template.user_data.add_commands(
            "yum update -y",
            "yum install -y httpd",
            "systemctl start httpd",
            "systemctl enable httpd",
            "echo '<h1>Cost-Optimized CI/CD Demo</h1>' > /var/www/html/index.html"
            # Add stress-ng installation
            "amazon-linux-extras install epel -y",
            "yum install -y stress-ng",
        )

        # Auto Scaling Group
        self.asg = autoscaling.AutoScalingGroup(
            self, "WebServerASG",
            vpc=vpc,
            launch_template=launch_template,
            min_capacity=1,
            max_capacity=3,
            desired_capacity=1,
            vpc_subnets=ec2.SubnetSelection(
                subnet_type=ec2.SubnetType.PUBLIC
            )
        )

        Tags.of(self.asg).add("Name", "CostOptimizedWebServer")

        # Target Tracking Scaling Policy
        self.asg.scale_on_cpu_utilization(
            "CpuScaling",
            target_utilization_percent=70,
            cooldown=Duration.minutes(5)
        )

Monitoring stack (infrastructure/monitoring_stack.py)

The Monitoring Stack provides comprehensive cost visibility and automated alerting using these monitoring capabilities:

  • SNS topic: Centralized notification system for all cost-related alerts
  • Budget alerts: Proactive notifications at 80% of budget (actual) and 100% (forecasted)
  • Billing alarms: Real-time alerts when charges exceed $30, evaluated every 6 hours
  • Custom dashboard: Visual representation of spending trends and patterns
import os

from aws_cdk import Duration, Stack
from aws_cdk import aws_budgets as budgets
from aws_cdk import aws_cloudwatch as cloudwatch
from aws_cdk import aws_cloudwatch_actions as cw_actions
from aws_cdk import aws_sns as sns
from aws_cdk import aws_sns_subscriptions as sns_subscriptions
from constructs import Construct
from dotenv import load_dotenv

load_dotenv()

class MonitoringStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs: object) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # SNS Topic for alerts
        alert_topic = sns.Topic(
            self, "CostAlertTopic",
            display_name="Cost Optimization Alerts"
        )

        # Subscribe to the topic using an email address from environment variable 
        # Ensure the EMAIL environment variable is set
        # Needs confirmation via email after deployment
        alert_topic.add_subscription(
            sns_subscriptions.EmailSubscription(os.getenv("EMAIL")) 
        )

        # Cost Budget Alert
        budgets.CfnBudget(
            self, "MonthlyBudget",
            budget={
                "budgetName": "Monthly-AWS-Budget",
                "budgetLimit": {
                    "amount": 50,  # $50 monthly limit
                    "unit": "USD"
                },
                "timeUnit": "MONTHLY",
                "budgetType": "COST"
            },
            notifications_with_subscribers=[
                {
                    "notification": {
                        "notificationType": "ACTUAL",
                        "comparisonOperator": "GREATER_THAN",
                        "threshold": 80  # Alert at 80% of budget ($40)
                    },
                    "subscribers": [
                        {
                            "subscriptionType": "EMAIL",
                            "address": os.getenv("EMAIL", "")  # Use environment variable for email
                        }
                    ]
                },
                {
                    "notification": {
                        "notificationType": "FORECASTED",
                        "comparisonOperator": "GREATER_THAN",
                        "threshold": 100  # Alert when forecasted to exceed budget
                    },
                    "subscribers": [
                        {
                            "subscriptionType": "EMAIL",
                            "address": os.getenv("EMAIL", "")  # Use environment variable for email
                        }
                    ]
                }
            ]
        )

        # CloudWatch Billing Alarm
        billing_alarm = cloudwatch.Alarm(
            self, "BillingAlarm",
            metric=cloudwatch.Metric(
                namespace="AWS/Billing",
                metric_name="EstimatedCharges",
                dimensions_map={
                    "Currency": "USD"
                },
                statistic="Maximum",
                period=Duration.hours(6)
            ),
            threshold=30,  # $30 threshold
            evaluation_periods=1,
            alarm_description="Alert when estimated charges exceed $30"
        )

        billing_alarm.add_alarm_action(
            cw_actions.SnsAction(alert_topic)
        )

        # Custom Dashboard
        dashboard = cloudwatch.Dashboard(
            self, "CostDashboard",
            dashboard_name="Cost-Optimization-Dashboard"
        )

        dashboard.add_widgets(
            cloudwatch.GraphWidget(
                title="Estimated Monthly Charges",
                left=[billing_alarm.metric],
                width=12,
                height=6
            )
        )

The monitoring stack uses environment variables for email addresses to avoid hardcoding sensitive information. Make sure you add your email in the .env file. After deployment, you will need to confirm the email subscription using the confirmation email sent by AWS.

Automated cost optimization techniques

This architecture implements several automated cost optimization strategies:

  • Auto scaling based on demand: The Auto Scaling Group automatically adjusts the number of EC2 instances based on CPU utilization. When traffic is low, it scales down to the minimum capacity (1 instance). During high traffic periods, it scales up to a maximum of 3 instances.
  • Target tracking scaling policy: The target tracking policy maintains 70% CPU utilization across the fleet. This ensures optimal resource utilization without over-provisioning. The 5-minute cooldown prevents rapid scaling actions that could cause instability.
  • Burstable performance instances: T3.micro instances provide baseline performance with the ability to burst when needed. This is perfect for workloads with variable performance requirements, as you only pay for the extra performance when you use it.
  • Real-time cost monitoring: CloudWatch billing alarms and AWS Budgets provide multiple layers of cost protection. The system alerts you before costs spiral out of control, allowing for proactive cost management.
  • Infrastructure as Code: By defining infrastructure in code, you can easily tear down environments when not needed and recreate them when required. This eliminates the common problem of “forgotten” resources that continue to accrue charges.

Deploying the application

To deploy all the infrastructure components defined in your AWS CDK project, you will need Bootstrap. Bootstrap prepares your AWS environment (e.g., S3 bucket, IAM roles) for CDK usage, and it is required if you run AWS CDK for the first time. Bootstrap creates a default CDKToolkit AWS CloudFormation stack.

Go to the root of the cdk directory (where your app.py file is). Run this command:

cdk bootstrap --profile <profile-name>

Once the CDKToolkit has been created you can deploy the application stack:

cdk deploy --all --profile <profile-name>

This command synthesizes and deploys all stacks in the correct dependency order. Once this process is complete, you can verify the deployment using the AWS Management Console. This command can be rerun as many times as you want. If the stack already exists, AWS will notify you and will not recreate it. If you have made any modification on the current deployed stack, the stack will be updated.

Stack formation

After deployment, each CDK stack will appear in the AWS CloudFormation console. You can inspect each stack to review its resources and status.

Cloudformation full stack:

Cloudformation

Monitoring stack:

Cloudformation monitoring

VPC stack:

Cloudformation vpc

Compute stack

Cloudformation compute

Monitoring dashboards and alarms

You can access monitoring dashboards and alarms using Amazon CloudWatch. There are three alarms, one for billing and two for the CPU (high and low).

Billing alarm

CPU high

CPU low

The CPU Low alarm shows an “In Alarm” status, indicating that the instances are underutilized. Meanwhile, there is a visible spike above the 70% threshold, which was configured in the compute stack. This spike results from a stress test used to verify whether autoscaling functions correctly.

For the stress test, you’ll run a 5-minute CPU load on 4 cores in the background. Connect to your EC2 instance and execute:

stress-ng --cpu 4 --timeout 300s

EC2

When CPU usage exceeds the threshold, the Auto Scaling Group will launch a second instance. This scaling activity can be observed in the CPU Dashboards and Auto Scaling Group views. After the cooldown period, if CPU usage drops below the target, the additional instance will be terminated.

Autoscaling

In addition to CPU and scaling metrics, the budget and cost monitoring dashboards can be also found under Billing and Cost Management. These tools help ensure your deployed stack remains within financial limits and offers insights into cost optimization.

Cost budget

Cost optimization

Setting up CI/CD with CircleCI

To automate the deployment pipeline using CircleCI, you need to define a CircleCI config file (.config.yml). This pipeline will handle tasks such as installing dependencies, validating your code, configuring AWS resources, and running tests.

Before CircleCI can deploy your application to AWS, you need to configure the required environment variables. These variables secure your AWS credentials and configuration settings. Here’s how to configure them:

  1. Go to your CircleCI dashboard.
  2. Create a new project and link it to your GitHub repository.
  3. Push it to GitHub in advance.
  4. Go to your project settings and add the AWS Credentials and Email environment variables:
  • AWS_ACCESS_KEY_ID: Your AWS access key
  • AWS_SECRET_ACCESS_KEY: Your AWS secret key
  • EMAIL: Email address for cost alerts
  • AWS_DEFAULT_REGION: Your preferred AWS region (e.g., eu-central-1)

![CircleCI env vars]2025-07-04-circleci-env-vars.png

Next, you’ll need to create a pipeline in your project and set up a trigger for that pipeline. If you want to trigger the pipeline on each push to the main branch, you can set up an all pushes event.

Once the environment variables, the pipeline, and the trigger are set up, you can define the CircleCI config file.

CircleCI configuration

The .circleci/config.yml file defines the complete deployment pipeline. Here’s a breakdown:

  • Orbs are pre-built CircleCI packages that provide common functionality. For example node provides Node.js runtime for CDK.

  • Deploy job steps:
    • Checkout retrieves your code from the repository.
    • Install Python installs Python 3, pip, and virtual environment tools.
    • Install AWS CLI v2 Installs the latest AWS CLI to interact with AWS services.
    • Install CDK Installs the AWS Cloud Development Kit (CDK) globally using npm.
    • Set Up Python Environment creates a virtual environment and installs Python dependencies for your CDK app.
    • Verify AWS Credentials confirms that the IAM role or access keys are valid.
    • Bootstrap CDK prepares your AWS environment (e.g., S3 bucket, IAM roles) for CDK usage.
    • Deploy stacks deploys all CDK stacks without requiring manual approval.
    • Output Info provides confirmation and useful logging once deployment is complete.
    • Workflow: The deploy_app workflow triggers the deploy job on every push to the repository.
  • Destroy job steps:

    • Same initial steps as those for the deploy job: checkout, install dependencies, and verify credentials.
    • Destroy CDK stacks executes cdk destroy --all --force to clean up all infrastructure.
    • Output teardown info logs confirmation that the teardown completed.
  • Workflows:

    • deploy_app triggers the deploy job on every push to the repository.
    • scheduled_teardown runs the destroy job every Friday at 15:30 UTC, tearing down deployed infrastructure to help manage cloud resource costs.
version: 2.1

orbs:
  node: circleci/node@7.1.0

jobs:
  deploy:
    docker:
      - image: cimg/node:24.3.0
    steps:
      - checkout

      - run:
          name: Install Python
          command: |
            sudo apt-get update
            sudo apt-get install -y python3 python3-venv python3-pip unzip curl

      - run:
          name: Install AWS CLI v2
          command: |
            curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
            unzip awscliv2.zip
            sudo ./aws/install
            aws --version

      - run:
          name: Install AWS CDK
          command: npm install -g aws-cdk

      - run:
          name: Set up Python environment
          command: |
            cd cdk
            python3 -m venv .venv
            source .venv/bin/activate
            pip install -r requirements.txt

      - run:
          name: Verify AWS credentials
          command: aws sts get-caller-identity

      - run:
          name: Bootstrap CDK
          command: |
            cd cdk
            source .venv/bin/activate
            cdk bootstrap

      - run:
          name: Deploy CDK stacks
          command: |
            cd cdk
            source .venv/bin/activate
            cdk deploy --all --require-approval never

      - run:
          name: Output deployment info
          command: |
            echo "Deployment completed successfully"
            echo "Check AWS Console for deployed resources"

  destroy:
    docker:
      - image: cimg/node:24.3.0
    steps:
      - checkout

      - run:
          name: Install Python
          command: |
            sudo apt-get update
            sudo apt-get install -y python3 python3-venv python3-pip unzip curl

      - run:
          name: Install AWS CLI v2
          command: |
            curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
            unzip awscliv2.zip
            sudo ./aws/install
            aws --version

      - run:
          name: Install node.js and CDK
          command: npm install -g aws-cdk

      - run:
          name: Set up Python environment
          command: |
            cd cdk
            python3 -m venv .venv
            source .venv/bin/activate
            pip install -r requirements.txt

      - run:
          name: Verify AWS credentials
          command: aws sts get-caller-identity

      - run:
          name: Destroy CDK stacks
          command: |
            cd cdk
            source .venv/bin/activate
            cdk destroy --all --force

      - run:
          name: Output teardown info
          command: |
            echo "Teardown completed successfully"
            echo "Resources have been destroyed"

workflows:
  deploy_app:
    jobs:
      - deploy

  # Scheduled destroy job (e.g., Every Friday at 15:30 UTC)
  scheduled_teardown:
    triggers:
      - schedule:
          cron: "30 15 * * 5"  # Every Friday at 15:30 UTC
          filters:
            branches:
              only:
                - main
    jobs:
      - destroy

Once this configuration is committed and pushed to your GitHub repository, CircleCI will kick off the deployment process. You will be able to monitor the steps directly in the CircleCI dashboard. If everything is set up correctly, you will get a green build.

Note: The scheduled teardown job will run every Friday at 15:30 UTC, automatically destroying all resources created by the deployment job. I recommend changing the schedule to a better time for you. You can disable it if you want to keep the resources running longer.

![CircleCI deploy]2025-07-04-circleci-deploy.png

![CircleCI destroy]2025-07-04-circleci-destroy.png

Cleaning up

The time for clean up is when you are done with the demo or want to avoid ongoing charges. If the CircleCI scheduled teardown job hasn’t cleaned everything up, you can do this manually. In the cdk directory, run:

cdk destroy --all --profile <profile-name>

Make sure you cross-check that all your resources have been deleted to avoid unnecessary charges. You can check in the console AWS EC2, Cloudwatch and Budget or delete them from the AWS Console.

Conclusion

This tutorial showed you how to build a cost-optimized CI/CD pipeline that combines the power of Infrastructure as Code with automated deployment and monitoring. The key achievements include:

  • Using AWS CDK to define infrastructure as code: Providing type safety, reusability, and version control for your infrastructure.
  • Leveraging CircleCI to automate deployment and teardown: Ensuring consistent, repeatable deployments while minimizing manual errors.
  • Applying auto-scaling policies to optimize compute resource usage: Automatically adjusting capacity based on demand to minimize costs.
  • Integrating cost monitoring and alerts for financial visibility: Proactive cost management through real-time alerts and budgets.

By implementing these practices, you can significantly reduce your AWS spending while maintaining high availability and performance. The infrastructure scales automatically based on demand, alerts you to cost anomalies, and can be easily reproduced or destroyed as needed. The combination of AWS CDK, CircleCI, and cost optimization techniques provides a robust foundation for cloud-native applications. As your applications grow, this architecture can be extended with additional features like blue-green deployments, canary releases, and multi-region setups.

Remember that cost optimization is an ongoing process. Regularly review your usage patterns, explore new AWS services and pricing models, and continuously refine your architecture based on actual usage data. With these practices in place, you’ll be well-equipped to build and maintain cost-effective cloud infrastructure that scales with your business needs.