AWS Builder Center

AWS: Zero to Hero — Week 9: CI/CD & Infrastructure as Code — CloudFormation, CDK, and CodePipeline

Week 9 of learning AWS from zero, with Claude as my research partner. Infrastructure as Code explained: CloudFormation stacks, change sets, rollback and drift, CDK constructs, and how CodePipeline, CodeBuild and CodeDeploy differ. Hands-on: break a stack on purpose, write CDK in TypeScript, and build a GitHub-to-CloudFormation pipeline that blocks a bad commit.

Series: AWS: Zero to Hero (9 articles)

  1. …
  2. 9
    AWS: Zero to Hero — Week 9: CI/CD & Infrastructure as Code — CloudFormation, CDK, and CodePipeline This article
For eight weeks, almost everything in this series has been built the same way: typing commands into a terminal, one at a time, and hoping I remember what I typed. That works for a lab. It does not work for a team, or for anything you'd want to rebuild next month, or for the moment someone asks "what exactly is running in our account right now?" This week is about the fix: describing infrastructure as code, and letting a pipeline apply it.

The Deep Dive

Infrastructure as Code: the idea

Infrastructure as Code (IaC) means your infrastructure is described in files — checked into version control, reviewed like any other code, and applied by a tool rather than by hand. The practical payoffs are concrete: you can rebuild an entire environment from scratch, diff what changed between two versions, roll back a bad change, and stand up an identical copy for testing without anyone remembering which buttons were clicked.
Most IaC tools are declarative: you describe the end state you want ("a table and a bucket should exist"), and the tool works out what to create, change, or delete to get there. That's a different mental model from the scripts we've been running, which were imperative — a sequence of steps, which fails halfway in confusing ways and has no memory of what it already did.

CloudFormation: AWS's native IaC service

CloudFormation takes a template (YAML or JSON) and turns it into a stack — a managed group of resources that's created, updated, and deleted as one unit. A few mechanics are worth understanding properly, because they're what separates it from "a script that makes resources":
  • Change sets — before applying an update, CloudFormation can show you exactly which resources will be added, modified, or replaced. The word "replaced" matters: some property changes force CloudFormation to delete and recreate a resource, which for a database is the kind of thing you want to see before it happens.
  • Automatic rollback — if any resource in an update fails, CloudFormation reverts the whole stack to its last good state instead of leaving you with half an environment. We'll trigger this on purpose in the lab.
  • Drift detection — if someone changes a resource by hand, outside CloudFormation, the stack no longer matches its template. Drift detection finds those differences. Hand-edits to managed resources are one of the most common ways IaC setups quietly rot.
  • Cost — CloudFormation itself is free when you're managing standard AWS resources; you pay only for the resources it creates.

CDK: write infrastructure in a real programming language

Writing large YAML templates by hand gets tedious and repetitive fast. The AWS CDK (Cloud Development Kit) lets you define the same infrastructure in TypeScript, Python, Java, and other languages, then synthesizes it into a CloudFormation template that CloudFormation actually deploys. CDK isn't a replacement for CloudFormation — it's a nicer way to author it, with loops, functions, and type checking in place of copy-pasted YAML.
CDK's building blocks are called constructs, at three levels: L1 constructs map one-to-one onto raw CloudFormation resources, L2 constructs add sensible defaults and convenience methods, and L3 constructs bundle several resources into a whole pattern. The most useful thing about L2 constructs is what they do with IAM — a single line like granting a function access to a table generates a narrowly scoped policy for you, which is Week 3's least-privilege lesson applied automatically instead of by hand.
An honest note on alternatives: Terraform is the widely used multi-cloud IaC tool, and plenty of teams choose it specifically so their infrastructure code isn't tied to AWS. Neither choice is "the real one." CloudFormation and CDK are the AWS-native path and what AWS's own certifications test, which is why they're the ones covered here.

CI/CD: the pipeline around the code

