Deploy Static Website to Amazon S3
Tags: aws
Published: 2020-07-22 12:12:45

---
title: Deploy Static Website to Amazon S3
published: true
date: 2020-07-20 22:43:42 UTC
tags: aws
canonical_url: https://macksmind.io/aws/2020/07/20/deploy-static-website-to-amazon-s3.html
---

 **TL;DR** —CloudFormation template that autodeploys MacksMind.io. Download from [GitHub](https://gist.github.com/MacksMind/c1dbc34d92aed1db892d23e758aa4637).

## Why CloudFormation?

Setting up static deploy to S3 usually involves many steps in the AWS UI, then using `aws s3 sync` locally to deploy changes. Here I demonstrate how to automate `jekyll build` in CodePipeline/CodeBuild, and also how to create all the resources in a single CloudFormation template. It creates between 12 and 20 resources depending on your selections.

Yes it’s overkill for a Jekyll site, but it’s also a starting point for related use cases that only need a single pipeline from a single GitHub branch. What follows is section by section commentary to help you with that adaptation.

## Parameters

Template sharing & reuse is all about parameters. If you’re deploying a [Jekyll](https://jekyllrb.com/) site, you might not have to change a thing. Some of the less obvious bits are:

- GitHubSecret    
  - Create an OAuth token for GitHub using steps 1-6 of [these instructions](https://docs.aws.amazon.com/codepipeline/latest/userguide/GitHub-create-personal-token-CLI.html)
  - Store the token in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/getting-started/) as “Other type of secrets” without automatic rotation
  - Construct a parameter resembling `{{resolve:secretsmanager:macksmind.io:SecretString:github-token}}`, in which `macksmind.io` is the _secret name_, and `github-token` is the _key_ pointing to the token
  - Of course you **can** paste the token directly into the parameter if you remove `AllowedPattern`, but you **shouldn’t**
- ChatbotSlackArn    
  - To receive Slack notifications when deploys start and finish, link to Slack using [these instructions](https://docs.aws.amazon.com/chatbot/latest/adminguide/getting-started.html)
  - Once the channel is configured, paste the ARN into the parameter

```
AWSTemplateFormatVersion: 2010-09-09
Parameters:
  GitHubOwner:
    Type: String
    Description: GitHub repo owner
    MinLength: 1
  GitHubRepo:
    Type: String
    Description: GitHub repo name
    MinLength: 1
  GitHubBranch:
    Type: String
    Default: master
    Description: GitHub branch name
    MinLength: 1
  GitHubSecret:
    Type: String
    Description: Reference to AWS Secrets Manager
    AllowedPattern: "\\{\\{resolve:secretsmanager:.+:SecretString:.+\\}\\}"
  Subdomain:
    Type: String
    Default: www
    Description: Subdomain of URL (optional)
  RootDomain:
    Type: String
    Description: Root domain of URL
  IndexPage:
    Type: String
    Default: index.html
    Description: Default page in any directory
    MinLength: 1
  ErrorPage:
    Type: String
    Default: 404.html
    Description: Page not found path (optional, but highly recommended)
  Route53:
    Type: String
    Description: Automatically configure DNS in Route53?
    AllowedValues:
    - true
    - false
    Default: false
  RedirectRoot:
    Type: String
    Description: Redirect RootDomain to Subdomain?
    AllowedValues:
    - true
    - false
    Default: false
  JekyllEnv:
    Type: String
    Description: Jekyll build environment
    Default: production
    AllowedValues:
    - production
    - development
  ChatbotSlackArn:
    Type: String
    Description: AWS Chatbot Channel ARN (optional)
```

## Metadata

Controls parameter display in CloudFormation UI. Without this, parameters display alphabetically. 🙁

```
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
    - Label:
        default: GitHub Configuration
      Parameters:
      - GitHubOwner
      - GitHubRepo
      - GitHubBranch
      - GitHubSecret
    - Label:
        default: Website Info
      Parameters:
      - Subdomain
      - RootDomain
      - IndexPage
      - ErrorPage
    - Label:
        default: DNS Details
      Parameters:
      - Route53
      - RedirectRoot
```

## Conditions

- Conditions and parameters are separate namespaces, permitting a Boolean such as `Route53` to match the String parameter it’s derived from
- Note the condition chaining using `Fn::And`

```
Conditions:
  HasSubdomain:
    Fn::Not:
    - Fn::Equals:
      - ''
      - Ref: Subdomain
  HasErrorPage:
    Fn::Not:
    - Fn::Equals:
      - ''
      - Ref: ErrorPage
  RedirectRoot:
    Fn::And:
    - Condition: HasSubdomain
    - Fn::Equals:
      - 'true'
      - Ref: RedirectRoot
  Route53:
    Fn::Equals:
    - 'true'
    - Ref: Route53
  Route53Redirect:
    Fn::And:
    - Condition: Route53
    - Condition: RedirectRoot
  Chatbot:
    Fn::Not:
    - Fn::Equals:
      - ''
      - Ref: ChatbotSlackArn
```

## Certificate

### Manual Step Alert!!

- Certificate validation via DNS requires creation of a cryptic CNAME in the root domain
- Once creation of your stack is under way, visit the Route53 console to view the pending certificate
- Expand the certificate to view the CNAME
- Create the CNAME in your DNS _or_ if your’re using Route53, AWS will create it for you with the push of a button

```
Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName:
        Fn::If:
        - HasSubdomain
        - Fn::Sub: "${Subdomain}.${RootDomain}"
        - Ref: RootDomain
      DomainValidationOptions:
      - DomainName:
          Fn::If:
          - HasSubdomain
          - Fn::Sub: "${Subdomain}.${RootDomain}"
          - Ref: RootDomain
        ValidationDomain:
          Ref: RootDomain
      ValidationMethod: DNS
```

## Deploy Bucket

- `WebsiteConfiguration` defines handling for requests that don’t have an exact match
- `AWS::NoValue` essentially unsets `ErrorDocument`, but be warned the default 404 is cryptic
- `DeployBucketPolicy` bravely lets all the world read, but not list

```
  DeployBucket:
    Type: AWS::S3::Bucket
    Properties:
      WebsiteConfiguration:
        IndexDocument:
          Ref: IndexPage
        ErrorDocument:
          Fn::If:
          - HasErrorPage
          - Ref: ErrorPage
          - Ref: AWS::NoValue
  DeployBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket:
        Ref: DeployBucket
      PolicyDocument:
        Statement:
        - Action:
          - s3:GetObject
          Effect: Allow
          Principal: "*"
          Resource:
            Fn::Sub: "${DeployBucket.Arn}/*"
```

## CloudFront Distribution

- `Compress` turns on automatic compression
- `MinTTL` sets the cache to 24 hours even though browsers see `cache-control: no-cache`
- `ViewerProtocolPolicy` handles https redirection while `OriginProtocolPolicy` avoids attempting https with S3
- `DomainName` uses the public hostname of the bucket, because `WebsiteConfiguration` has no effect on S3 access using AWS internals

```
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases:
        - Fn::If:
          - HasSubdomain
          - Fn::Sub: "${Subdomain}.${RootDomain}"
          - Ref: RootDomain
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          Compress: true
          ForwardedValues:
            QueryString: false
          MinTTL: 86400
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
        Enabled: true
        Origins:
        - DomainName:
            Fn::Sub: "${DeployBucket}.s3-website-${AWS::Region}.amazonaws.com"
          Id: S3Origin
          CustomOriginConfig:
            OriginProtocolPolicy: http-only
        HttpVersion: http2
        PriceClass: PriceClass_100
        ViewerCertificate:
          AcmCertificateArn:
            Ref: Certificate
          MinimumProtocolVersion: TLSv1.2_2018
          SslSupportMethod: sni-only
```

## DNS RecordSets

- `Condition: Route53` makes these resources optional
- A & AAAA to cover IPv4 and IPv6
- Using Route53 aliases instead of CNAME for a shorter lookup    
  - If your DNS is _not_ at Route53, you’ll need CNAMEs pointing to the CloudFront `DomainName`

```
  DistributionIP4:
    Type: AWS::Route53::RecordSet
    Condition: Route53
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
          - Distribution
          - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName:
        Fn::Sub: "${RootDomain}."
      Name:
        Fn::If:
        - HasSubdomain
        - Fn::Sub: "${Subdomain}.${RootDomain}"
        - Ref: RootDomain
      Type: A
  DistributionIP6:
    Type: AWS::Route53::RecordSet
    Condition: Route53
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
          - Distribution
          - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName:
        Fn::Sub: "${RootDomain}."
      Name:
        Fn::If:
        - HasSubdomain
        - Fn::Sub: "${Subdomain}.${RootDomain}"
        - Ref: RootDomain
      Type: AAAA
```

## CodePipeline

The magic starts here.

- PollForSourceChanges turns off polling since we define a Webhook below

```
  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      ArtifactStore:
        Location:
          Ref: PipelineBucket
        Type: S3
      RoleArn:
        Fn::GetAtt:
        - PipelineRole
        - Arn
      Stages:
      - Actions:
        - ActionTypeId:
            Category: Source
            Owner: ThirdParty
            Provider: GitHub
            Version: 1
          Configuration:
            Owner:
              Ref: GitHubOwner
            Repo:
              Ref: GitHubRepo
            Branch:
              Ref: GitHubBranch
            OAuthToken:
              Ref: GitHubSecret
            PollForSourceChanges: false
          Name: GitHubCommit
          OutputArtifacts:
          - Name: SourcePipe
        Name: Source
      - Actions:
        - Name: JekyllDeploy
          ActionTypeId:
            Category: Build
            Owner: AWS
            Provider: CodeBuild
            Version: 1
          Configuration:
            ProjectName:
              Ref: Project
          InputArtifacts:
          - Name: SourcePipe
        Name: Build
```

## Pipeline Bucket

- CodePipeline stores the code here for CodeBuild
- CodeBuild also caches here as you’ll see below

```
  PipelineBucket:
    Type: AWS::S3::Bucket
    Properties:
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
```

## Pipeline Role & Policy

All the perms needed to orchestrate the build

```
  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action: sts:AssumeRole
          Effect: Allow
          Principal:
            Service: codepipeline.amazonaws.com
        Version: 2012-10-17
  PipelineRoleDefaultPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
        - Action:
          - s3:GetObject*
          - s3:GetBucket*
          - s3:List*
          - s3:DeleteObject*
          - s3:PutObject*
          - s3:Abort*
          Effect: Allow
          Resource:
          - Fn::GetAtt:
            - PipelineBucket
            - Arn
          - Fn::Sub: "${PipelineBucket.Arn}/*"
        - Action:
          - codebuild:BatchGetBuilds
          - codebuild:StartBuild
          - codebuild:StopBuild
          Effect: Allow
          Resource:
            Fn::GetAtt:
            - Project
            - Arn
        Version: 2012-10-17
      PolicyName: PipelineRoleDefaultPolicy
      Roles:
      - Ref: PipelineRole
```

## Webhook

Automatically manages webhooks from your GitHub repo.

```
  PipelineWebhook:
    Type: AWS::CodePipeline::Webhook
    Properties:
      Authentication: GITHUB_HMAC
      AuthenticationConfiguration:
        SecretToken:
          Ref: GitHubSecret
      Filters:
      - JsonPath: "$.ref"
        MatchEquals: refs/heads/{Branch}
      TargetAction: GitHubCommit
      TargetPipeline:
        Ref: Pipeline
      TargetPipelineVersion:
        Fn::GetAtt:
        - Pipeline
        - Version
      RegisterWithThirdParty: true
```

## CodeBuild Project

The magic continues here.

- `Cache` property, `bundle config set path`, and `cache` key in `BuildSpec` avoid unneeded gem installs    
  - `node_modules` and `npm install` would work in a similar way
  - Simulate `rm -rf node_modules` by deleting the cache from the Pipeline Bucket
- `--cache-control='no-cache'` is set during `aws s3 sync`
- ` aws cloudfront create-invalidation` refreshes the cache

```
  Project:
    Type: AWS::CodeBuild::Project
    Properties:
      Name:
        Ref: AWS::StackName
      Artifacts:
        Type: CODEPIPELINE
      Cache:
        Location:
          Fn::Sub: "${PipelineBucket}/build_cache"
        Type: S3
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:4.0
        Type: LINUX_CONTAINER
      ServiceRole:
        Fn::GetAtt:
        - ProjectRole
        - Arn
      Source:
        BuildSpec:
          Fn::Sub: |
            version: 0.2
            phases:
              install:
                runtime-versions:
                  ruby: 2.7
                commands:
                  - mkdir -p _bundle_cache
                  - bundle config set path _bundle_cache
                  - bundle config set without 'development test'
                  - bundle install
              build:
                commands:
                  - JEKYLL_ENV=${JekyllEnv} bundle exec jekyll build
              post_build:
                commands:
                  - aws s3 sync --cache-control='no-cache' _site s3://${DeployBucket}/ --delete
                  - aws cloudfront create-invalidation --distribution-id ${Distribution} --paths '/*'
            cache:
              paths:
              - '_bundle_cache/**/*'
        Type: CODEPIPELINE
```

## CodeBuild Role & Policy

Lots of perms for all the CodeBuild things.

```
  ProjectRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action: sts:AssumeRole
          Effect: Allow
          Principal:
            Service: codebuild.amazonaws.com
        Version: 2012-10-17
  ProjectRoleDefaultPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
        - Action:
          - s3:GetObject*
          - s3:GetBucket*
          - s3:List*
          - s3:DeleteObject*
          - s3:PutObject*
          - s3:Abort*
          Effect: Allow
          Resource:
          - Fn::GetAtt:
            - PipelineBucket
            - Arn
          - Fn::Sub: "${PipelineBucket.Arn}/*"
        - Action:
          - s3:List*
          - s3:DeleteObject*
          - s3:PutObject*
          - s3:Abort*
          Effect: Allow
          Resource:
          - Fn::GetAtt:
            - DeployBucket
            - Arn
          - Fn::Sub: "${DeployBucket.Arn}/*"
        - Action:
          - logs:CreateLogGroup
          - logs:CreateLogStream
          - logs:PutLogEvents
          Effect: Allow
          Resource:
          - Fn::Sub: arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/${Project}
          - Fn::Sub: arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/${Project}:*
        - Action: cloudfront:CreateInvalidation
          Effect: Allow
          Resource:
            Fn::Sub: arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}
        Version: 2012-10-17
      PolicyName: ProjectRoleDefaultPolicy
      Roles:
      - Ref: ProjectRole
```

## Slack Notification

- AWS sends status updates and a link to the build
- Love to see that ✅

```
  ProjectNotification:
    Type: AWS::CodeStarNotifications::NotificationRule
    Condition: Chatbot
    Properties:
      DetailType: FULL
      EventTypeIds:
      - codebuild-project-build-state-failed
      - codebuild-project-build-state-succeeded
      - codebuild-project-build-state-in-progress
      - codebuild-project-build-state-stopped
      Name:
        Fn::Sub: "${AWS::StackName}-notification"
      Resource:
        Fn::GetAtt:
        - Project
        - Arn
      Targets:
      - TargetAddress:
          Ref: ChatbotSlackArn
        TargetType: AWSChatbotSlack
```

## Redirect Root Domain

A few key changes allow redirection to the primary hostname.

- This Certificate _also_ needs a CNAME for validation
- `RedirectAllRequestsTo` in S3 is the key difference from the deploy bucket
- https redirect is handled by S3 instead of CloudFront for fewer round trips
- Oddly the bucket doesn’t need to be public ¯\_(ツ)\_/¯

```
  CertificateRedirect:
    Type: AWS::CertificateManager::Certificate
    Condition: RedirectRoot
    Properties:
      DomainName:
        Ref: RootDomain
      DomainValidationOptions:
      - DomainName:
          Ref: RootDomain
        ValidationDomain:
          Ref: RootDomain
      ValidationMethod: DNS
  DeployBucketRedirect:
    Type: AWS::S3::Bucket
    Condition: RedirectRoot
    Properties:
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      WebsiteConfiguration:
        RedirectAllRequestsTo:
          HostName:
            Fn::Sub: "${Subdomain}.${RootDomain}"
          Protocol: https
  DistributionRedirect:
    Type: AWS::CloudFront::Distribution
    Condition: RedirectRoot
    Properties:
      DistributionConfig:
        Aliases:
        - Ref: RootDomain
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          ForwardedValues:
            QueryString: false
          TargetOriginId: S3OriginRedirect
          ViewerProtocolPolicy: allow-all
        Enabled: true
        Origins:
        - DomainName:
            Fn::Sub: "${DeployBucketRedirect}.s3-website-${AWS::Region}.amazonaws.com"
          Id: S3OriginRedirect
          CustomOriginConfig:
            OriginProtocolPolicy: http-only
        HttpVersion: http2
        PriceClass: PriceClass_100
        ViewerCertificate:
          AcmCertificateArn:
            Ref: CertificateRedirect
          MinimumProtocolVersion: TLSv1.2_2018
          SslSupportMethod: sni-only
  DistributionIP4Redirect:
    Type: AWS::Route53::RecordSet
    Condition: Route53Redirect
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
          - DistributionRedirect
          - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName:
        Fn::Sub: "${RootDomain}."
      Name:
        Ref: RootDomain
      Type: A
  DistributionIP6Redirect:
    Type: AWS::Route53::RecordSet
    Condition: Route53Redirect
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
          - DistributionRedirect
          - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName:
        Fn::Sub: "${RootDomain}."
      Name:
        Ref: RootDomain
      Type: AAAA
```