Deploying S3 static site with AWS CDK pipeline


Published on 3/6/2024

Welcome to this tutorial on deploying an S3 static site with CDK pipeline!

In this tutorial, you will learn how to set up a CDK pipeline to deploy a static site to Amazon S3. We will cover the entire process, from creating the necessary infrastructure using AWS CDK to automating the deployment using codepipeline. You can find the complete code on github here

Let's get started!


Folder Structure

We will create a monorepo with the following folder structure:

  static-site/
  ├── app/
  ├── infra/
    

The app folder will be a simple VITE application, and the infra folder will contain the CDK code to create the necessary infrastructure.

VITE application

After creating the folder, cd into the app folder and run the following command to create a new VITE application:

pnpm create vite

Feel free to use npm or yarn if you prefer.

To learn more about VITE, you can check out the official documentation.

AWS CDK infrastructure

This tutorial assumes you have AWS installed and configured on your local machine. If not, you can follow the instructions here to install and configure the AWS CLI. Make sure the the configured profile has permissions to provision AWS resources. to make things simple the profile I used had an AministratorAccess policy attached to it.

Lets install aws-cdk:

pnpm install -g aws-cdk

Verify the installation by running the following command:

cdk --version

Bootstrap the CDK environment:

AWS CDK requires certain resources and permissions to be able to deploy apps for you. for example, S3 buckets to store build artifacts and iam executation roles.

Let's bootstrap the CDK environment by running the following command:

cdk bootstrap

Learn more about bootstrapping here

Create a new CDK project

Lets cd into the infra folder and run the following command to create a new CDK project:

cdk init app --language typescript

This will create a new CDK project with the following folder structure:

  infra/
  ├── bin/
  ├── lib/
  ├── test/
  ├── cdk.json
  ├── jest.config.js
  ├── package.json
  ├── README.md
  ├── tsconfig.json

Before we continue, let's cleanup some boilerplate code that we don't need. Open the lib/infra-stack.ts file and remove the HelloCdkStack class and the HelloCdkStackProps interface.

import { Construct } from 'constructs';
import {
  Stack,
  StackProps
} from 'aws-cdk-lib';
 
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    //...
  }
}

We're going to use the aws_codepipeline construct to create our pipeline. the pipeline will have 3 stages:

  • Source stage -> getting the source code from github
  • Build stage -> building the VITE application inside a codebuild project
  • Deploy stage -> deploying the built application to an S3 bucket with a cloudfront distribution in front of it.

Stage 1: Source

We will use github as our source provider. To do this, we need to create a new GitHubSourceAction

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  SecretValue,
  aws_codepipeline as codepipeline,
  aws_codepipeline_actions as codepipelineActions,
} from 'aws-cdk-lib';
 
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
 
    const sourceOutput = new codepipeline.Artifact()
 
    const sourceAction = new codepipelineActions.GitHubSourceAction({
      actionName: 'GithubSource',
      owner: 'mactunechy',
      repo: 'cdk-s3-static-site-hosting',
      oauthToken: SecretValue.secretsManager('github_token2'),
      output: sourceOutput,
      branch: 'master'
    })
 
  }
}

Make use you have created the monorepo on github and use that information in the GitHubSourceAction constructor.

Setting up the github token

In your github account create an access token with the following permissions:

  • repo
  • admin:repo_hook

Learn more about creating a github token here

In the AWS console, navigate to AWS Secret Manager service and store a plaintext secret. I named mine github_token2, you can name yours whatever you want.

We will use this secret to authenticate with github in the GitHubSourceAction constructor.

Stage 2: Build

