Kensio Software Blog »

Automated static site infrastructure with AWS CloudFormation, Cloudfront, S3, Hugo, GitHub Actions

Hugh Grigg | Kensio Software | Tuesday 15 Mar 2022

After manually setting up several static sites with AWS, Hugo and GitHub Actions, I got round to putting together CloudFormation configuration to do it automatically.

This includes handling the TLS certificate in AWS ACM. Unfortunately it seems it’s either difficult or impossible to do this in a single CloudFormation stack, because the ACM certificate must be created in the us-east-1 region, and there’s no straightforward way to then depend on and access that resource for resources being deployed in another region (eu-west-2 in my case).

Because of that, I’ve split it into two separate CloudFormation stacks, one that handles the Hosted Zone in Route53 and the ACM Certificate for it, which is deployed in us-east-1, and then another stack for everything else, which is deployed in eu-west-2.

The CloudFormation template for the Route 53 Hosted Zone and ACM Certificate looks like this:

AWSTemplateFormatVersion: "2010-09-09"
Description: Hosted Zone and ACM Certificate for static website.

# NOTE: If the domain name is not registered in Route 53, you'll need to begin
# the creation of this stack, wait for the Route53 Hosted Zone to be created,
# then go and take the name servers for it in Route 53 and set them in the
# registrar (e.g. Namecheap) so that DNS validation of the ACM Certificate can
# complete. It can take more than 30 minutes for the validation to happen after
# this.

Parameters:
  DomainName:
    Type: String
    Description: The domain name for the static site.

Resources:

  Route53HostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      Name: !Ref DomainName

  DomainCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName
      ValidationMethod: DNS
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          HostedZoneId: !Ref Route53HostedZone

Outputs:
  CertificateArn:
    Value: !Ref DomainCertificate
    Description: ARN of ACM Certificate created inside StackSet.
    Export:
      Name: !Sub ${AWS::StackName}-cert-arn

You can deploy that with your choice of domain name like this:

export DOMAIN_NAME='example.com' && \
export ZONE_CERT_STACK='foobar-zone-cert-stack-name' && \
aws cloudformation create-stack \
  --region 'us-east-1' \
  --stack-name "${ZONE_CERT_STACK}" \
  --template-body file://path/to/domain-certificate.cloudformation.yml \
  --parameters ParameterKey=DomainName,ParameterValue="${DOMAIN_NAME}" \
  && \
aws cloudformation --region 'us-east-1' wait stack-create-complete --stack-name "${ZONE_CERT_STACK}" && \
aws cloudformation --region 'us-east-1' describe-stacks --stack-name "${ZONE_CERT_STACK}"

Note that if the domain name is not registered in Route 53, you’ll need to begin the creation of this stack, wait for the Route53 Hosted Zone to be created, then go and take the name servers for it in Route 53 and set them in the registrar (e.g. Namecheap) so that DNS validation of the ACM Certificate can complete. It can take more than 30 minutes for the validation to happen after this, because it has to wait for DNS to update.

Once that stack has deployed, you can find the ARN of the new ACM Certificate like this:

aws acm list-certificates --region 'us-east-1' \
  --query 'CertificateSummaryList[].[CertificateArn,DomainName]' --output text \
  | grep "${DOMAIN_NAME}"

You can manually store that Certificate ARN in an environment variable, or do it automatically in one command:

export CERT_ARN=$( aws acm list-certificates --region 'us-east-1' \
  --query 'CertificateSummaryList[].[CertificateArn,DomainName]' --output text \
  | grep "${DOMAIN_NAME}" | cut -f1 )

Then we use the main CloudFormation template to set up all the other AWS infrastructure for the static site. The template looks like this:

AWSTemplateFormatVersion: "2010-09-09"
Description: Static website set up.

Parameters:
  DomainName:
    Type: String
    Description: The domain name for the static site.
  CertificateArn:
    Type: String
    Description: ARN of the ACM Certificate.

Mappings:
  Region2S3WebsiteSuffix:
    us-east-1:
      Suffix: .s3-website-us-east-1.amazonaws.com
    us-west-1:
      Suffix: .s3-website-us-west-1.amazonaws.com
    us-west-2:
      Suffix: .s3-website-us-west-2.amazonaws.com
    eu-west-1:
      Suffix: .s3-website-eu-west-1.amazonaws.com
    ap-northeast-1:
      Suffix: .s3-website-ap-northeast-1.amazonaws.com
    ap-northeast-2:
      Suffix: .s3-website-ap-northeast-2.amazonaws.com
    ap-southeast-1:
      Suffix: .s3-website-ap-southeast-1.amazonaws.com
    ap-southeast-2:
      Suffix: .s3-website-ap-southeast-2.amazonaws.com
    ap-south-1:
      Suffix: .s3-website-ap-south-1.amazonaws.com
    us-east-2:
      Suffix: .s3-website-us-east-2.amazonaws.com
    sa-east-1:
      Suffix: .s3-website-sa-east-1.amazonaws.com
    cn-north-1:
      Suffix: .s3-website.cn-north-1.amazonaws.com.cn
    eu-central-1:
      Suffix: .s3-website.eu-central-1.amazonaws.com
    eu-west-2:
      Suffix: .s3-website.eu-west-2.amazonaws.com

