Production-Ready Deployment Project
Deploy a production-ready serverless application using AWS CDK and Terraform
Introduction
Welcome to the Infrastructure Deployment Capstone Project! In this hands-on project, you'll deploy a production-ready serverless application on AWS that combines batch processing and event-driven architectures. You'll learn how to use Infrastructure as Code (IaC) to provision, configure, and manage cloud resources in a repeatable, version-controlled manner. This project demonstrates real-world deployment patterns used by engineering teams to ship reliable, scalable applications to production.
Project Overview
You'll build and deploy a Data Processing Pipeline that consists of two AWS components working together to handle different types of workloads:
1. ECS Fargate Task (Batch Processing)
Purpose: Processes large datasets on a scheduled basis (e.g., nightly data aggregation, report generation)
- Runs as a containerized task on AWS ECS Fargate
- Triggered by EventBridge (CloudWatch Events) on a schedule
- Reads data from S3, processes it, and writes results back
- Auto-scales based on workload requirements
- Publishes completion events to SNS for notifications
2. Lambda Function (Event-Driven Processing)
Purpose: Processes individual records in real-time as they arrive (e.g., user uploads, API requests)
- Triggered by messages in an SQS queue
- Processes one record at a time with automatic retries
- Validates, transforms, and enriches incoming data
- Stores processed results in DynamoDB
- Dead Letter Queue (DLQ) for failed messages
┌─────────────────────────────────────────────────┐
│ AWS Cloud Environment │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────┐ ┌──────────────────────────────┐
│ BATCH PROCESSING PIPELINE │ │ EVENT-DRIVEN PIPELINE │
└─────────────────────────────────┘ └──────────────────────────────┘
[ EventBridge Rule ] [ S3 Upload / API Gateway ]
│ (cron) │
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ ECS Fargate │ │ SQS Queue │
│ Task │─────────┐ └────────┬────────┘
└──────┬───────┘ │ │
│ │ │ (trigger)
│ ▼ ▼
│ ┌────────────┐ ┌──────────────┐
│ │ SNS Topic │ │ Lambda │
│ │(Notif.) │ │ Function │
│ └────────────┘ └──────┬───────┘
│ │
▼ ▼
┌────────────┐ ┌───────────────┐
│ S3 Bucket │ │ DynamoDB │
│ (Data) │ │ Table │
└────────────┘ └───────────────┘
│ │
│ │
└────────── CloudWatch Logs ───────────────┘
(Monitoring)What You'll Learn
Infrastructure as Code (IaC)
Define your entire infrastructure using code with AWS CDK and Terraform
Serverless Architecture
Deploy scalable applications without managing servers using Lambda and ECS Fargate
Event-Driven Patterns
Implement queue-based processing with SQS, Lambda triggers, and DLQ patterns
Container Orchestration
Package and deploy applications using Docker containers on ECS
CI/CD Integration
Automate deployments with GitHub Actions and AWS service integrations
Monitoring & Observability
Set up CloudWatch metrics, logs, and alarms for production monitoring
Prerequisites
Required Tools & Accounts
Development Tools
- Node.js 18+ (for AWS CDK)
- Python 3.11+ (for Lambda function)
- Docker Desktop (for container builds)
- AWS CLI v2 configured
- Git for version control
Cloud Accounts
- AWS Account with admin access
- AWS credentials configured locally
- GitHub account (for CI/CD)
- Basic understanding of AWS services
Expected Project Structure
Before diving into the implementation, here's what your final project structure will look like. Understanding this organization will help you navigate through the steps ahead.
data-pipeline-cdk/
├── app.py # CDK app entry point
├── requirements.txt # Python dependencies for CDK
├── requirements-dev.txt # Development dependencies
├── README.md # Project documentation
├── cdk.json # CDK configuration
├── source.bat # Environment activation script
│
├── infrastructure/ # CDK Stack Definitions
│ ├── __init__.py
│ ├── batch_processing_stack.py # ECS Fargate batch processing
│ └── event_driven_stack.py # Lambda + SQS event processing
│
├── project/ # Application Code
│ ├── __init__.py
│ │
│ ├── lambdas/ # Lambda Functions
│ │ ├── __init__.py
│ │ └── lambda_one/ # Event-driven processor
│ │ ├── __init__.py
│ │ └── handler.py # Lambda handler code
│ │
│ └── tasks/ # ECS Tasks
│ ├── __init__.py
│ └── task_one/ # Batch processor
│ ├── __init__.py
│ ├── main.py # Python batch processing script
│ ├── Dockerfile # Container definition
│ └── requirements.txt # Task dependencies (boto3)
│
└── tests/ # Unit Tests
├── __init__.py
└── unit/
└── __init__.pyKey Directories Explained
infrastructure/
Contains CDK stack definitions that define your AWS resources. Each stack file represents a logical grouping of related infrastructure components.
project/lambdas/
Houses all Lambda function code. Each subdirectory represents a separate Lambda function with its handler and dependencies.
project/tasks/
Contains ECS task definitions and Docker containers. Each task has its own Dockerfile, application code, and dependencies.
app.py
The main CDK application file that instantiates and synthesizes your stacks. This is the entry point for CDK commands like deploy and destroy.
Part 1: Deployment with AWS CDK
AWS Cloud Development Kit (CDK) lets you define cloud infrastructure using familiar programming languages. We'll use Python to define our entire stack, which CDK will synthesize into CloudFormation templates.
Step 1: Initialize CDK Project
mkdir data-pipeline-cdk && cd data-pipeline-cdknpm install -g aws-cdkcdk init app --language pythonsource .venv/bin/activatepip install --upgrade pippip install -r requirements.txtrm -R data_pipeline_cdkmkdir infrastructureStep 2: Define Lambda + SQS Stack
Create infrastructure/event_driven_stack.py to define the event-driven processing pipeline:
from aws_cdk import (
Stack,
Duration,
RemovalPolicy,
CfnOutput,
aws_lambda as lambda_,
aws_sqs as sqs,
aws_dynamodb as dynamodb,
aws_lambda_event_sources as lambda_event_sources,
)
from constructs import Construct
class EventDrivenStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# DynamoDB table for processed data
table = dynamodb.Table(
self, "ProcessedDataTable",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING
),
sort_key=dynamodb.Attribute(
name="timestamp",
type=dynamodb.AttributeType.NUMBER
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY, # For demo only
)
# Dead Letter Queue
dlq = sqs.Queue(
self, "ProcessingDLQ",
queue_name="data-processing-dlq",
retention_period=Duration.days(14),
)
# Main processing queue
queue = sqs.Queue(
self, "ProcessingQueue",
queue_name="data-processing-queue",
visibility_timeout=Duration.seconds(300),
dead_letter_queue=sqs.DeadLetterQueue(
queue=dlq,
max_receive_count=3
),
)
# Lambda function
processor = lambda_.Function(
self, "DataProcessor",
runtime=lambda_.Runtime.PYTHON_3_11,
handler="handler.handler",
code=lambda_.Code.from_asset("project/lambdas/lambda_one"),
environment={
"TABLE_NAME": table.table_name,
},
timeout=Duration.seconds(60),
memory_size=512,
)
# Grant permissions
table.grant_write_data(processor)
# Connect SQS trigger to Lambda
processor.add_event_source(
lambda_event_sources.SqsEventSource(
queue,
batch_size=10,
report_batch_item_failures=True,
)
)
# Outputs
CfnOutput(self, "QueueURL", value=queue.queue_url)
CfnOutput(self, "TableName", value=table.table_name)Key CDK Patterns Explained
- Dead Letter Queue (DLQ): Failed messages after 3 retries go to the DLQ for debugging
- Event Source Mapping: Automatically polls SQS and invokes Lambda with batches of messages
- Batch Item Failures: Enables partial batch responses - only failed items are retried
- IAM Permissions:
grantWriteData()automatically creates least-privilege IAM policies
Step 3: Lambda Function Implementation
Create project/lambdas/lambda_one/handler.py with the processing logic:
import json
import os
import time
import boto3
from datetime import datetime, timezone
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
def handler(event, context):
"""
Process messages from SQS queue and store in DynamoDB.
Implements partial batch failure handling.
"""
failed_records = []
for record in event['Records']:
try:
# Parse message body
body = json.loads(record['body'])
# Validate required fields
if 'id' not in body or 'data' not in body:
raise ValueError("Missing required fields: id or data")
# Process and enrich data
item = {
'id': body['id'],
'timestamp': int(time.time()),
'data': body['data'],
'processed_at': datetime.now(timezone.utc).isoformat(),
'status': 'processed'
}
# Store in DynamoDB
table.put_item(Item=item)
print(f"Successfully processed record: {body['id']}")
except Exception as e:
print(f"Failed to process record: {str(e)}")
# Add to failed records for retry
failed_records.append({
'itemIdentifier': record['messageId']
})
# Return batch item failures for SQS to retry
return {
'batchItemFailures': failed_records
}Step 4: Define ECS Batch Processing Stack
Create infrastructure/batch_processing_stack.py for the scheduled batch job:
from aws_cdk import (
Stack,
Aws,
RemovalPolicy,
CfnOutput,
aws_ecs as ecs,
aws_ec2 as ec2,
aws_s3 as s3,
aws_sns as sns,
aws_events as events,
aws_events_targets as targets,
)
from constructs import Construct
class BatchProcessingStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# VPC for ECS tasks
vpc = ec2.Vpc(
self, "BatchVPC",
max_azs=2,
nat_gateways=0, # Use VPC endpoints to save costs
enable_dns_hostnames=True, # Required for VPC endpoints
enable_dns_support=True, # Required for VPC endpoints
)
# Security group for VPC endpoints
vpc_endpoint_sg = ec2.SecurityGroup(
self, "VPCEndpointSecurityGroup",
vpc=vpc,
description="Security group for VPC endpoints",
allow_all_outbound=True,
)
# Allow HTTPS traffic from within the VPC to VPC endpoints
vpc_endpoint_sg.add_ingress_rule(
peer=ec2.Peer.ipv4(vpc.vpc_cidr_block),
connection=ec2.Port.tcp(443),
description="Allow HTTPS from VPC",
)
# VPC Endpoints for ECR (required for ECS tasks to pull images)
vpc.add_interface_endpoint(
"EcrDockerEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
security_groups=[vpc_endpoint_sg],
private_dns_enabled=True,
)
vpc.add_interface_endpoint(
"EcrApiEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.ECR,
security_groups=[vpc_endpoint_sg],
private_dns_enabled=True,
)
vpc.add_interface_endpoint(
"CloudWatchLogsEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
security_groups=[vpc_endpoint_sg],
private_dns_enabled=True,
)
# SNS endpoint for task notifications
vpc.add_interface_endpoint(
"SnsEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.SNS,
security_groups=[vpc_endpoint_sg],
private_dns_enabled=True,
)
# S3 Gateway endpoint (required for ECR to pull Docker layers from S3)
vpc.add_gateway_endpoint(
"S3Endpoint",
service=ec2.GatewayVpcEndpointAwsService.S3,
)
# S3 bucket for data
data_bucket = s3.Bucket(
self, "DataBucket",
bucket_name=f"batch-data-{Aws.ACCOUNT_ID}",
versioned=False,
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
)
# SNS topic for notifications
notification_topic = sns.Topic(
self, "BatchNotifications",
display_name="Batch Processing Notifications",
)
# Security group for ECS tasks
task_security_group = ec2.SecurityGroup(
self, "TaskSecurityGroup",
vpc=vpc,
description="Security group for ECS tasks",
allow_all_outbound=True,
)
# ECS cluster
cluster = ecs.Cluster(
self, "BatchCluster",
vpc=vpc,
cluster_name="batch-processing-cluster",
)
# Task definition
task_definition = ecs.FargateTaskDefinition(
self, "BatchTask",
memory_limit_mib=2048,
cpu=1024,
)
# Container definition
task_definition.add_container(
"BatchProcessor",
image=ecs.ContainerImage.from_asset("./project/tasks/task_one"),
logging=ecs.LogDrivers.aws_logs(stream_prefix="batch-processor"),
environment={
"BUCKET_NAME": data_bucket.bucket_name,
"SNS_TOPIC_ARN": notification_topic.topic_arn,
},
)
# Grant S3 and SNS permissions
data_bucket.grant_read_write(task_definition.task_role)
notification_topic.grant_publish(task_definition.task_role)
# EventBridge rule to trigger daily at 2 AM UTC
rule = events.Rule(
self, "DailyBatchRule",
schedule=events.Schedule.cron(
minute="0",
hour="2",
week_day="*",
),
)
# Add ECS task as target
# Use isolated subnets with VPC endpoints (no NAT gateway needed)
rule.add_target(
targets.EcsTask(
cluster=cluster,
task_definition=task_definition,
subnet_selection=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
),
security_groups=[task_security_group],
)
)
# Outputs
CfnOutput(self, "BucketName", value=data_bucket.bucket_name)
CfnOutput(self, "TopicArn", value=notification_topic.topic_arn)
Step 5: Batch Processor Implementation
Create project/tasks/task_one/main.py for the batch processing logic:
import os
import json
import boto3
from datetime import datetime, timedelta, timezone
s3 = boto3.client('s3')
sns = boto3.client('sns')
BUCKET_NAME = os.environ['BUCKET_NAME']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
def process_batch():
"""
Batch processing job that runs daily.
Aggregates previous day's data from S3.
"""
print("Starting batch processing job...")
try:
# Calculate yesterday's date for partitioning
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
prefix = f"raw-data/{yesterday}/"
# List all files from yesterday
response = s3.list_objects_v2(
Bucket=BUCKET_NAME,
Prefix=prefix
)
if 'Contents' not in response:
print(f"No data found for {yesterday}")
return
# Process each file
total_records = 0
for obj in response['Contents']:
file_key = obj['Key']
# Download and parse file
file_obj = s3.get_object(Bucket=BUCKET_NAME, Key=file_key)
data = json.loads(file_obj['Body'].read())
# Aggregate logic here
total_records += len(data)
print(f"Processed {file_key}: {len(data)} records")
# Generate summary report
summary = {
'date': yesterday,
'total_records': total_records,
'processed_at': datetime.now(timezone.utc).isoformat(),
'status': 'success'
}
# Save summary to S3
summary_key = f"reports/{yesterday}/summary.json"
s3.put_object(
Bucket=BUCKET_NAME,
Key=summary_key,
Body=json.dumps(summary),
ContentType='application/json'
)
# Send notification
sns.publish(
TopicArn=SNS_TOPIC_ARN,
Subject=f"Batch Job Completed: {yesterday}",
Message=json.dumps(summary, indent=2)
)
print(f"Batch processing completed successfully: {total_records} records")
except Exception as e:
error_msg = f"Batch processing failed: {str(e)}"
print(error_msg)
# Send failure notification
sns.publish(
TopicArn=SNS_TOPIC_ARN,
Subject="Batch Job Failed",
Message=error_msg
)
raise
if __name__ == '__main__':
process_batch()Create project/tasks/task_one/Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]Create project/tasks/task_one/requirements.txt:
boto3>=1.28.0
botocore>=1.31.0Step 6: Deploy to AWS
Before deploying, we need to update app.py to include both stacks we've created. Replace the content of app.py with the following:
import os
import aws_cdk as cdk
from infrastructure.batch_processing_stack import BatchProcessingStack
from infrastructure.event_driven_stack import EventDrivenStack
app = cdk.App()
env = cdk.Environment(
account=os.getenv("CDK_DEFAULT_ACCOUNT"),
region=os.getenv("CDK_DEFAULT_REGION"))
batch_stack = BatchProcessingStack(
scope=app,
construct_id="BatchProcessingStack",
env=env,
)
event_stack = EventDrivenStack(
scope=app,
construct_id="EventDrivenStack",
env=env,
)
app.synth()Understanding app.py
- Environment Configuration: Uses environment variables to set AWS account and region
- Stack Instantiation: Creates both BatchProcessingStack and EventDrivenStack with the same environment
- Synthesize: The
app.synth()command generates CloudFormation templates from your CDK code
Now you're ready to deploy both stacks to AWS:
cdk bootstrapcdk synthcdk deploy --allBonus: Simplify Your CDK with Production-Ready Constructs
Level Up Your CDK Skills with Reusable Constructs
While you've learned to build CDK infrastructure from scratch, professional teams use pre-built, battle-tested constructs to speed up development and enforce best practices. The ByteCode Solutions Core AWS CDK library provides production-ready components that handle common infrastructure patterns with built-in security and operational best practices.
Core AWS CDK Library Features
The core-aws-cdk library provides common elements and constructs to create infrastructure in AWS using AWS CDK with Python, including:
Base Stacks
Pre-configured CDK stacks with automatic tagging and organization
Lambda Functions
Simplified Lambda creation with automatic packaging and dependencies
S3 Buckets
S3 bucket creation with built-in security best practices and encryption
SQS Queues
Queue creation with automatic dead-letter queue configuration
SNS Topics
Topic creation with subscription management and filtering
Network Stack
VPC and networking resource management with best practices
ZIP Asset Packaging
Automatic Lambda ZIP creation with dependencies and caching
How This Could Simplify Your Project
Instead of manually defining every CDK construct, you could use the library to:
- Leverage pre-configured base stacks with automatic tagging and resource organization
- Use the Lambda construct with automatic ZIP packaging instead of manual asset handling
- Create SQS queues with built-in DLQ patterns in fewer lines of code
- Deploy SNS topics with standardized subscription management
- Follow security best practices automatically enforced by the library
Step 7: Testing Your Deployment
Test Event-Driven Pipeline (Lambda + SQS)
Important: Ensure you use the resource names exactly as they were created by CloudFormation!
Send Test Message to SQS
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/ACCOUNT/data-processing-queue \
--message-body '{"id": "test-001", "data": "Hello from SQS!"}'Expected Output:
{
"MD5OfMessageBody": "979b14da0634d5f4b19c4ab55828f2d2",
"MessageId": "b45d02eb-3fdc-4450-9dcd-80439bdf2466"
}Check Lambda Logs
aws logs tail /aws/lambda/EventDrivenStack-DataProcessor --followExpected Output:
2024-01-22T17:14:06.005000+00:00/[$LATEST]586f05e33e INIT_START Runtime Version: python:3.11.v109 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:49f73325b4590c
2024-01-22T17:14:06/[$LATEST]586f05e33e START RequestId: b3bb3e97-d58f-5b07-9d0a-5f7b889be30f Version: $LATEST
2024-01-22T17:14:06/[$LATEST]586f05e33e Successfully processed record: test-001
2024-01-22T17:14:06/[$LATEST]586f05e33e END RequestId: b3bb3e97-d58f-5b07-9d0a-5f7b889be30f
2024-01-22T17:14:06/[$LATEST]586f05e33e REPORT RequestId: b3bb3e97-d58f-5b07-9d0a-5f7b889be30f Duration: 55.99 ms Billed Duration: 579 ms Memory Size: 512 MB Max Memory Used: 86 MB Init Duration: 522.64 msLook for the "Successfully processed record: test-001" message confirming successful processing.
Query DynamoDB to Verify Processing
aws dynamodb scan --table-name ProcessedDataTableExpected Output:
{
"Items": [
{
"id": {
"S": "test-001"
},
"processed_at": {
"S": "2026-01-22T17:14:06.532243"
},
"data": {
"S": "Hello from SQS!"
},
"status": {
"S": "processed"
},
"timestamp": {
"N": "1769102046"
}
}
],
"Count": 1,
"ScannedCount": 1,
"ConsumedCapacity": null
}Your test message should appear in the DynamoDB table with status "processed" and the correct data.
Test Batch Processing (ECS)
Upload Test Data to S3
# Create test data file
echo '[{"id": 1, "value": "test"}]' > test-data.json
# Upload to S3 (replace ACCOUNT with your account ID, and adjust date to be your yesterday)
aws s3 cp test-data.json s3://batch-data-ACCOUNT/raw-data/2024-01-21/data.jsonManually Trigger ECS Task
Don't wait for the scheduled execution, trigger the task manually for immediate testing, and use the appropriate subnet and security group:
aws ecs run-task \
--cluster batch-processing-cluster \
--task-definition BatchProcessingStack-BatchTask \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-***],securityGroups=[sg-***]}"Check ECS Task Logs
aws logs tail /ecs/batch-processor --followExpected Output:
2024-01-22T18:15:07 batch-processor/BatchProcessor/8a317aeb015 Starting batch processing job...
2024-01-22T18:15:07 batch-processor/BatchProcessor/8a317aeb015 Processed raw-data/2024-01-21/data.json: 1 records
2024-01-22T18:15:07 batch-processor/BatchProcessor/8a317aeb015 Batch processing completed successfully: 1 recordsLook for the "Batch processing completed successfully" message confirming the ECS task processed the data and generated the summary report.
Part 2: Bonus - Deployment with Terraform
Why Terraform?
While AWS CDK is excellent for AWS-specific infrastructure, Terraform is a cloud-agnostic IaC tool that works across AWS, Azure, GCP, and 1000+ providers. It uses HCL (HashiCorp Configuration Language) and maintains infrastructure state to enable safe updates and drift detection.
✅ Terraform Advantages
- Multi-cloud support
- Large ecosystem & modules
- State management & planning
- Industry standard
✅ AWS CDK Advantages
- Use familiar languages (Python/TypeScript)
- Better IDE support & type safety
- AWS service integration
- Higher-level abstractions
Terraform Deployment Commands
terraform initterraform planterraform applyterraform destroyBonus Challenge: Deploy with Production-Ready Terraform Modules
Advanced Exercise: Use Reusable Terraform Modules
Ready to take your Terraform skills to the next level? Instead of writing infrastructure code from scratch, try using production-ready, reusable Terraform modules from the ByteCode Solutions Core Terraform library. This is how real engineering teams build scalable infrastructure!
Core Terraform Modules Repository
The core-terraform repository provides a collection of battle-tested, modular infrastructure components that follow HashiCorp and industry best practices:
ECS Cluster Module
Provisions ECS clusters with Fargate/EC2 support, auto-scaling, and IAM roles
Lambda Function Module
Deploys Lambda functions with runtime config, IAM, and logging best practices
Lambda Builder Module
Build pipeline for packaging Lambda code with dependencies and caching
Folder Hash Module
Generate hashes to trigger updates when source files change
Your Challenge
Try replicating the same infrastructure using these production-ready modules:
- Use the Lambda Function and Lambda Builder modules for the event-driven pipeline
- Use the ECS Cluster module for the batch processing infrastructure
- Reference the module documentation for usage examples and input variables
- Compare the module approach with raw Terraform resource definitions
CDK vs Terraform: Quick Comparison
| Feature | AWS CDK | Terraform |
|---|---|---|
| Language | Python, TypeScript, Java, C#, Go | HCL (declarative) |
| Cloud Support | AWS only | Multi-cloud (AWS, Azure, GCP, etc.) |
| State Management | CloudFormation handles state | Terraform state file (local or remote) |
| Learning Curve | Moderate (need to know programming) | Easy (simple declarative syntax) |
| Type Safety | Yes (type hints with Python/TypeScript) | Limited |
| Ecosystem | AWS-focused constructs | Massive provider ecosystem |
Part 3: Automated Deployment with GitLab CI/CD
Why CI/CD for Infrastructure?
Manual deployments from your local machine work for learning, but production teams use CI/CD pipelines to automate infrastructure deployment. This ensures consistency, enables team collaboration, and provides audit trails for all infrastructure changes.
✅ Consistency
Same deployment process every time, regardless of who triggers it
✅ Security
AWS credentials stay in CI/CD, never on developer machines
✅ Auditability
Complete history of who deployed what and when
Step 1: Create Infrastructure Dockerfile
Create infrastructure/Dockerfile to containerize your CDK deployment environment:
FROM alpine:3.18.5
LABEL authors="Your Name <your.email@example.com>"
LABEL version="1.0"
ARG CI_JOB_TOKEN
ARG CDK_DEFAULT_ACCOUNT
ARG CDK_DEFAULT_REGION
ARG AWS_ACCESS_KEY_ID
ARG AWS_DEFAULT_REGION=us-east-1
ARG AWS_SECRET_ACCESS_KEY
ENV APP_NAME=data-pipeline-cdk
ENV VIRTUAL_ENV=/home/$APP_USER/.venv
# Install dependencies
RUN apk update && \
apk add --update npm curl git openssh gcc python3 py3-pip docker-cli && \
npm i -g aws-cdk && \
rm -rf /var/cache/apk/*
# Configure SSH for GitLab
RUN mkdir -p ~/.ssh/ && \
touch ~/.ssh/known_hosts && \
chmod 644 ~/.ssh/known_hosts && \
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Create working directory
RUN mkdir /$APP_NAME && \
chmod g+rwx /$APP_NAME
WORKDIR /$APP_NAME
# Copy project files
COPY infrastructure ./infrastructure
COPY project ./project
COPY app.py ./
COPY cdk.json ./
COPY cdk.context.json ./
# Install Python dependencies
RUN echo "machine gitlab.com" > ~/.netrc && \
echo "login gitlab-ci-token" >> ~/.netrc && \
echo "password ${CI_JOB_TOKEN}" >> ~/.netrc && \
pip install --upgrade pip && \
pip install -r infrastructure/requirements.txt && \
rm ~/.netrc
# Verify installations
RUN echo "pip version: $(pip --version)" && \
echo "AWS CDK version: $(cdk --version)"Step 2: Create Infrastructure Requirements
Create infrastructure/requirements.txt for the CDK dependencies needed in the container:
aws-cdk-lib>=2.0.0
constructs>=10.0.0Step 3: Configure GitLab CI/CD Pipeline
Create .gitlab-ci.yml in your project root to define the CI/CD pipeline:
image: docker:20.10.21-dind
services:
- docker:20.10.21-dind
variables:
APP_NAME: data-pipeline-cdk
CI_JOB_TOKEN: "${ACCESS_TOKEN}"
TEMPLATE_REGISTRY_HOST: "registry.gitlab.com"
DOCKER_HOST: tcp://docker:2375
stages:
- deploy
# Deploy infrastructure on main branch
deploy:
stage: deploy
environment:
name: $CI_COMMIT_BRANCH
on_stop: stop_deploy
variables:
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}"
AWS_DEFAULT_REGION: "${AWS_DEFAULT_REGION}"
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}"
CDK_DEFAULT_ACCOUNT: "${CDK_DEFAULT_ACCOUNT}"
CDK_DEFAULT_REGION: "${CDK_DEFAULT_REGION}"
CI_JOB_TOKEN: "${ACCESS_TOKEN}"
script:
# Build the infrastructure container
- docker build -t $APP_NAME-infrastructure --build-arg CI_JOB_TOKEN=${CI_JOB_TOKEN} -f infrastructure/Dockerfile .
# Synthesize CloudFormation templates
- docker run --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} --env AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} --env CDK_DEFAULT_ACCOUNT=${CDK_DEFAULT_ACCOUNT} --env CDK_DEFAULT_REGION=${CDK_DEFAULT_REGION} $APP_NAME-infrastructure cdk synth
# Deploy all stacks
- docker run --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} --env AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} --env CDK_DEFAULT_ACCOUNT=${CDK_DEFAULT_ACCOUNT} --env CDK_DEFAULT_REGION=${CDK_DEFAULT_REGION} $APP_NAME-infrastructure cdk deploy --all --require-approval never
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Destroy infrastructure (manual trigger only)
stop_deploy:
stage: deploy
environment:
name: $CI_COMMIT_BRANCH
action: stop
script:
- docker build -t $APP_NAME-infrastructure --build-arg CI_JOB_TOKEN=${CI_JOB_TOKEN} -f infrastructure/Dockerfile .
- docker run --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} --env AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} --env CDK_DEFAULT_ACCOUNT=${CDK_DEFAULT_ACCOUNT} --env CDK_DEFAULT_REGION=${CDK_DEFAULT_REGION} $APP_NAME-infrastructure cdk destroy --all --force
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manualPipeline Breakdown
- Docker-in-Docker: Uses Docker inside GitLab CI to build and run your infrastructure container
- deploy job: Automatically triggers on pushes to main branch, builds container, and deploys infrastructure
- stop_deploy job: Manual-only job to destroy infrastructure when needed
- Environment variables: AWS credentials and configuration passed from GitLab CI/CD variables
Step 4: Configure GitLab CI/CD Variables
In your GitLab project, go to Settings → CI/CD → Variables and add the following variables:
| Variable Name | Description | Masked |
|---|---|---|
AWS_ACCESS_KEY_ID | AWS IAM access key for deployment | Yes |
AWS_SECRET_ACCESS_KEY | AWS IAM secret access key | Yes |
AWS_DEFAULT_REGION | AWS region (e.g., us-east-1) | No |
CDK_DEFAULT_ACCOUNT | Your AWS account ID | No |
CDK_DEFAULT_REGION | CDK deployment region | No |
ACCESS_TOKEN | GitLab access token (if using private dependencies) | Yes |
Step 5: Trigger Automated Deployment
Once everything is configured, push your code to the main branch:
git add .
git commit -m "Add CDK infrastructure with GitLab CI/CD"
git push origin mainGitLab CI/CD will automatically:
- Build the infrastructure Docker container
- Run
cdk synthto generate CloudFormation templates - Run
cdk deploy --allto provision AWS resources
Benefits of CI/CD Infrastructure Deployment
For Teams
- Consistent deployments across all team members
- No "works on my machine" issues
- Easy rollbacks by reverting git commits
- Collaboration through merge requests
For Production
- Audit trail of all infrastructure changes
- Secrets managed centrally in CI/CD
- Automated testing before deployment
- Environment parity (dev, staging, prod)
Production Monitoring & Best Practices
CloudWatch Monitoring
- Lambda invocations, errors, duration
- SQS queue depth & age of oldest message
- ECS task CPU/memory utilization
- DynamoDB read/write capacity
- Custom metrics for business logic
Alerting Strategy
- Lambda error rate > 5%
- DLQ message count > 0
- ECS task failures
- Queue age > 5 minutes
- S3 batch job completion status
Production Best Practices
Security
- Use IAM roles, never hardcode credentials
- Enable encryption at rest (S3, DynamoDB)
- Use VPC endpoints for private communication
- Implement least privilege IAM policies
- Enable CloudTrail for audit logging
Reliability
- Use Dead Letter Queues for failed messages
- Implement exponential backoff retries
- Set appropriate Lambda timeouts
- Enable X-Ray for distributed tracing
- Tag all resources for cost tracking
Cleaning Up Resources
CDK Cleanup
cdk destroy --allTerraform Cleanup
terraform destroyKey Takeaways
- Infrastructure as Code enables version-controlled, repeatable deployments
- AWS CDK uses programming languages for type-safe infrastructure definitions
- Terraform provides cloud-agnostic IaC with a large ecosystem
- Serverless architecture reduces operational overhead for batch and event-driven workloads
- ECS Fargate is ideal for containerized batch jobs that run on schedules
- Lambda + SQS provides scalable, reliable event-driven processing
- Dead Letter Queues are essential for handling failures in production
- Monitoring and alerting with CloudWatch ensures system reliability
- IAM roles and policies implement security through least privilege access
- Always tag resources and clean up unused infrastructure to control costs
Additional Resources
Another Practical Guide: Lambda with Custom Dependencies
Practical Guide: Deploying Lambda Functions with Terraform
For a more focused, step-by-step walkthrough of deploying Lambda functions written in Python with custom dependencies (not included in AWS Lambda Runtime), check out this detailed Medium article. This guide demonstrates a simpler deployment scenario using Terraform as Infrastructure as Code (IaC), perfect for understanding the fundamentals before tackling complex multi-service architectures.
What You'll Learn:
- Packaging Python Lambda functions with external dependencies
- Creating Lambda deployment packages with libraries not in AWS Runtime
- Using Terraform to deploy Lambda functions to AWS
- Managing IAM roles and permissions for Lambda