AWS Yarns

A blog about AWS things.

Deploy a static Blog quickly on AWS

Posted by Chris McKinnel - 13 November 2020
6 minute read

Today I found myself in the position of needing to rapidly spin up a personal blog site where I can post some of the cool stuff I’ve been involved in using AWS in 2020.

I wanted something that:

  1. Was very low cost (or free)
  2. Served at the edge around the world using a CDN
  3. Served using TLS (without having to pay for a certificate – it’s 2020 after all)
  4. Didn’t need any servers to feed and water
  5. Could be deployed by just pushing to source control

I was torn between just going to wordpress.com and clicking through the wizard to get a blog and whipping something up on AWS. One of the things I often tell my customers is just spend a little bit of extra time up front and it’ll pay dividends down the track – so today I’m eating my own dogfood and I’m going to spend a little more time up front to get a blog deployed on AWS using CloudFront and S3, orchestrated using CodePipeline and CodeBuild and all defined with Infrastructure as Code.

Architecture

First thing’s first – what is the architecture going to look like?

Site components

Blog site components. CloudFront serving our website from S3 will be fully serverless.

Deployment pipeline components

Deployment pipeline components. We'll use CodePipeline and CodeBuild to automatically deploy changes to our blog site.

This should cover our requirements so far:

  • Fully serverless – nails requirements #1 and #4
  • CloudFront – covers #2
  • Certificate Manager – covers #3
  • CodePipeline and CodeBuild – covers #5

Sweet! So what do we start with? My preference has always been to get the CI / CD stuff deployed first, so as you’re developing you can just push to source control with every iteration and it automatically gets built.

Unfortunately, in a lot of software (and infrastructure) projects this step gets skipped because you’re desperate to get something working – often to prove that it’s even possible – and you end up losing a bunch of time re-deploying, refactoring and automating what you’ve done so far. I’ve deployed many, many projects and I still make this mistake from time to time.

Not today, though!

First, let’s define a simple CloudFormation template as a placeholder for our blog infrastructure:

infra.yml

Resources:
  TemporaryBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

We’ll replace the contents of this template with our website infrastructure once our CI / CD pipeline is successfully running and launching this template for us.

Next, let’s define a simple CodePipeline that uses GitHub as a source and deploys our infra.yml template.

pipeline.yml

Parameters:

  GitHubOwner:
    Type: String
    Description: The username or org that owns the repo

  GitHubRepo:
    Type: String
    Description: The name of the repo as it appears in Github

  GitHubBranch:
    Type: String
    Description: The branch to pull
    Default: master

  GitHubTokenSecretName:
    Type: String
    Description: The name of the Secrets Manager secret that contains a GitHub personal access token
    Default: Github

  GitHubTokenSecretKeyName:
    Type: String
    Description: The JSON key name of the Secrets Manager value
    Default: PersonalAccessToken

Resources:

  PipelineBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ArtifactStore:
        Type: S3
        Location: !Ref PipelineBucket
      RestartExecutionOnUpdate: true
      RoleArn: !GetAtt CodePipelineRole.Arn
      Stages:
        - Name: Source
          Actions:
            - Name: GitHub
              ActionTypeId:
                Category: Source
                Owner: ThirdParty
                Version: 1
                Provider: GitHub
              OutputArtifacts:
                - Name: source
              Configuration:
                Owner: !Ref GitHubOwner
                Repo: !Ref GitHubRepo
                Branch: !Ref GitHubBranch
                OAuthToken: !Sub '{{resolve:secretsmanager:${GitHubTokenSecretName}:SecretString:${GitHubTokenSecretKeyName}}}'
              RunOrder: 1
        - Name: Upload
          Actions:
            - Name: Site
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              InputArtifacts:
                - Name: source
              Configuration:
                ProjectName: !Ref CodeBuildProject
              RunOrder: 1
        - Name: Deploy
          Actions:
            - Name: Cloudfront
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Sub ${AWS::StackName}-infra
                RoleArn: !GetAtt CodePipelineRole.Arn
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                TemplatePath: source::infra.yml
              RunOrder: 1

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:4.0
      Source:
        Type: CODEPIPELINE
        BuildSpec:
          !Sub |
            version: 0.2
            phases:
              build:
                commands:
                  - echo "fake build done!"

  CodePipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  CodeBuildRole:
    Type: AWS::IAM::Role
    DependsOn: CodePipelineRole
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
We're using Secrets Manager to store our GitHub personal access token.

