Deploying Web Apps at Scale with AWS CDK, CodeBuild, CodePipeline, and CloudFront with custom domain

Thang Dang
Nerd For Tech
Published in
13 min readMar 28, 2023
Image source: CICD workflow

What is CICD?

Continuous Integration/Continuous Deployment (CI/CD) has become an essential part of modern software development, allowing for faster and more efficient development cycles. It involves a set of practices and tools that automate the building, testing, and deployment of code changes. This process ensures that changes made to the application are thoroughly tested and deployed to production in a timely and consistent manner.

AWS offers a suite of powerful tools for implementing CI/CD pipelines, including AWS CDK, CodeBuild, and CodePipeline. These tools enable developers to define their infrastructure as code, automate the build and deployment process, and manage their pipelines with ease.

  • AWS CDK (Cloud Development Kit): an open-source software development framework for defining cloud infrastructure in code, using familiar programming languages like TypeScript, Python, and Java.
  • AWS CodeBuild: a fully managed continuous integration service that compiles source code, runs tests, and produces software packages that are ready to deploy.
  • AWS CodePipeline: a fully managed continuous delivery service that helps you automate your release pipelines for fast and reliable application and infrastructure updates. It enables you to model, visualize, and automate the steps required to release your software.

To deploy our web application, we will leverage AWS S3 for highly scalable and durable object storage, AWS CloudFront as a content delivery network to improve user experience with low latency and high transfer speeds, and AWS Route53 for domain name resolution to associate our custom domain with our web application.

  • AWS S3: Amazon Simple Storage Service (S3) is a highly scalable and durable object storage service that allows you to store and retrieve data from anywhere on the web. It is commonly used for storing static files such as images, videos, and HTML/CSS/JS files for web applications.
  • AWS CloudFront: a highly scalable and secure content delivery network that accelerates the delivery of web content and APIs to customers worldwide. It improves the user experience by reducing latency and increasing transfer speeds, while also providing advanced security features to protect against common web threats.
  • AWS Route53: Amazon Route 53 is a highly scalable and available DNS (Domain Name System) service that routes traffic to internet applications such as web servers, S3 buckets, and CloudFront distributions. It is used to map domain names to the IP addresses of the servers hosting the web application, allowing users to access the application using a custom domain name.
Architecture diagrams

In this blog post, we’ll demonstrate how to utilize AWS CDK, CodeBuild, and CodePipeline to build and deploy a Vue.js (or any other framework) application, stored on AWS S3 and served via AWS CloudFront. We’ll start by defining our infrastructure as code with AWS CDK, followed by publishing our web application to AWS S3 and creating a CloudFront distribution. Next, we’ll set up our build environment using CodeBuild and configure our pipeline using CodePipeline. We’ll also show you how to integrate your Vue.js application with your CI/CD pipeline.

You can see the full source code in here.

At the time this blog has been published, it used:

  • node version: v16.17.0
  • npm version: 8.15.0
  • cdk version: 2.61.1 (build d319d9c)

Create vue project:

$ vue create web-app

Initialize GitHub repo and push the source code to it:

$ cd web-app
$ git init
$ git add .
$ git commit -m "Initial commit"
$ git remote add origin https://github.com/USERNAME/REPO_NAME.git
$ git push -u origin main

Install necessary libraries:

  • Install cdk library:
$ npm install -g aws-cdk
  • Initialize cdk app:
$ cdk init app –language typescript
Terminal cdk init

Here we go! We just first created cdk source.

Next, we will prepare the environment variables for each environments(development, staging, test, production…).

Create env directory: `mkdir env && cd env`

$ mkdir env

Next, we will create a set of files and its content:

  • .base.yaml
# cdk stack prefix name
base_id: my-web-app

Specific environment:

  • .dev.yaml
  • .prod.yaml

And the content will be:

region: ap-northeast-1 #feel free to change it

githubInfo:
owner: Owner
repo: ExampleRepository
branch: TARGET_BRANCH

connectionARN: ""

route53:
hostedZoneName: "host-example.com"
domainName: "my-website-example.com"

acm:
domainName: "*.my-website-example.com"
certificateARN: ""

In above file, we also place route53 and acm , this will be used to setup our custom domain in the next part.

For each environment, if you have a secret env variables to be hidden, let’s create .env file:

  • .dev.env
  • .prod.env

For example, I will place in the API endpoint:

VUE_APP_BASE_URL: http://localhost:8000/api