We will use AWS CodeBuild service to build our Vite application into a production ready bundle. the bundle will be outputed by the build action as an artifact and is stored in an artifacts s3 bucket.

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  SecretValue,
  aws_codepipeline as codepipeline,
  aws_codepipeline_actions as codepipelineActions,
  aws_codebuild as codebuild,
} from 'aws-cdk-lib';
 
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
 
    const sourceOutput = new codepipeline.Artifact()
    const buildOutput = new codepipeline.Artifact()
 
    const sourceAction = new codepipelineActions.GitHubSourceAction({
      actionName: 'GithubSource',
      owner: 'mactunechy',
      repo: 'cdk-s3-static-site-hosting',
      oauthToken: SecretValue.secretsManager('github_token2'),
      output: sourceOutput,
      branch: 'master'
    })
 
 
    const buildAction = new codepipelineActions.CodeBuildAction({
      actionName: 'Build',
      project: new codebuild.PipelineProject(this, 'ViteSiteBuildProject', {
        buildSpec: codebuild.BuildSpec.fromObject({
          version: '0.2',
          phases: {
            install: {
              'runtime-versions': {
                nodejs: '20.x'
              },
              commands: [
                'cd app',
                'npm install pnpm -g',
                'pnpm install'
              ],
            },
            build: {
              commands: ['pnpm run build'],
            },
          },
          artifacts: {
            files: ['**/*'],
            'base-directory': 'app/dist',
          },
 
        }),
        environment: {
          buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5,
        }
      }),
      input: sourceOutput,
      outputs: [buildOutput],
    })
  }
}
 

Stage 3: Deloyment

We will use S3 deployment action to deploy the built application bundle to our s3 bucket. The output of the previous stage will be the input for this stage.

Before we continue with the deploy stage, we need to create s3 bucket and a cloudfront distribution to serve our static site.

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  SecretValue,
  RemovalPolicy,
  aws_codepipeline as codepipeline,
  aws_codepipeline_actions as codepipelineActions,
  aws_codebuild as codebuild,
  aws_s3 as s3,
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as cfOrigins,
  
} from 'aws-cdk-lib';
 
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
 
    const sourceOutput = new codepipeline.Artifact()
    const buildOutput = new codepipeline.Artifact()
 
    const sourceAction = new codepipelineActions.GitHubSourceAction({
      actionName: 'GithubSource',
      owner: 'mactunechy',
      repo: 'cdk-s3-static-site-hosting',
      oauthToken: SecretValue.secretsManager('github_token2'),
      output: sourceOutput,
      branch: 'master'
    })
 
 
    const buildAction = new codepipelineActions.CodeBuildAction({
      actionName: 'Build',
      project: new codebuild.PipelineProject(this, 'ViteSiteBuildProject', {
        buildSpec: codebuild.BuildSpec.fromObject({
          version: '0.2',
          phases: {
            install: {
              'runtime-versions': {
                nodejs: '20.x'
              },
              commands: [
                'cd app',
                'npm install pnpm -g',
                'pnpm install'
              ],
            },
            build: {
              commands: ['pnpm run build'],
            },
          },
          artifacts: {
            files: ['**/*'],
            'base-directory': 'app/dist',
          },
 
        }),
        environment: {
          buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5,
        }
      }),
      input: sourceOutput,
      outputs: [buildOutput],
    })
 
    const bucket = new s3.Bucket(this, 'ViteStiteBucket', {
      websiteIndexDocument: 'index.html',
      removalPolicy: RemovalPolicy.DESTROY,
      blockPublicAccess: {
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false,
      },
      publicReadAccess: true,
    })
 
    const viteSiteOAI = new cloudfront.OriginAccessIdentity(this, 'OriginAccessControl', {
      comment: 'Vite site OAI'
    });
 
    new cloudfront.Distribution(this, 'ViteSiteDistribution', {
      defaultBehavior: {
        origin: new cfOrigins.S3Origin(bucket, { originAccessIdentity: viteSiteOAI }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.ALLOW_ALL,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS
      },
      errorResponses: [
        {
          httpStatus: 403,
          responsePagePath: '/error.html',
          responseHttpStatus: 200
        }
      ],
    })
 
    const deployAction = new codepipelineActions.S3DeployAction({
      actionName: 'S3Deploy',
      input: buildOutput,
      bucket
    })
  }
}

Let's walk through the code the added:

S3 bucket

  • We create an S3 bucket that we're going to use to host our static site. We set the websiteIndexDocument to index.html to enable static website hosting.
  • We add a policy statement to allow public read access to the bucket.