Note: we’re giving our CodePipelineRole administrator access here, which is a bit loose. We should probably spend the time and figure out exactly what permissions our pipeline needs instead of being lazy and just granting administrator access. This wouldn’t fly if I was doing this for a customer, so it shouldn’t fly writing public blog posts.

We'll also need to define some parameters that we pass into our CloudFormation template:

cfn-parameters.json

[
  {
    "ParameterKey": "GitHubOwner",
    "ParameterValue": "chrismckinnel"
  }, 
  {
    "ParameterKey": "GitHubRepo",
    "ParameterValue": "test-blog-site"
  },
  {
    "ParameterKey": "GitHubBranch",
    "ParameterValue": "master"
  }, 
  {
    "ParameterKey": "GitHubTokenSecretName",
    "ParameterValue": "Github"
  }, 
  {
    "ParameterKey": "GitHubTokenSecretKeyName",
    "ParameterValue": "PersonalAccessToken"
  }
]

And finally push our GitHub personal access token secret into secrets manager so our pipeline can fetch it from CloudFormation later.

aws secretsmanager create-secret --name Github --description "Personal access token for GitHub" --secret-string "{\"PersonalAccessToken\":\"xxxxxxxxx\"}" --region us-east-1
{
    "VersionId": "cc28f99a-406c-4643-84ce-xxx", 
    "Name": "Github", 
    "ARN": "arn:aws:secretsmanager:ap-southeast-2:xxx:secret:Github-dLVxxx"
}

            

Create a new git repo and commit and push our code, then we'll deploy the CloudFormation template:

git add .
git commit

[master (root-commit) 7eeaff6] Initial commit
 2 files changed, 145 insertions(+)
 create mode 100644 infra.yml
 create mode 100644 pipeline.yml
 
git push origin master

Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 2 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 1.44 KiB | 1.44 MiB/s, done.
Total 4 (delta 0), reused 0 (delta 0)
To github.com:chrismckinnel/test-blog-site.git
 * [new branch]      master -> master

aws cloudformation create-stack --stack-name blog-pipeline --template-body file://pipeline.yml -parameters file://cfn-parameters.json --capabilities CAPABILITY_IAM --region us-east-1

{
    "StackId": "arn:aws:cloudformation:us-east-1:046671368452:stack/blog-pipeline/b5b3ba90-4186-11eb-98ff-0ab512dce13f"
}

Note: we’re deploying this stack into North Virginia mainly because our ACM certificate has to live there to work with CloudFormation. Alternatively, we could deploy the certificate separately and deploy our pipeline and infrastructure in our region of choice, but the path of least resistance is to just chuck it all in North Virginia.

We can check the status of our CloudFormation stack using the API:

aws cloudformation describe-stacks --stack-name blog-pipeline --region us-east-1 | jq '.Stacks[0].StackStatus'
"CREATE_COMPLETE"          
            

This should have deployed a CloudFormation stack that deployed our CI / CD pipeline. It’s pretty basic at the moment – CodeBuild, the step that will end up building our website source, just echoes a message and completes successfully.

Blog site initial pipeline with CodePipeline.

The deploy step in the pipeline will have created another CloudFormation stack called blog-pipeline-infra which deploys the infra.yml template we defined at the beginning of the post. We’ll update this later to deploy our website with CloudFront.

Blog site infrastructure CloudFormation stack.

So, we’ve got a CI / CD pipeline!

One of the cool things you can do with CodePipeline is configure it so it updates its parent CloudFormation template, making it self-updating. This means you’re able to just push updates to the CodePipeline configuration to source control and it will take care of the rest. Without the self-update step you’d need to run a `cloudformation update-stack` on it every time you wanted to make a change (like adding a stage, changing configuration, updating parameters, etc).