Yay!! We just finished creating base source and environment files for project. Next, we will create our first cdk stack.

Setup s3_cloudfront_stack

Under /lib folder , Let’s remove the generated stack’s file first.

Create file: s3_cloudfront_stack.ts. This will create the s3, cloudfront stack that use to store and hosting our web app.

Create file: cicd_stack.ts . This will create CodeBuild, CodePipeline stack that use to perform CI/CD.

s3_cloudfront_stack.ts file will look like:

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';


export class BucketStack extends cdk.Stack {
readonly s3Bucket: s3.IBucket;
readonly distribution: cloudfront.IDistribution;

constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// we will put stack here
}
}

Let’s create custom props for this stack:

interface S3StackProps extends cdk.StackProps {
env: cdk.Environment;
mode: string;
route53: {
domainName: string;
hostedZoneName: string;
}
acm: {
domainName: string;
certificateARN: string;
}
}

Place S3StackProps on top of stack class, and replace props type.

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface S3StackProps extends cdk.StackProps {
env: cdk.Environment;
mode: string;
route53: {
domainName: string;
hostedZoneName: string;
}
acm: {
domainName: string;
certificateARN: string;
}
}

export class BucketStack extends cdk.Stack {
readonly s3Bucket: s3.IBucket;
readonly distribution: cloudfront.IDistribution;

constructor(scope: Construct, id: string, props?: cdk.S3StackProps) {
super(scope, id, props);
// we will put stack here
}
}

It’s time to add s3 stack into:

this.s3Bucket = new s3.Bucket(this, id + "-s3", {
bucketName: id + "-s3",
websiteErrorDocument: "index.html",
websiteIndexDocument: "index.html",
publicReadAccess: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
});

Explain:

  • bucketName: a parameter used to specify the name of the S3 bucket that will be created.
  • websiteErrorDocument: The name of the error document to use when the web application encounters an error.
  • websiteIndexDocument: The name of the index document to serve as the entry point for the web application.
  • publicReadAccess: A boolean value that specifies whether the objects in the bucket are publicly readable.
  • removalPolicy: Specifies the removal policy for the bucket. Here, REMOVAL_POLICY.DESTROY is set, which will remove the bucket when the CloudFormation stack is deleted.
  • objectOwnership: Specifies the object ownership policy for the bucket. Here, BUCKET_OWNER_PREFERRED is set, which will always use the bucket owner as the object owner.

Hosted Zone:

const hostedZone = route53.HostedZone.fromLookup(this, id + "-hostedZone", {
domainName: props.route53.hostedZoneName,
});

We have 2 options to obtain ACM certificate:

  • To create a new ACM Certificate:
