Using CloudFormation to Automate Build, Test, and Deploy with CodePipeline
Jenna Pederson
Posted on December 8, 2021
In part 1, we automated the provisioning of an Amazon EC2 instance using AWS CloudFormation. In part 2, we added an Amazon RDS Postgresql database to the CloudFormation template from part 1 so both the EC2 instance and the database can be provisioned together as a set of resources. Today in part 3, we will introduce a continuous integration/continuous deployment (CI/CD) pipeline to automate the build, test, and deploy phases of your release process. To do this, we’ll use AWS CodeDeploy and CodePipeline.
As a reminder, here's what we've covered and where we're going:
- automate the provisioning of your EC2 instance using CloudFormation (part 1),
- add an RDS Postgresql database to your stack with CloudFormation (part 2), and
- create a CodePipeline with CloudFormation (this post, part 3).
Prerequisites
To work through the examples in this post, you’ll need:
- an AWS account (you can create your account here if you don’t already have one),
- the AWS CLI installed (you can find instructions for installing the AWS CLI here), and
- a key-pair to use for SSH (you can create a key-pair following these instructions).
Unfamiliar with CloudFormation or feeling a little rusty? Check out part 1 or my Intro to CloudFormation post before getting started.
Just want the code? Grab it here and then check out the buildspec, appspec, and scripts here.
Prepare EC2 Instance
First, we’ll need to prepare the EC2 instance so that we can deploy our app to it. We’ll create an EC2 instance role and a CodeDeploy trust role, install the CodeDeploy agent, and tag the instance or instance we want to deploy to.
1. Create EC2 Instance Role
In the CloudFormation template that creates your EC2 instance, create the following new resources, InstanceRole
, InstanceRolePolicies
, and InstanceRoleInstanceProfile
:
InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service:
- ec2.amazonaws.com
Action:
- sts:AssumeRole
Path: /
InstanceRolePolicies:
Type: AWS::IAM::Policy
Properties:
PolicyName: InstanceRole
PolicyDocument:
Statement:
- Effect: Allow
Action:
- autoscaling:Describe*
- cloudformation:Describe*
- cloudformation:GetTemplate
- s3:Get*
Resource: '*'
Roles:
- !Ref 'InstanceRole'
InstanceRoleInstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Path: /
Roles:
- !Ref 'InstanceRole'
These resources create an instance profile to pass an IAM role to an EC2 instance. This allows the EC2 instance to do things like get the CodeDeploy agent from S3.
Next, we’ll add the IamInstanceProfile
property to the EC2 instance:
WebAppInstance:
Properties:
...
IamInstanceProfile: !Ref 'InstanceRoleInstanceProfile'
2. Install CodeDeploy Agent
The CodeDeploy agent will be installed on each EC2 instance you want to deploy your app to and helps CodeDeploy communicate with your EC2 instance for deployments. First, you’ll include metadata in the AWS::CloudFormation::Init
key, which will be used by the cfn-init
helper script.
WebAppInstance:
...
Metadata:
AWS::CloudFormation::Init:
services:
sysvint:
codedeploy-agent:
enabled: 'true'
ensureRunning: 'true'
Then, we’ll add the UserData
key, which allows us to pass user data to the EC2 instance to perform automated configuration tasks and run scripts after the instance starts up.
WebAppInstance:
Properties:
...
UserData: !Base64
Fn::Join:
- ''
- - "#!/bin/bash -ex\n"
- "yum update -y aws-cfn-bootstrap\n"
- "yum install -y aws-cli\n"
- "yum install -y ruby\n"
- "iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 3000\n"
- "echo 'iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 3000' >> /etc/rc.local\n"
- "# Helper function.\n"
- "function error_exit\n"
- "{\n"
- ' /opt/aws/bin/cfn-signal -e 1 -r "$1" '''
- !Ref 'WaitHandle'
- "'\n"
- " exit 1\n"
- "}\n"
- "# Install the AWS CodeDeploy Agent.\n"
- "cd /home/ec2-user/\n"
- "aws s3 cp 's3://aws-codedeploy-us-east-1/latest/codedeploy-agent.noarch.rpm'\
\ . || error_exit 'Failed to download AWS CodeDeploy Agent.'\n"
- "yum -y install codedeploy-agent.noarch.rpm || error_exit 'Failed to\
\ install AWS CodeDeploy Agent.' \n"
- '/opt/aws/bin/cfn-init -s '
- !Ref 'AWS::StackId'
- ' -r WebAppInstance --region '
- !Ref 'AWS::Region'
- " || error_exit 'Failed to run cfn-init.'\n"
- "# All is well, so signal success.\n"
- /opt/aws/bin/cfn-signal -e 0 -r "AWS CodeDeploy Agent setup complete."
'
- !Ref 'WaitHandle'
- "'\n"
This installs a few helper packages like the aws-cli
and aws-cfn-bootstrap
, and then installs the CodeDeploy agent (by copying it from S3). The cfn-init
script grabs the metadata we added earlier and ensures those services are enabled and running. The cfn-signal
helper script signals to CloudFormation that the instance had been successfully created or updated.
Finally, add the following two resources that are used in the UserData
we just added so that CloudFormation waits until the UserData
scripts are finished running.
WaitHandle:
Type: AWS::CloudFormation::WaitConditionHandle
WaitCondition:
Type: AWS::CloudFormation::WaitCondition
Properties:
Handle: !Ref 'WaitHandle'
Timeout: '900'
3. Tag the Instances
Next, we need to tag the EC2 instances. CodeDeploy will use these tags to identify which instances to deploy to.
WebAppInstance:
Properties:
...
Tags:
- Key: 'CodeDeployTag'
Value: 'CodeDeployDemo'
4. Create CodeDeploy Trust Role
We also need to add a CodeDeploy trust role so that CodeDeploy has access to work with the EC2 instance. Add the following two resources:
CodeDeployTrustRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Sid: '1'
Effect: Allow
Principal:
Service:
- codedeploy.us-east-1.amazonaws.com
- codedeploy.us-west-2.amazonaws.com
Action: sts:AssumeRole
Path: /
CodeDeployRolePolicies:
Type: AWS::IAM::Policy
Properties:
PolicyName: CodeDeployPolicy
PolicyDocument:
Statement:
- Effect: Allow
Resource:
- '*'
Action:
- ec2:Describe*
- Effect: Allow
Resource:
- '*'
Action:
- autoscaling:CompleteLifecycleAction
- autoscaling:DeleteLifecycleHook
- autoscaling:DescribeLifecycleHooks
- autoscaling:DescribeAutoScalingGroups
- autoscaling:PutLifecycleHook
- autoscaling:RecordLifecycleActionHeartbeat
Roles:
- !Ref 'CodeDeployTrustRole'
This trust role will be used in the next section when we configure the CodePipeline. We’ll pass the ARN of this trust role to that CloudFormation template, so let’s add this as an Output
:
Outputs:
...
CodeDeployTrustRoleARN:
Value: !GetAtt 'CodeDeployTrustRole.Arn'
5. Create the stack
And finally, we’ll need to create the stack:
aws cloudformation create-stack --stack-name CloudFormationEc2Example --template-body file://07_ec2_codedeploy.yaml \
--parameters ParameterKey=AvailabilityZone,ParameterValue=us-east-1a \
ParameterKey=EnvironmentType,ParameterValue=dev \
ParameterKey=KeyPairName,ParameterValue=jenna \
ParameterKey=DBPassword,ParameterValue=Abcd1234 \
--capabilities CAPABILITY_IAM
Or, if you’re updating the stack you created in part 2, you can use the update-stack
command instead.
Once we’ve created the EC2 instance set up for CodeDeploy, we’ll be ready to create the CodePipeline pipeline.
To view the full version of this template, check it out here.
Create CodePipeline pipeline
The pipeline we are building will have three stages:
A Source stage to pull our code from the GitHub repository. The Source stage will use a CodeStarConnection and an S3 bucket.
A Build stage to build the source code into an artifact. The Build stage will use a CodeBuild Project and the same S3 bucket.
A Deploy stage to deploy the artifact to the EC2 instance. The Deploy stage will use a CodeDeploy Application and a CodeDeploy DeploymentGroup.
First, we’ll need an app to deploy.
1. Prepare the App to Deploy
You can fork the hello-express repo into your own github account. This is a simple Node/Express web app. For the purposes of this demo, the most interesting parts are the buildspec.yml
and appspec.yml
files, and scripts in the bin
directory:
- The buildspec.yml file is a specification file that contains build commands and configuration that are used to build a CodeBuild project.
- The appspec.yml file is a specification file that defines a series of lifecycle hooks for a CodeDeploy deployment.
- The bin directory contains the
www
start script for the app, and the scripts for each of the lifecycle hooks. You can read more about the lifecycle hooks here.
2. Add Parameters
Then, we’ll need the following input Parameters for our template:
Parameters:
GitHubRepo:
Type: String
GitHubBranch:
Type: String
Default: main
GitHubUser:
Type: String
CodeDeployServiceRole:
Type: String
Description: A service role ARN granting CodeDeploy permission to make calls to EC2 instances with CodeDeploy agent installed.
TagKey:
Description: The EC2 tag key that identifies this as a target for deployments.
Type: String
Default: CodeDeployTag
AllowedPattern: '[\x20-\x7E]*'
ConstraintDescription: Can contain only ASCII characters.
TagValue:
Description: The EC2 tag value that identifies this as a target for deployments.
Type: String
Default: CodeDeployDemo
AllowedPattern: '[\x20-\x7E]*'
ConstraintDescription: Can contain only ASCII characters.
3. Create Service Roles
In order for CodeBuild to access S3 to put the built artifact into the bucket, we'll need to create a service role, CodeBuildServiceRole
. We’ll need a second service role, CodePipelineServiceRole
, which allows CodePipeline to get the source code from the GitHub connection, to start builds, to get the artifact from the bucket, and to create and deploy the app. Add these two IAM resources:
CodeBuildServiceRole:
Type: AWS::IAM::Role
Properties:
Path: /
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: codebuild.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: "logs"
PolicyDocument:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
- ecr:GetAuthorizationToken
- ssm:GetParameters
Resource: "*"
- PolicyName: "S3"
PolicyDocument:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Action:
- s3:GetObject
- s3:PutObject
- s3:GetObjectVersion
Resource: !Sub arn:aws:s3:::${ArtifactBucket}/*
CodePipelineServiceRole:
Type: AWS::IAM::Role
Properties:
Path: /
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: codepipeline.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: root
PolicyDocument:
Version: 2012-10-17
Statement:
- Resource:
- !Sub arn:aws:s3:::${ArtifactBucket}/*
- !Sub arn:aws:s3:::${ArtifactBucket}
Effect: Allow
Action:
- s3:*
- Resource: "*"
Effect: Allow
Action:
- codebuild:StartBuild
- codebuild:BatchGetBuilds
- iam:PassRole
- Resource:
- !Ref CodeStarConnection
Effect: Allow
Action:
- codestar-connections:UseConnection
- Resource: "*"
Effect: Allow
Action:
- codedeploy:CreateDeployment
- codedeploy:CreateDeploymentGroup
- codedeploy:GetApplication
- codedeploy:GetApplicationRevision
- codedeploy:GetDeployment
- codedeploy:GetDeploymentConfig
- codedeploy:RegisterApplicationRevision
4. Create the Source Stage
For the Source stage, we’ll create a CodeStar Connection and an S3 Bucket.
1. Create a CodeStarConnection
When we set up the pipeline, we’ll have a Source stage the pulls our source code from GitHub. To do this, we’ll need to create a CodeStarConnection for GitHub. This will give our pipeline access to a GitHub repository. We’ll use CloudFormation to create this by adding the resource to our template, but there will be a manual step to change the connection from Pending to Available after the first time we apply the template.
CodeStarConnection:
Type: 'AWS::CodeStarConnections::Connection'
Properties:
ConnectionName: CfnExamplesGitHubConnection
ProviderType: GitHub
2. Create S3 Bucket to Hold Artifacts
We’ll need a place to store the build artifacts so we’ll create an S3 bucket. Add the following S3 resource to your template:
ArtifactBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
3. Create the Stage
Then we’ll create the first stage of the pipeline.
Pipeline:
Type: AWS::CodePipeline::Pipeline
Properties:
RoleArn: !GetAtt CodePipelineServiceRole.Arn
ArtifactStore:
Type: S3
Location: !Ref ArtifactBucket
Stages:
- Name: Source
Actions:
- Name: App
ActionTypeId:
Category: Source
Owner: AWS
Version: '1'
Provider: CodeStarSourceConnection
Configuration:
ConnectionArn: !Ref CodeStarConnection
BranchName: !Ref GitHubBranch
FullRepositoryId: !Sub ${GitHubUser}/${GitHubRepo}
OutputArtifacts:
- Name: AppArtifact
RunOrder: 1
Using the CodeStarSourceConnection resource we created above, this will configure it to use the branch, GitHub user, and repository name based on the input parameters.
5. Create Build Stage
For the Build stage, we’ll create a CodeBuild Project that indicates what kind of environment to build the code in. Here, we’re using a Docker Linux container. We’ll also set the service role to the service role we created earlier.
CodeBuildProject:
Type: AWS::CodeBuild::Project
Properties:
Artifacts:
Type: CODEPIPELINE
Source:
Type: CODEPIPELINE
BuildSpec: buildspec.yml
Environment:
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/docker:17.09.0
Type: LINUX_CONTAINER
Name: !Ref AWS::StackName
ServiceRole: !Ref CodeBuildServiceRole
Then we need to add the Build stage to the pipeline we started in the last step.
- Name: Build
Actions:
- Name: Build
ActionTypeId:
Category: Build
Owner: AWS
Version: '1'
Provider: CodeBuild
Configuration:
ProjectName: !Ref CodeBuildProject
InputArtifacts:
- Name: AppArtifact
OutputArtifacts:
- Name: BuildOutput
RunOrder: 1
This will also use the same S3 bucket from a the previous step to store the build output.
6. Create Deploy Stage
In the last stage, Deploy, we’ll need a CodeDeploy Application and a CodeDeploy DeploymentGroup.
CodeDeployApplication:
Type: AWS::CodeDeploy::Application
CodeDeployGroup:
Type: AWS::CodeDeploy::DeploymentGroup
Properties:
ApplicationName: !Ref CodeDeployApplication
Ec2TagFilters:
- Key: !Ref 'TagKey'
Value: !Ref 'TagValue'
Type: KEY_AND_VALUE
ServiceRoleArn: !Ref 'CodeDeployServiceRole'
The DeploymentGroup uses the EC2TagFilters
to specify which group of EC2 instances to deploy to. When we setup the EC2 instance above, we tagged it with a tag/value that is used here. We also set the service role to the one we created earlier.
Then we add the final stage to the pipeline.
- Name: Deploy
Actions:
- Name: Deploy
ActionTypeId:
Category: Deploy
Owner: AWS
Version: '1'
Provider: CodeDeploy
Configuration:
ApplicationName: !Ref CodeDeployApplication
DeploymentGroupName: !Ref CodeDeployGroup
InputArtifacts:
- Name: BuildOutput
RunOrder: 1
7. Add Outputs
Last, we need to add an Output to our template to give us the fully URL to view our pipeline in the AWS Console.
Outputs:
PipelineUrl:
Value: !Sub https://console.aws.amazon.com/codepipeline/home?region=${AWS::Region}#/view/${Pipeline}
To view the full version of this template, check it out here.
8. Create the Stack
Now that we have the pipeline template created, we can create the stack. We’ll need to copy CodeDeployTrustRoleARN Output from previous EC2 stack, which you can grab from the Outputs tab in the AWS Console.
Then, run create-stack
at the command line, replacing CODE_DEPLOY_SERVICE_ROLE_ARN
below with the ARN you just copied.
aws cloudformation create-stack --stack-name CloudFormationPipelineExample --template-body file://08_pipeline.yaml \
--parameters ParameterKey=GitHubRepo,ParameterValue=hello-express \
ParameterKey=GitHubUser,ParameterValue=jennapederson \
ParameterKey=CodeDeployServiceRole,ParameterValue=CODE_DEPLOY_SERVICE_ROLE_ARN \
--capabilities CAPABILITY_IAM
Because this template creates IAM roles, we also need to tell CloudFormation that this capability (creating IAM resources) is allowed to be used by specifying the --capabilities
option.
9. Make CodeStarConnection Available
Once the stack is created successfully, you'll need to change the CodeStarConnection from Pending to Available. To do this, head over to the AWS Console and find your newly created stack. On the Outputs tab, click the link to go to your new pipeline.
In the left-hand menu under Settings, click Connections. Select the new connection and click the "Update pending connection" button.
You'll need to give GitHub access to your account and the repository before the connection will become available. You can read more about that here in the section "To create a connection to GitHub."
10. Retry the Source Stage
Now that your CodeStarConnection is Available, head back to your pipeline and note that the Source stage has failed because of the Pending CodeStarConnection.
Click the "Retry" button next to the Source stage to restart it.
Your pipeline will restart by pulling the source code from GitHub, build the app, and then deploy the artifact to your EC2 instance! When complete, you can grab the WebServerPublicDNS
URL from your EC2 stack and open it in a browser:
And you will see Hello, Express show in your browser!
What you learned
In this post, we enhanced the CloudFormation template from part 2 to install CodeDeploy agent and tag the EC2 instances you want to deploy to. Then we created a new template that sets up a Source, Build, and Deploy stage for a CodePipeline complete with a CodeStarConnection, CodeBuild project, and a CodeDeploy application. Now you have a pipeline that will pull source code, build your app, and deploy it to EC2 when there are changes to a specific branch in a GitHub repository.
You can grab the final CloudFormation template we created here.
Like what you read? Follow me here on Dev.to or on Twitter to stay updated!
Posted on December 8, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.