Shout-out to Rupert Byrant-Green for showing me how to do this! And making his code public - a lot of this blog post's CloudFormation is "borrowed" from him.

A future blog post will go into more detail on this self-update step, but for now let’s add it in and update our stack.

pipeline.yml

Parameters:

  GitHubOwner:
    Type: String
    Description: The username or org that owns the repo

  GitHubRepo:
    Type: String
    Description: The name of the repo as it appears in Github

  GitHubBranch:
    Type: String
    Description: The branch to pull
    Default: master

  GitHubTokenSecretName:
    Type: String
    Description: The name of the Secrets Manager secret that contains a GitHub personal access token
    Default: Github

  GitHubTokenSecretKeyName:
    Type: String
    Description: The JSON key name of the Secrets Manager value
    Default: PersonalAccessToken

Resources:

  PipelineBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ArtifactStore:
        Type: S3
        Location: !Ref PipelineBucket
      RestartExecutionOnUpdate: true
      RoleArn: !GetAtt CodePipelineRole.Arn
      Stages:
        - Name: Source
          Actions:
            - Name: GitHub
              ActionTypeId:
                Category: Source
                Owner: ThirdParty
                Version: 1
                Provider: GitHub
              OutputArtifacts:
                - Name: source
              Configuration:
                Owner: !Ref GitHubOwner
                Repo: !Ref GitHubRepo
                Branch: !Ref GitHubBranch
                OAuthToken: !Sub '{{resolve:secretsmanager:${GitHubTokenSecretName}:SecretString:${GitHubTokenSecretKeyName}}}'
              RunOrder: 1
        - Name: Update
          Actions:
            - Name: Pipeline
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Ref AWS::StackName
                RoleArn: !GetAtt CodePipelineRole.Arn
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                TemplatePath: source::pipeline.yml
                TemplateConfiguration: source::codepipeline-parameters.json
              RunOrder: 1
        - Name: Upload
          Actions:
            - Name: Site
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              InputArtifacts:
                - Name: source
              Configuration:
                ProjectName: !Ref CodeBuildProject
              RunOrder: 1
        - Name: Deploy
          Actions:
            - Name: Cloudfront
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Sub ${AWS::StackName}-infra
                RoleArn: !GetAtt CodePipelineRole.Arn
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                TemplatePath: source::infra.yml
              RunOrder: 1

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:4.0
      Source:
        Type: CODEPIPELINE
        BuildSpec:
          !Sub |
            version: 0.2
            phases:
              build:
                commands:
                  - echo "fake build done!"

  CodePipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  CodeBuildRole:
    Type: AWS::IAM::Role
    DependsOn: CodePipelineRole
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess

We also need to create a new file for our parameters in the format that CodePipeline expects (why are they a different format?? AWS devs owe me a couple of hours for that one).

codepipeline-parameters.json

{
  "Parameters": {
    "GitHubOwner": "chrismckinnel",
    "GitHubRepo": "test-blog-site",
    "GitHubBranch": "master",
    "GitHubTokenSecretName": "Github",
    "GitHubTokenSecretKeyName": "PersonalAccessToken"
  }
}

Let's commit and push the change above, and update our CloudFormation stack.

git add .
git commit
[master b61e330] Add update step
 1 file changed, 23 insertions(+)

git push origin master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 443 bytes | 443.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:chrismckinnel/test-blog-site.git
   94c6d3e..b61e330  master -> master
   
aws cloudformation update-stack --stack-name blog-pipeline --template-body file://pipeline.yml --parameters file://cfn-parameters.json --capabilities CAPABILITY_IAM --region us-east-1
{
    "StackId": "arn:aws:cloudformation:us-east-1:046671368452:stack/blog-pipeline/b6f2c2e0-4189-11eb-afef-xxxxxx"
}

Now you should have a self-update step in your pipeline:

Deployment pipeline components with self-update.