Cloudfront distribution

  • viteSidteOAI is an identity used to by the cloudfront distribution to access the s3 bucket objects securely. and forwards them to the requester. Learn more more about cloudfront OAI here
  • We create a new cloudfront distribution with the s3 bucket as the origin. We set the viewerProtocolPolicy to ALLOW_ALL to allow both http and https requests.

Finally we create a new S3DeployAction to deploy the built application to the s3 bucket.

The pipeline

We integrate everything together by adding the stages to a pipeline

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  SecretValue,
  aws_codepipeline as codepipeline,
  aws_codepipeline_actions as codepipelineActions,
  aws_codebuild as codebuild,
  aws_s3 as s3,
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as cfOrigins,
 
  RemovalPolicy
} from 'aws-cdk-lib';
 
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
 
    const sourceOutput = new codepipeline.Artifact()
    const buildOutput = new codepipeline.Artifact()
 
    const sourceAction = new codepipelineActions.GitHubSourceAction({
      actionName: 'GithubSource',
      owner: 'mactunechy',
      repo: 'cdk-s3-static-site-hosting',
      oauthToken: SecretValue.secretsManager('github_token2'),
      output: sourceOutput,
      branch: 'master'
    })
 
 
    const buildAction = new codepipelineActions.CodeBuildAction({
      actionName: 'Build',
      project: new codebuild.PipelineProject(this, 'ViteSiteBuildProject', {
        buildSpec: codebuild.BuildSpec.fromObject({
          version: '0.2',
          phases: {
            install: {
              'runtime-versions': {
                nodejs: '20.x'
              },
              commands: [
                'cd app',
                'npm install pnpm -g',
                'pnpm install'
              ],
            },
            build: {
              commands: ['pnpm run build'],
            },
          },
          artifacts: {
            files: ['**/*'],
            'base-directory': 'app/dist',
          },
 
        }),
        environment: {
          buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5,
        }
      }),
      input: sourceOutput,
      outputs: [buildOutput],
    })
 
    const bucket = new s3.Bucket(this, 'ViteStiteBucket', {
      websiteIndexDocument: 'index.html',
      removalPolicy: RemovalPolicy.DESTROY,
      blockPublicAccess: {
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false,
      },
      publicReadAccess: true,
    })
 
    const viteSiteOAI = new cloudfront.OriginAccessIdentity(this, 'OriginAccessControl', {
      comment: 'Vite site OAI'
    });
 
    new cloudfront.Distribution(this, 'ViteSiteDistribution', {
      defaultBehavior: {
        origin: new cfOrigins.S3Origin(bucket, { originAccessIdentity: viteSiteOAI }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.ALLOW_ALL,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS
      },
      errorResponses: [
        {
          httpStatus: 403,
          responsePagePath: '/error.html',
          responseHttpStatus: 200
        }
      ],
    })
 
    const deployAction = new codepipelineActions.S3DeployAction({
      actionName: 'S3Deploy',
      input: buildOutput,
      bucket
    })
 
    const pipeline = new codepipeline.Pipeline(this, 'ViteSitePipeline', {
      pipelineName: 'ViteSitePipeline',
      // let's save CMK since we're not doing any cross-account deployments
      crossAccountKeys: false,
    });
 
    pipeline.addStage({
      stageName: 'Source',
      actions: [sourceAction],
    })
 
    pipeline.addStage({
      stageName: 'Build',
      actions: [buildAction]
    })
 
    pipeline.addStage({
      stageName: 'Deploy',
      actions: [deployAction]
    })
  }
}

We're almost done. we can check the resources that are going to be deployed by running the following command:

cdk diff

Push the code to Github and deploy by running:

cdk deploy

Once it's deployment is complete, the pipeline automatically start running and you should see the stages running in the AWS CodePipeline console.

Once the pipeline is complete, you can navigate to the cloudfront distribution domain name to see your static site live.

That's it! You've successfully deployed a static site to Amazon S3 using AWS CDK pipeline.

Improvements

Currently, we can trigger a pipeline run by pushing changes to the master branch. Our site will be updated as expected, but Cloudfront cache will still be stale. We can add a cache validation lambda function to the pipeline to invalidate the cloudfront cache after a successful deployment. Learn more here

We will cover this in a follow up posts. Happy coding!!

Github Repository