Continuous Integration (CI) means every change gets automatically built and tested the moment it's pushed, so problems surface in minutes rather than at release time. Continuous Delivery/Deployment (CD) means changes that pass those checks flow automatically toward production. AWS splits this across three services that are easy to confuse, and worth keeping straight:
  • CodePipeline — the orchestrator. It defines the stages (source, build, deploy) and moves artifacts between them. It does none of the actual building itself.
  • CodeBuild — the build engine. It runs your build and test commands in a managed container, driven by a buildspec.yml file.
  • CodeDeploy — the deployment engine, for pushing application code onto EC2, Lambda, or ECS targets. (Our lab deploys through CloudFormation instead, so we won't need it.)
One current detail that changes what you can actually do as a beginner: CodeCommit, AWS's own Git hosting service, is no longer available to new customers. Existing customers can keep using it, but a brand-new account can't create repositories. In practice that means your source stage connects to GitHub (or Bitbucket, or GitLab) through a connection — and it's worth knowing that many teams skip CodePipeline entirely and use GitHub Actions to deploy to AWS instead. This lab sticks with CodePipeline because the goal is learning the AWS-native pieces.

Cost Reality Check for This Week

This is one of the safest weeks in the series. CloudFormation and CDK are free; the resources the labs create (a DynamoDB table in on-demand mode and S3 buckets) cost essentially nothing at this scale. CodePipeline (V2, now the default) bills per action execution minute, and CodeBuild bills per build minute, both with small monthly free allowances — a handful of test runs is a rounding error, but check the current pricing pages if you plan to run many. Two things to know: cdk bootstrap leaves a small set of supporting resources in your account (an S3 bucket and some IAM roles) that persist after you delete your own stacks — negligible cost, and you'll reuse them. And the pipeline's artifact bucket is yours to empty and delete at the end.

Hands-On, Part 1: A CloudFormation stack, update, rollback, and drift

Step 1 — Write a template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
mkdir zth-iac && cd zth-iac

cat > template.yaml <<'YAML'
AWSTemplateFormatVersion: '2010-09-09'
Description: Zero to Hero week 9 - first CloudFormation stack
Parameters:
Env:
Type: String
Default: dev
AllowedValues: [dev, prod]
Resources:
NotesTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: NoteId
AttributeType: S
KeySchema:
- AttributeName: NoteId
KeyType: HASH
Tags:
- Key: Env
Value: !Ref Env
ArtifactBucket:
Type: AWS::S3::Bucket
Outputs:
TableName:
Value: !Ref NotesTable
BucketName:
Value: !Ref ArtifactBucket
YAML
No bucket name is set on purpose — CloudFormation generates a unique one, which sidesteps S3's global-uniqueness problem.

Step 2 — Validate and deploy

1
2
3
4
5
6
7
8
aws cloudformation validate-template --template-body file://template.yaml

aws cloudformation deploy \
--template-file template.yaml \
--stack-name zth-basics

aws cloudformation describe-stacks --stack-name zth-basics \
--query 'Stacks[0].Outputs'

Step 3 — Preview a change before applying it

Edit the template to change Default: dev to Default: prod, then ask for a change set without executing it:
1
2
3
4
aws cloudformation deploy \
--template-file template.yaml \
--stack-name zth-basics \
--no-execute-changeset
Open the change set in the CloudFormation console (or aws cloudformation describe-change-set) and read what it says will happen — in this case, a tag modification on the table. This is the habit worth building: read the plan before you apply it. Then execute it for real:
1
2
3
aws cloudformation deploy \
--template-file template.yaml \
--stack-name zth-basics

Step 4 — Break it on purpose and watch the rollback

Add this resource to the template. S3 bucket names can't contain uppercase letters or underscores, so this will fail at creation:
1
2
3
4
BrokenBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: Invalid_Bucket_Name
1
2
3
aws cloudformation deploy \
--template-file template.yaml \
--stack-name zth-basics
The deploy fails. Now check what actually happened to the stack:
1
2
aws cloudformation describe-stacks --stack-name zth-basics \
--query 'Stacks[0].StackStatus'
It should read UPDATE_ROLLBACK_COMPLETE — the stack reverted to its last good state, and your table and original bucket are untouched. Remove the broken resource from the template before continuing.

Step 5 — Cause drift by hand, then detect it

Change the bucket outside CloudFormation, then ask CloudFormation to notice:
1
2
3
4
5
6
7
8
9
10
11
12
BUCKET=$(aws cloudformation describe-stacks --stack-name zth-basics \
--query 'Stacks[0].Outputs[?OutputKey==`BucketName`].OutputValue' --output text)

aws s3api put-bucket-versioning --bucket $BUCKET \
--versioning-configuration Status=Enabled

DRIFT_ID=$(aws cloudformation detect-stack-drift --stack-name zth-basics \
--query 'StackDriftDetectionId' --output text)

sleep 20
aws cloudformation describe-stack-resource-drifts --stack-name zth-basics \
--stack-resource-drift-status-filters MODIFIED
CloudFormation should flag the bucket as modified and show the versioning property that no longer matches the template. That's the "someone changed it by hand" problem, caught mechanically instead of by luck.

Hands-On, Part 2: The same idea in CDK (TypeScript)

You'll need Node.js installed.

Step 1 — Install, scaffold, and bootstrap

1
2
3
4
5
6
npm install -g aws-cdk

mkdir zth-cdk && cd zth-cdk
cdk init app --language typescript

cdk bootstrap
cdk bootstrap is a one-time setup per account and region — it creates the supporting S3 bucket and IAM roles CDK deploys through. This is the part that leaves resources behind; that's expected.

Step 2 — Define a stack

Replace the contents of lib/zth-cdk-stack.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';

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

const table = new dynamodb.Table(this, 'Items', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});