Awesome! Now all we need to do to update our blog site is commit and push to source control and our pipeline will take care of the rest for us.

Let’s add in some parameters to pipeline.yml and see if they end up deploying themselves.

pipeline.yml

Parameters:

  GitHubOwner:
    Type: String
    Description: The username or org that owns the repo

  GitHubRepo:
    Type: String
    Description: The name of the repo as it appears in Github

  GitHubBranch:
    Type: String
    Description: The branch to pull
    Default: master

  GitHubTokenSecretName:
    Type: String
    Description: The name of the Secrets Manager secret that contains a GitHub personal access token
    Default: Github

  GitHubTokenSecretKeyName:
    Type: String
    Description: The JSON key name of the Secrets Manager value
    Default: PersonalAccessToken
    
  SiteDomainName:
    Type: String
    Description: Domain name for site e.g. mckinnel.me (no www)

  HostedZoneId:
    Type: String
    Description: Existing HostedZone's ID

  WebsiteSourceDirectory:
    Type: String
    Description: The directory name in the repo where the website source is
    Default: dist

Resources:

  PipelineBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ArtifactStore:
        Type: S3
        Location: !Ref PipelineBucket
      RestartExecutionOnUpdate: true
      RoleArn: !GetAtt CodePipelineRole.Arn
      Stages:
        - Name: Source
          Actions:
            - Name: GitHub
              ActionTypeId:
                Category: Source
                Owner: ThirdParty
                Version: 1
                Provider: GitHub
              OutputArtifacts:
                - Name: source
              Configuration:
                Owner: !Ref GitHubOwner
                Repo: !Ref GitHubRepo
                Branch: !Ref GitHubBranch
                OAuthToken: !Sub '{{resolve:secretsmanager:${GitHubTokenSecretName}:SecretString:${GitHubTokenSecretKeyName}}}'
              RunOrder: 1
        - Name: Update
          Actions:
            - Name: Pipeline
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Ref AWS::StackName
                RoleArn: !GetAtt CodePipelineRole.Arn
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                TemplatePath: source::pipeline.yml
                TemplateConfiguration: source::codepipeline-parameters.json
              RunOrder: 1
        - Name: Upload
          Actions:
            - Name: Site
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              InputArtifacts:
                - Name: source
              Configuration:
                ProjectName: !Ref CodeBuildProject
              RunOrder: 1
        - Name: Deploy
          Actions:
            - Name: Cloudfront
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Sub ${AWS::StackName}-infra
                RoleArn: !GetAtt CodePipelineRole.Arn
                TemplatePath: source::infra.yml
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
              RunOrder: 1

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:4.0
      Source:
        Type: CODEPIPELINE
        BuildSpec:
          !Sub |
            version: 0.2
            phases:
              build:
                commands:
                  - echo "fake build done!"

  CodePipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  CodeBuildRole:
    Type: AWS::IAM::Role
    DependsOn: CodePipelineRole
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
            

We'll also need to update these params in cfn-parameters.json and codepipeline-parameters.json.

cfn-parameters.json

[
  {
    "ParameterKey": "GitHubOwner",
    "ParameterValue": "chrismckinnel"
  }, 
  {
    "ParameterKey": "GitHubRepo",
    "ParameterValue": "test-blog-site"
  },
  {
    "ParameterKey": "GitHubBranch",
    "ParameterValue": "master"
  }, 
  {
    "ParameterKey": "GitHubTokenSecretName",
    "ParameterValue": "Github"
  }, 
  {
    "ParameterKey": "GitHubTokenSecretKeyName",
    "ParameterValue": "PersonalAccessToken"
  },
  {
    "ParameterKey": "WebsiteSourceDirectory",
    "ParameterValue": "dist"
  },
  {
    "ParameterKey": "SiteDomainName",
    "ParameterValue": "mckinnel.me"
  }, 
  {
    "ParameterKey": "HostedZoneId",
    "ParameterValue": "xxx"
  } 
]

codepipeline-parameters.json