const certificate = new acm.Certificate(this, id + "-cert", {
domainName: props.acm.domainName,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
  • To retrieve the created Certificate, you can find certificateARN via AWS Certificate Manager console:
const certificate = acm.Certificate.fromCertificateArn(
this,
id + "-cert",
props.acm.certificateARN
);

CloudFront distribution:

this.distribution = new cloudfront.Distribution(this, id + "-cfdis", {
defaultBehavior: {
origin: new origins.S3Origin(this.s3Bucket, {
originId: id + "-origin"
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
},
errorResponses: [
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: cdk.Duration.seconds(10)
},
],
defaultRootObject: "index.html",
domainNames: [props.route53.domainName],
certificate: certificate,
});

Explain:

  • defaultBehavior: The default behavior for the CloudFront distribution, which includes the origin for the S3 bucket, viewer protocol policy, and allowed methods.
  • origin: The S3 bucket origin for the CloudFront distribution.
  • originId: The unique identifier for the S3 bucket origin.
  • viewerProtocolPolicy: The policy for viewer protocols, which is set to REDIRECT_TO_HTTPS in this case.
  • allowedMethods: The allowed HTTP methods for the CloudFront distribution, which is set to ALLOW_ALL in this case.
  • errorResponses: The error responses to display for the CloudFront distribution, which includes the HTTP status code, response HTTP status, response page path, and TTL.
  • httpStatus: The HTTP status code for the error response.
  • responseHttpStatus: The HTTP status for the response.
  • responsePagePath: The path to the error response page.
  • ttl: The TTL for the error response.
  • defaultRootObject: The default root object for the CloudFront distribution, which is set to index.html in this case.
  • domainNames: The domain names for the CloudFront distribution, which includes the custom domain name specified in Route53.
  • certificate: The SSL certificate to use for the CloudFront distribution.

And the last one is route53 record:

new route53.ARecord(this, id + "-record", {
zone: hostedZone,
recordName: props.route53.domainName,
target: route53.RecordTarget.fromAlias(
new CloudFrontTarget(this.distribution)
),
});

Explain:

  • zone: The hosted zone for the Route53 A record.
  • recordName: The name of the Route53 A record, which is set to the custom domain name.
  • target: The target for the Route53 A record, which is an alias to the CloudFront distribution.
  • CloudFrontTarget: The target type for the alias, which is a CloudFront distribution.

Nice! We’ve done our bucket stack.

The complete source code BucketStack will be:

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";

interface S3StackProps extends cdk.StackProps {
env: cdk.Environment;
mode: string;
route53: {
domainName: string;
hostedZoneName: string;
}
acm: {
domainName: string;
certificateARN: string;
}
}

export class BucketStack extends cdk.Stack {
readonly s3Bucket: s3.IBucket;
readonly distribution: cloudfront.IDistribution;

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

this.s3Bucket = new s3.Bucket(this, id + "-s3", {
bucketName: id + "-s3",
websiteErrorDocument: "index.html",
websiteIndexDocument: "index.html",
publicReadAccess: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
});

const hostedZone = route53.HostedZone.fromLookup(this, id + "-hostedZone", {
domainName: props.route53.hostedZoneName,
});

// const certificate = new acm.Certificate(this, id + "-cert", {
// domainName: props.acm.domainName,
// validation: acm.CertificateValidation.fromDns(hostedZone),
// });

// retrieve defined certificate
const certificate = acm.Certificate.fromCertificateArn(
this,
id + "-cert",
props.acm.certificateARN
);

this.distribution = new cloudfront.Distribution(this, id + "-cfdis", {
defaultBehavior: {
origin: new origins.S3Origin(this.s3Bucket, {
originId: id + "-origin"
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
},
errorResponses: [
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: cdk.Duration.seconds(10)
},
],
defaultRootObject: "index.html",
domainNames: [props.route53.domainName],
certificate: certificate,
});

new route53.ARecord(this, id + "-record", {
zone: hostedZone,
recordName: props.route53.domainName,
target: route53.RecordTarget.fromAlias(
new CloudFrontTarget(this.distribution)
),
});
}
}

Next, we will create CICD stack in cicd_stack.ts file.

Create interface CICDStackProps, class CICDStack and import necessary libraries:

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

import * as codepipeline_actions from "aws-cdk-lib/aws-codepipeline-actions";
import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";

interface CICDStackProps extends cdk.StackProps {
env: cdk.Environment;
mode: string;
bucket: s3.IBucket;
githubInfo: {
gitOwner: string,
gitRepository: string,
branch: string,
};
connectionARN: string;
webEnv: {
baseUrl?: string,
}
distributionId: string;
}

export class CICDStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: CICDStackProps) {
super(scope, id, props);
}
}

Initialize output artifacts:

const sourceOutput = new codepipeline.Artifact();
const buildOutput = new codepipeline.Artifact();

Create GitHub source for codebuild . We use a GitHub webhook with the PUSH event to trigger CodeBuild whenever new commits are pushed to your TARGET_BRANCH or when a pull request is merged into your branch.

const gitHubSource = codebuild.Source.gitHub({
owner: props.githubInfo.gitOwner,
repo: props.githubInfo.gitRepository,
webhook: true,
webhookFilters: [
codebuild.FilterGroup.inEventOf(codebuild.EventAction.PUSH)
.andBranchIs(props.githubInfo.branch),
],
});

We create CodeBuild role, which gives Codebuild permissions to access s3, run codebuild:

// codebuild role
const codebuildRole = new iam.Role(this, "CodeBuildRole", {
roleName: id + "-codebuild-role",
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal('codebuild.amazonaws.com'),
new iam.ServicePrincipal('codepipeline.amazonaws.com')
)
});

codebuildRole.addToPolicy(
new iam.PolicyStatement({ resources: ["*"], actions: ["s3:*"] })
);

Create build project of CodeBuild:

// build project
const buildProject = new codebuild.Project(this, id + '-codebuild', {
projectName: id + '-codebuild',
role: codebuildRole,
badge: true,
source: gitHubSource,
buildSpec: codebuild.BuildSpec.fromSourceFilename('buildspec.yml'),
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
},
environmentVariables: {
VUE_APP_BASE_URL: {
value: `${props.webEnv.baseUrl}`,
},
},
});