const fn = new lambda.Function(this, 'Handler', {
runtime: lambda.Runtime.PYTHON_3_13,
handler: 'index.handler',
code: lambda.Code.fromInline(
'def handler(event, context):\n return {"statusCode": 200, "body": "hello from CDK"}'
),
environment: { TABLE_NAME: table.tableName },
});

table.grantReadWriteData(fn);
}
}

Step 3 — Synthesize, diff, deploy

1
2
3
cdk synth # prints the CloudFormation template CDK generated
cdk diff # shows what would change, like a change set
cdk deploy
Look at the output of cdk synth and compare it to the roughly twenty lines of TypeScript you wrote. Find the IAM policy in it — you never wrote one. That's the single grantReadWriteData call generating a policy scoped to that one table for that one function.

Step 4 — Destroy it

1
cdk destroy

Hands-On, Part 3: A real pipeline — GitHub, CodeBuild, CloudFormation

This pipeline lints a CloudFormation template on every push, and only deploys it if the lint passes. Pushing a bad template should stop the pipeline before anything is deployed.

Step 1 — A GitHub repository

Create a new GitHub repository (I'll call it zth-iac-demo) and put two files at its root. First, template.yaml — reuse the template from Part 1, without the broken resource. Second, buildspec.yml:
1
2
3
4
5
6
7
8
9
10
11
version: 0.2
phases:
install:
commands:
- pip install cfn-lint
build:
commands:
- cfn-lint template.yaml
artifacts:
files:
- template.yaml

Step 2 — Connect AWS to GitHub

1
2
3
4
CONN_ARN=$(aws codeconnections create-connection \
--provider-type GitHub --connection-name zth-github \
--query 'ConnectionArn' --output text)
echo $CONN_ARN
The connection starts out PENDING, and this is the one step that can't be done from the CLI: open the Developer Tools → Settings → Connections page in the AWS console, select the connection, and choose Update pending connection to authorize the GitHub app. (If your CLI version doesn't have codeconnections yet, the older aws codestar-connections command is the same thing.)

Step 3 — Three service roles

The lab uses broad managed policies to keep this short, scoped to what the template actually creates. Anything real should be tighter — Week 3's least-privilege lesson still applies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
make_role () { # usage: make_role <role-name> <service-principal>
cat > trust-$1.json <<JSON
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"$2"},"Action":"sts:AssumeRole"}]}
JSON