{
  "Parameters": {
    "GitHubOwner": "chrismckinnel",
    "GitHubRepo": "test-blog-site",
    "GitHubBranch": "master",
    "GitHubTokenSecretName": "Github",
    "GitHubTokenSecretKeyName": "PersonalAccessToken",
    "WebsiteSourceDirectory": "dist",
    "SiteDomainName": "mckinnel.me",
    "HostedZoneId": "xxx"
  }
}

Let's push to source control:

git add .
git commit
[master b61e330] Add parameters
 1 file changed, 23 insertions(+)

git push origin master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 443 bytes | 443.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:chrismckinnel/test-blog-site.git
   94c6d3e..b61e330  master -> master

Once the pipeline has run through, we can now check the parameters section of the CloudFormation dashboard and see our parameters are now applied to the stack. Great!

Deployment pipeline components. The HostedZoneId parameter should be updated with your own Hosted Zone ID.

Now we’re ready to actually deploy our website infrastructure – all we need to do is update infra.yml, make a couple of final changes to pipeline.yml and push it all to source control.

infra.yml

Parameters:

  HostedZoneId:
    Type: String
    Description: Existing HostedZone's ID
    ConstraintDescription: Must be an AWS Route53 HostedZoneId

  SiteDomainName:
    Type: String
    Description: Domain name for site e.g. mckinnel.me (no www)
    ConstraintDescription: Must be a valid domain name

  WebsiteBucket:
    Type: String

  WebsiteBucketDomainName:
    Type: String