Explain:

  • badge: A boolean value that specifies whether to enable the CodeBuild badge for the project.
  • buildSpec: The path to the buildspec file that contains the build instructions for CodeBuild.
  • environment: The environment in which the build will run. In this case, it specifies the build image used for the build. Since the buildspec.yml used node v14, this build image will be version 5. Refer to this page to find your suitable version.
  • environmentVariables: An object that contains key-value pairs of environment variables that will be available during the build process. Here, it sets the VUE_APP_BASE_URL environment variable to the baseUrl specified in the props object.

This time, we have to define buildspec.yml to let CodeBuild follows the build instruction. Create buildspec.yml file in the root folder of your web-app.

version: 0.2

phases:
install:
runtime-versions:
nodejs: 14
commands:
- npm install -g yarn
pre_build:
commands:
- echo Build started on `date`
- yarn install
- echo "Prepare env"
- touch .env
- echo "VUE_APP_BASE_URL=$VUE_APP_BASE_URL" >> .env
- cat .env
build:
commands:
- yarn build
post_build:
commands:
- echo Build completed on `date`

artifacts:
base-directory: dist
files:
- '**/*'

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

CodePipeline source action:

// source action
const sourceAction =
new codepipeline_actions.CodeStarConnectionsSourceAction({
actionName: "GitHub_Source",
owner: props.githubInfo.git_owner,
repo: props.githubInfo.git_repository,
branch: props.githubInfo.branch,
output: sourceOutput,
connectionArn: props.connectionARN,
})

In order to have connectionARN , navigate to CodePipeline > Settings > Connections > Create Connection. In this blog we use GitHub but you can choose your preferred provider. After creating connection, you will have connectionARN .

Manual approval action:

// manual approval action
const manualApprovalAction =
new codepipeline_actions.ManualApprovalAction({
actionName: "BuildApproval",
});

Build and Deploy action:

// build action
const buildAction = new codepipeline_actions.CodeBuildAction({
actionName: 'Build',
project: buildProject,
input: sourceOutput,
outputs: [buildOutput]
})

// deploy action
const deployAction = new codepipeline_actions.S3DeployAction({
actionName: 'DeployToS3',
input: buildOutput,
bucket: props.bucket,
runOrder: 1,
})

If you want to create invalidate for CloudFront anytime deployment, just follow the code below:

// invalidate cache codebuild
// Ref: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_codepipeline_actions-readme.html#invalidating-the-cloudfront-cache-when-deploying-to-s3
const invalidateBuildProject = new codebuild.PipelineProject(this, id + `-invalidate-codebuild`, {
projectName: id + `-invalidate-codebuild`,
buildSpec: codebuild.BuildSpec.fromObject({
version: '0.2',
phases: {
build: {
commands:[
'aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID} --paths "/*"',
],
},
},
}),
environmentVariables: {
CLOUDFRONT_ID: { value: props.distributionId },
},
});

// Add Cloudfront invalidation permissions to the project
const distributionArn = `arn:aws:cloudfront::${this.account}:distribution/${props.distributionId}`;
invalidateBuildProject.addToRolePolicy(new iam.PolicyStatement({
resources: [distributionArn],
actions: [
'cloudfront:CreateInvalidation',
],
}));

// invalidate cache action
const invalidateAction = new codepipeline_actions.CodeBuildAction({
actionName: 'InvalidateCache',
project: invalidateBuildProject,
input: buildOutput,
runOrder: 2,
})

We create CodePipeline to put everything above in:

// pipeline
new codepipeline.Pipeline(this, id + "-pipeline", {
pipelineName: id + "-pipeline",
stages: [
{
stageName: "Source",
actions: [sourceAction]
},
{
stageName: "Approve",
actions: [manualApprovalAction]
},
{
stageName: "Build",
actions: [buildAction]
},
{
stageName: "Deploy",
actions: [deployAction, invalidateAction]
},
]
});

Now, we completed our CICD Stack!!

Let’s update the entry point file of CDK.

First, install these libraries:

$ npm install yaml
$ npm install dotenv

Then, create utils folder, and index.ts file to store the function in order to load the environment file.

import * as fs from "fs";
import * as dotenv from "dotenv";
import * as path from "path";
import * as yaml from "yaml";