Resources:

  WebsiteDNSName:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Join [ '', [ !Ref DomainName, . ] ]
      Comment: CNAME redirect custom name to CloudFront distribution
      Name: !Ref DomainName
      Type: A
      AliasTarget:
      	# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt WebsiteCDN.DomainName

  S3BucketForWebsiteContent:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref DomainName
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html

  WebsiteCDN:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: !Ref DomainName
        Aliases:
          - !Ref DomainName
        Enabled: true
        PriceClass: PriceClass_100
        HttpVersion: http2
        DefaultCacheBehavior:
          ViewerProtocolPolicy: redirect-to-https
          # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
          TargetOriginId: only-origin
          Compress: true
        Origins:
          - CustomOriginConfig:
              OriginProtocolPolicy: http-only
            DomainName: !Join ['',[
              !Ref 'S3BucketForWebsiteContent',
              !FindInMap [Region2S3WebsiteSuffix,!Ref 'AWS::Region', Suffix]
            ]]
            Id: only-origin
        CNAMEs:
          - !Ref DomainName
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          MinimumProtocolVersion: TLSv1.2_2019
          SslSupportMethod: sni-only

  UpdateSiteUser:
    Type: AWS::IAM::User

  UpdateSiteUserAccessKey:
    Type: AWS::IAM::AccessKey
    Properties:
      UserName: !Ref UpdateSiteUser

  UpdateSiteUserAccessKeySecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub ${AWS::StackName}-update-site-user-access-key-secret
      Description: !Sub "Access key credentials for ${AWS::StackName} site update user."
      SecretString: !Sub
        - '{"AccessKeyId":"${AccessKeyId}","SecretAccessKey":"${SecretAccessKey}"}'
        - AccessKeyId: !Ref UpdateSiteUserAccessKey
          SecretAccessKey: !GetAtt UpdateSiteUserAccessKey.SecretAccessKey

  UpdateSitePolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Sub update-${AWS::StackName}-site-policy
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - s3:PutObject
              - s3:PutBucketPolicy
              - s3:ListBucket
              - s3:DeleteObject
              - s3:PutObjectAcl
              - cloudfront:CreateInvalidation
              - s3:GetBucketPolicy
            Resource:
              - !Sub
                - "arn:aws:cloudfront::${AWS::AccountId}:distribution/${WebsiteCDN}"
                - WebsiteCDN: !Ref WebsiteCDN
              - !GetAtt S3BucketForWebsiteContent.Arn
              - !Join ["", [!GetAtt S3BucketForWebsiteContent.Arn, "/*"]]
      Users:
        - !Ref UpdateSiteUser

Outputs:

  BucketName:
    Description: Name of S3 bucket for website content.
    Value: !Ref S3BucketForWebsiteContent

  CloudfrontDistribution:
    Description: CloudFront distribution ID.
    Value: !Ref WebsiteCDN

Note that for the next step, the CERT_ARN environment variable needs to be set as described above. You can then deploy a stack from that template like this:

export DOMAIN_NAME='example.com' && \
export MAIN_STACK='foobar-stack-name' && \
aws cloudformation --region 'eu-west-2' create-stack \
  --stack-name "${MAIN_STACK}" \
  --template-body file://path/to/cloudformation.yml \
  --capabilities CAPABILITY_IAM \
  --parameters ParameterKey=DomainName,ParameterValue="${DOMAIN_NAME}" \
               ParameterKey=CertificateArn,ParameterValue="${CERT_ARN}" \
  && \
aws cloudformation --region 'eu-west-2' wait stack-create-complete --stack-name "${MAIN_STACK}" && \
aws cloudformation --region 'eu-west-2' describe-stacks --stack-name "${MAIN_STACK}"

That stack includes an IAM user with granular permissions for updating the static site, and an access key for that user. To get the access key id and secret access key for the site update user:

export MAIN_STACK='foobar-stack-name' && \
aws secretsmanager get-secret-value --secret-id "${MAIN_STACK}-update-site-user-access-key-secret"

Take those access key credentials and use them set two secrets in the GitHub repo for the project: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.

After that, add a GitHub Action workflow in the project at .github/workflows/update-site.yml. The content of the GitHub Action file is:

name: Update site

on: push

jobs:
  build:
    name: Build and Deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Install Hugo
        run: |
          HUGO_DOWNLOAD=hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz
          wget https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${HUGO_DOWNLOAD}
          tar xvzf ${HUGO_DOWNLOAD} hugo
          mv hugo $HOME/hugo
        env:
          HUGO_VERSION: 0.94.2
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '16.x'
      - name: NPM install
        run: |
          npm install
          npm install -g postcss-cli autoprefixer
      - name: Hugo Build
        run: $HOME/hugo -v
      - name: Deploy to S3
        if: github.ref == 'refs/heads/main'
        run: aws s3 sync public/ s3://example.com/ --delete --acl=public-read && aws cloudfront create-invalidation --distribution-id=ABC123CFDISTID --paths=/*
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_EC2_METADATA_DISABLED: true

Note that you need to set the S3 bucket name and CloudFront Distribution ID in the GitHub Action file.

You should now have push-to-deploy with the static site content being generated in the Github Action and then pushed to S3.

If you need any help with AWS development work, then contact me.