aws iam create-role --role-name $1 --assume-role-policy-document file://trust-$1.json >/dev/null
}

make_role ZthCodeBuildRole codebuild.amazonaws.com
aws iam attach-role-policy --role-name ZthCodeBuildRole --policy-arn arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
aws iam attach-role-policy --role-name ZthCodeBuildRole --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

make_role ZthCfnDeployRole cloudformation.amazonaws.com
aws iam attach-role-policy --role-name ZthCfnDeployRole --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
aws iam attach-role-policy --role-name ZthCfnDeployRole --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

make_role ZthPipelineRole codepipeline.amazonaws.com

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
ARTIFACT_BUCKET=zth-pipeline-artifacts-$ACCOUNT_ID
aws s3 mb s3://$ARTIFACT_BUCKET

CB_ROLE_ARN=$(aws iam get-role --role-name ZthCodeBuildRole --query 'Role.Arn' --output text)
CFN_ROLE_ARN=$(aws iam get-role --role-name ZthCfnDeployRole --query 'Role.Arn' --output text)
PIPE_ROLE_ARN=$(aws iam get-role --role-name ZthPipelineRole --query 'Role.Arn' --output text)

cat > pipeline-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow",
"Action": ["s3:GetObject","s3:GetObjectVersion","s3:PutObject","s3:GetBucketVersioning"],
"Resource": ["arn:aws:s3:::$ARTIFACT_BUCKET","arn:aws:s3:::$ARTIFACT_BUCKET/*"]},
{"Effect": "Allow",
"Action": ["codebuild:StartBuild","codebuild:BatchGetBuilds"],
"Resource": "*"},
{"Effect": "Allow",
"Action": ["cloudformation:CreateStack","cloudformation:UpdateStack","cloudformation:DescribeStacks",
"cloudformation:CreateChangeSet","cloudformation:ExecuteChangeSet",
"cloudformation:DescribeChangeSet","cloudformation:DeleteChangeSet"],
"Resource": "*"},
{"Effect": "Allow", "Action": "iam:PassRole", "Resource": "$CFN_ROLE_ARN"},
{"Effect": "Allow",
"Action": ["codeconnections:UseConnection","codestar-connections:UseConnection"],
"Resource": "$CONN_ARN"}
]
}
JSON

aws iam put-role-policy --role-name ZthPipelineRole \
--policy-name zth-pipeline-inline --policy-document file://pipeline-policy.json

sleep 10 # let IAM propagate

Step 4 — The CodeBuild project

1
2
3
4
5
6
aws codebuild create-project \
--name zero-to-hero-build \
--source type=CODEPIPELINE \
--artifacts type=CODEPIPELINE \
--environment type=LINUX_CONTAINER,image=aws/codebuild/standard:7.0,computeType=BUILD_GENERAL1_SMALL \
--service-role $CB_ROLE_ARN
(If that image tag has been retired by the time you run this, pick the current standard image from the CodeBuild console.)

Step 5 — The pipeline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
cat > pipeline.json <<JSON
{
"pipeline": {
"name": "zero-to-hero-pipeline",
"roleArn": "$PIPE_ROLE_ARN",
"artifactStore": {"type": "S3", "location": "$ARTIFACT_BUCKET"},
"stages": [
{"name": "Source", "actions": [{
"name": "GitHubSource",
"actionTypeId": {"category": "Source", "owner": "AWS", "provider": "CodeStarSourceConnection", "version": "1"},
"configuration": {"ConnectionArn": "$CONN_ARN", "FullRepositoryId": "<your-github-username>/zth-iac-demo", "BranchName": "main"},
"outputArtifacts": [{"name": "SourceOutput"}]
}]},
{"name": "Build", "actions": [{
"name": "LintTemplate",
"actionTypeId": {"category": "Build", "owner": "AWS", "provider": "CodeBuild", "version": "1"},
"configuration": {"ProjectName": "zero-to-hero-build"},
"inputArtifacts": [{"name": "SourceOutput"}],
"outputArtifacts": [{"name": "BuildOutput"}]
}]},
{"name": "Deploy", "actions": [{
"name": "DeployStack",
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "CloudFormation", "version": "1"},
"configuration": {
"ActionMode": "CREATE_UPDATE",
"StackName": "zth-pipeline-stack",
"TemplatePath": "BuildOutput::template.yaml",
"RoleArn": "$CFN_ROLE_ARN"
},
"inputArtifacts": [{"name": "BuildOutput"}]
}]}
]
}
}
JSON