export const loadEnvironmentVariablesFile = (
mode: "development" | "production",
envDirPath = path.join(process.cwd(), "env")
) => {
const baseYaml = fs.readFileSync(path.join(envDirPath, ".base.yaml"), "utf-8");
const modeYaml = fs.readFileSync(
path.join(envDirPath, mode === "production" ? ".prod.yaml" : ".dev.yaml"),
"utf-8"
);

const baseObj = yaml.parse(baseYaml);
const modeObj = yaml.parse(modeYaml);

return Object.assign({}, baseObj, modeObj);
};

export const configLocalEnvironmentFile = (
mode: "dev" | "prod",
envDirPath: string = path.join(process.cwd(), "env")
) => {
dotenv.config({ path: envDirPath+`/.${mode}.env` });
}

Back to the entry file in /bin folder. We will call our stack from here

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CICDStack } from '../lib/cicd_stack';
import { loadEnvironmentVariablesFile, configLocalEnvironmentFile as setDotEnvironmentFile } from "../utils/index";
import { BucketStack } from '../lib/s3_cloudfront_stack';

const app = new cdk.App();
const mode = process.env.MODE === "prod" ? "prod" : "dev";
const env = loadEnvironmentVariablesFile(mode);

setDotEnvironmentFile(mode)

const baseId = env.base_id + "-" + mode

const envUser = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: env.region,
}

const githubInfo = {
gitOwner: env.githubInfo.owner,
gitRepository: env.githubInfo.repo,
branch: env.githubInfo.branch
};

const bucketStack = new BucketStack(app, baseId + "-web", {
env: envUser,
mode: mode,
route53:{
domainName: env.route53.domainName,
hostedZoneName: env.route53.hostedZoneName,
},
acm: {
domainName: env.acm.domainName,
certificateARN: env.acm.certificateARN,
}
})

new CICDStack(app, baseId + "-cicd", {
env: envUser,
mode: mode,
bucket: bucketStack.s3Bucket,
githubInfo: githubInfo,
connectionARN: env.connectionARN,
webEnv: {
baseUrl: process.env.VUE_APP_BASE_URL,
},
distributionId: bucketStack.distribution.distributionId
})

app.synth()

Yay!! We just finished setting up AWS CDK Resources. Now you can run diff command to check if everything is OK. Remember to place your environment MODE .

$ MODE=dev cdk diff

Since it is error-free, you’re ready to deploy your app to CDK. Run the following command to deploy to AWS CloudFormation:

$ MODE=dev cdk deploy --all

After deploying, navigating to each AWS resources such as s3, CloudFront, CodeBuild, CodePipeline,… to check if everything created exactly what you set.

Destroy cdk command:

$ MODE=dev cdk destroy --all

You can see the full source code in here.

Conclusion

In this blog post, we have explored how to use AWS CDK, CodeBuild, and CodePipeline to build and deploy a Vue.js application with a CI/CD pipeline. By using these tools, we can define our infrastructure as code, automate our build and deployment processes, and manage our pipelines with ease.

By integrating our Web application with our CI/CD pipeline, we can enjoy the benefits of faster and more efficient development cycles, reduced risk of errors and bugs, and greater overall reliability and consistency of our application.

In conclusion, AWS CDK, CodeBuild, and CodePipeline provide powerful tools for implementing CI/CD pipelines that can greatly enhance the efficiency and reliability of our software development processes. By leveraging these tools, we can focus on delivering high-quality applications while minimizing the time and effort required for deployment.

References

  1. AWS CDK documentation: https://docs.aws.amazon.com/cdk/latest/guide/home.html
  2. AWS CodeBuild documentation: https://docs.aws.amazon.com/codebuild/latest/userguide/welcome.html
  3. AWS CodePipeline documentation: https://docs.aws.amazon.com/codepipeline/latest/userguide/welcome.html
  4. AWS Invalidating Cache: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_codepipeline_actions-readme.html#invalidating-the-cloudfront-cache-when-deploying-to-s3
  5. AWS CloudFront CDK: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront-readme.html#distribution-api

Feel free to contact me by email thangdangdev@gmail.com or leave a comment if you have any question:

If you see that this article is helpful, don’t hesitate to give a clap.

Thank you for reading ❤

--

--

Thang Dang
Nerd For Tech

Researching new techs, writing blog and sharing my knowledge to everyone. Feel free to contact me.