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.