aws codepipeline create-pipeline --cli-input-json file://pipeline.json
Replace <your-github-username> with your own. The pipeline starts a run as soon as it's created.

Step 6 — Watch it deploy, then break it with a bad commit

1
2
aws codepipeline get-pipeline-state --name zero-to-hero-pipeline \
--query 'stageStates[].[stageName,latestExecution.status]'
Once all three stages show Succeeded, confirm the stack exists:
1
2
aws cloudformation describe-stacks --stack-name zth-pipeline-stack \
--query 'Stacks[0].StackStatus'
Now push a bad commit — change BillingMode: PAY_PER_REQUEST to BillingMode: PAY_PER_BANANA in template.yaml, commit, and push. Check the pipeline again after a minute or two:
1
2
aws codepipeline get-pipeline-state --name zero-to-hero-pipeline \
--query 'stageStates[].[stageName,latestExecution.status]'
The Build stage should show Failed and the Deploy stage should never have run — the bad template was caught by CI before it ever reached CloudFormation. Fix the line, push again, and watch it go green. That loop — a broken change stopped automatically, a fixed one deployed automatically — is the entire point of CI/CD.

Cleanup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Pipeline, build project, and the stacks it created
aws codepipeline delete-pipeline --name zero-to-hero-pipeline
aws codebuild delete-project --name zero-to-hero-build
aws cloudformation delete-stack --stack-name zth-pipeline-stack
aws cloudformation delete-stack --stack-name zth-basics

# GitHub connection
aws codeconnections delete-connection --connection-arn $CONN_ARN

# Artifact bucket (must be emptied first)
aws s3 rm s3://$ARTIFACT_BUCKET --recursive
aws s3 rb s3://$ARTIFACT_BUCKET

# IAM roles
aws iam delete-role-policy --role-name ZthPipelineRole --policy-name zth-pipeline-inline
aws iam delete-role --role-name ZthPipelineRole
aws iam detach-role-policy --role-name ZthCodeBuildRole --policy-arn arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
aws iam detach-role-policy --role-name ZthCodeBuildRole --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
aws iam delete-role --role-name ZthCodeBuildRole
aws iam detach-role-policy --role-name ZthCfnDeployRole --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
aws iam detach-role-policy --role-name ZthCfnDeployRole --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
aws iam delete-role --role-name ZthCfnDeployRole
The cdk destroy from Part 2 already removed the CDK stack. The CDKToolkit stack that cdk bootstrap created can stay — you'll reuse it for any future CDK work — or you can delete it from the CloudFormation console if you'd rather have a completely clean account.

My Take

This week made me realize that managing infrastructure isn't just about creating resources, it's about making the entire process repeatable, reliable, and automated. CloudFormation and CDK simplify infrastructure management, while CodePipeline and CodeBuild help catch errors before they reach deployment. The biggest takeaway for me is that good infrastructure should be version-controlled, reproducible, and automated rather than dependent on manual commands.

Next week — Part 10: Observability — CloudWatch, X-Ray, and CloudTrail, and how you actually find out what your infrastructure is doing (and who changed it).

Series: AWS: Zero to Hero (9 articles)

  1. …
  2. 9
    AWS: Zero to Hero — Week 9: CI/CD & Infrastructure as Code — CloudFormation, CDK, and CodePipeline This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article