Resources:

  AcmCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref SiteDomainName
      SubjectAlternativeNames:
        - !Sub www.${SiteDomainName}
      ValidationMethod: DNS

  WebsiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref WebsiteBucket
      PolicyDocument:
        Statement:
        - Sid: CloudFrontAccess
          Effect: Allow
          Principal:
            CanonicalUser: !GetAtt CloudFrontIdentity.S3CanonicalUserId
          Action: s3:GetObject
          Resource: !Sub arn:aws:s3:::${WebsiteBucket}/*

  CloudFrontIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub Identity for ${SiteDomainName}

  CloudFrontDistro:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      - CloudFrontIdentity
    Properties:
      DistributionConfig:
        Comment: Cloudfront Distribution pointing to S3 bucket
        Origins:
        - DomainName: !Ref WebsiteBucketDomainName
          Id: S3Origin
          S3OriginConfig:
            OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontIdentity}
        Enabled: true
        HttpVersion: http2
        DefaultRootObject: index.html
        Aliases:
          - !Ref SiteDomainName
          - !Sub www.${SiteDomainName}
        DefaultCacheBehavior:
          AllowedMethods:
            - GET
            - HEAD
          Compress: true
          DefaultTTL: 0
          MaxTTL: 0
          MinTTL: 0
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
        ViewerCertificate:
          AcmCertificateArn: !Ref AcmCertificate
          SslSupportMethod: sni-only

  WebsiteDNSName:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneId: !Ref HostedZoneId
      RecordSets:
        - Name: !Sub ${SiteDomainName}.
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt CloudFrontDistro.DomainName
        - Name: !Sub www.${SiteDomainName}.
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt CloudFrontDistro.DomainName

pipeline.yml

Parameters:
Parameters:

  GitHubOwner:
    Type: String
    Description: The username or org that owns the repo

  GitHubRepo:
    Type: String
    Description: The name of the repo as it appears in Github

  GitHubBranch:
    Type: String
    Description: The branch to pull
    Default: master

  GitHubTokenSecretName:
    Type: String
    Description: The name of the Secrets Manager secret that contains a GitHub personal access token
    Default: Github

  GitHubTokenSecretKeyName:
    Type: String
    Description: The JSON key name of the Secrets Manager value
    Default: PersonalAccessToken

  SiteDomainName:
    Type: String
    Description: Domain name for site e.g. mckinnel.me (no www)
    ConstraintDescription: Must be a valid domain name

  HostedZoneId:
    Type: String
    Description: Existing HostedZone's ID
    ConstraintDescription: Must be an AWS Route53 HostedZoneId

  WebsiteSourceDirectory:
    Type: String
    Description: The directory name in the repo where the website source is
    Default: src

Resources:

  WebsiteBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  PipelineBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ArtifactStore:
        Type: S3
        Location: !Ref PipelineBucket
      RestartExecutionOnUpdate: true
      RoleArn: !GetAtt CodePipelineRole.Arn
      Stages:
        - Name: Source
          Actions:
            - Name: GitHub
              ActionTypeId:
                Category: Source
                Owner: ThirdParty
                Version: 1
                Provider: GitHub
              OutputArtifacts:
                - Name: source
              Configuration:
                Owner: !Ref GitHubOwner
                Repo: !Ref GitHubRepo
                Branch: !Ref GitHubBranch
                OAuthToken: !Sub '{{resolve:secretsmanager:${GitHubTokenSecretName}:SecretString:${GitHubTokenSecretKeyName}}}'
              RunOrder: 1
        - Name: Update
          Actions:
            - Name: Pipeline
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Ref AWS::StackName
                RoleArn: !GetAtt CodePipelineRole.Arn
                TemplatePath: source::pipeline.yml
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                ParameterOverrides: !Sub |
                  {
                    "SiteDomainName": "${SiteDomainName}",
                    "GitHubOwner": "${GitHubOwner}",
                    "GitHubRepo": "${GitHubRepo}",
                    "GitHubBranch": "${GitHubBranch}",
                    "HostedZoneId": "${HostedZoneId}",
                    "WebsiteSourceDirectory": "${WebsiteSourceDirectory}"
                  }
              RunOrder: 1
        - Name: Upload
          Actions:
            - Name: Site
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              InputArtifacts:
                - Name: source
              Configuration:
                ProjectName: !Ref CodeBuildProject
              RunOrder: 1
        - Name: Deploy
          Actions:
            - Name: Cloudfront
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: CloudFormation
              InputArtifacts:
                - Name: source
              Configuration:
                ActionMode: REPLACE_ON_FAILURE
                StackName: !Sub ${AWS::StackName}-site
                RoleArn: !GetAtt CodePipelineRole.Arn
                TemplatePath: source::infra.yml
                Capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPAND
                ParameterOverrides: !Sub |
                  {
                    "SiteDomainName": "${SiteDomainName}",
                    "HostedZoneId": "${HostedZoneId}",
                    "WebsiteBucketDomainName": "${WebsiteBucket.DomainName}",
                    "WebsiteBucket":" ${WebsiteBucket}"
                  }
              RunOrder: 1

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    DependsOn: CodePipelineRole
    Properties:
      Name: !Ref AWS::StackName
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:4.0
      Source:
        Type: CODEPIPELINE
        BuildSpec:
          !Sub |
            version: 0.2
            phases:
              install:
                commands:
                  - npm install
              build:
                commands:
                  - node_modules/.bin/gulp build
              post_build:
                commands:
                  - aws s3 sync ${WebsiteSourceDirectory} s3://${WebsiteBucket} --delete

  CodePipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess

  CodeBuildRole:
    Type: AWS::IAM::Role
    DependsOn: CodePipelineRole
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess

Above we have moved the creation of our Website S3 bucket into our pipeline CloudFormation template so we can pass it into our buildspec definition. This means that CodeBuild can upload our static files to S3.

I've chosen to use Start Bootstrap's Clean Blog for my website, and it uses gulp to build the website files into a dist directory.

If you've been following along, you'll need to update the buildspec section of your CodeBuild project to suit your own website.

Let's commit our changes to source control.

git add .
git commit
[master b61e330] Add website infra
 1 file changed, 23 insertions(+)

git push origin master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 443 bytes | 443.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:chrismckinnel/test-blog-site.git
   94c6d3e..b61e330  master -> master

Finally, we can see our website infrastructure deployed!

Deployed infrastructure components for the blog site.

Now if you want to make changes to your website, you just update your HTML / CSS / JS, push to GitHub and it'll automatically get published.