Hosting a Hugo site on Amazon Web Services

Overview

I posted recently that I've switched from using WordPress to host my blog to using Hugo. One of the major reasons for this is that as someone who works with AWS, I can quickly spin up infrastructure to host a static website at fairly low cost.

Storage

AWS S3:right
At it's simplest, Hugo needs somewhere to store the content that it generates. In the AWS world, the best option is an S3 bucket. Not only does S3 provide cheap storage (approx. $0.024 per GB per month), it can also host it as a static website. This means that creating a website can be as straightforward as

  1. create S3 bucket using a domain name as the name
  2. Set permissions to public (unless using CloudFront OAIs)
  3. Configure Static hosting

Running the hugo command will convert your content to static pages, this can then simply be copied to the s3 bucket (assuming you have the AWS CLI tool and appropriate credentials configured) by using a command such as

1aws s3 sync public s3://<insert_bucket_name>

Caching / CDN

AWS CloudFront and S3:right
As we mentioned, Hugo generates static websites - as such, it's perfect content to use with a CDN (Content Distribution Network). Luckily enough, we have a perfect candidate within AWS with CloudFront.

CloudFront is a low-cost CDN hosted by AWS and integrates easily with S3 and ensures that once content has been requested, a copy is held and shared, rather than pulling from S3 on every request. Even better, if you combine CloudFront with AWS Certificate Manager (ACM), it's possible to create a free managed SSL certificate allowing you to serve the site via HTTPs.

DNS

Route 53, CloudFront and S3:right
When using CloudFront, the content will be available via a .cloudfront.net domain. To use this with custom domains, such as http://simonhanmer.com, we need to use DNS to forward requests from the custom domain to the CloudFront URL (normally via a CNAME).

Route53 is AWS's DNS service. Setting this up involves creating a zone in Route53 for each domain and then configuring your domain registrar to point the domain to the AWS DNS nameservers.

Pricing

Pricing within AWS is dependent on where we deploy - I host a live and test site using the eu-west-2 (London) region since that's closest to me. Currently, my hosting is costing me approximately $0.60 per month which is made up of:

  • S3 Storage: Transfer costs $0.08 - storage currently falls under the Free Tier, but I'd expect this to be about $0.02 per month.
  • CloudFront: Free - we're allowed upto 1TB of data transfer free per month.
  • Route 53: This is my most expensive cost, a Route 53 Zone costs $0.50 per month.

Infrastructure As Code

All of the above, could be deployed manually fairly easily but I'm lazy and prefer to use Infrastructure As Code (IaC) wherever possible. As I mentioned above, I have a live and test site which I deploy but the template below can be modified easily if you want a single site.

Before deploying the above, I manually create a certificate within ACM covering the domain and all subdomains (in my case simonhanmer.com and *.simonhanmer.com). Once the certificate has been created, I can use the following CloudFormation template to deploy the required infrastructure:

  1AWSTemplateFormatVersion: '2010-09-09'
  2Transform: AWS::Serverless-2016-10-31
  3Description: >
  4    Deploy hugo to test & live website.
  5
  6Parameters:
  7  liveDomain:
  8    Type: String
  9    Description: Domain for live website
 10
 11  testDomain:
 12    Type: String
 13    Description: Domain for test website
 14
 15  indexDocument:
 16    Type: String
 17    Description: Name of index page 
 18    Default: index.html
 19
 20  certificateARN:
 21    Type: String
 22    Description: ARN of certificate for website
 23
 24  priceClass:
 25    Type: String
 26    Default: PriceClass_100
 27    AllowedValues:
 28      - PriceClass_100
 29      - PriceClass_200
 30      - PriceClass_All
 31
 32  hostedZoneId:
 33    Type: String
 34    Description: ID of hosted zone for domain in Route53
 35
 36Metadata: 
 37  AWS::CloudFormation::Interface:
 38    ParameterGroups:
 39      - Label:
 40          default: Domain information
 41        Parameters:
 42          - liveDomain
 43          - testDomain
 44      - Label:
 45          default: Cloudfront information
 46        Parameters:
 47          - certificateARN
 48          - hostedZoneId
 49          - priceClass
 50          - indexDocument
 51
 52Resources:
 53  liveBucket:
 54    Type: AWS::S3::Bucket
 55    Properties:
 56      AccessControl:            PublicRead
 57      BucketName:               !Ref liveDomain
 58      WebsiteConfiguration:
 59        IndexDocument:          !Ref indexDocument
 60      PublicAccessBlockConfiguration:
 61        BlockPublicAcls:        false
 62        BlockPublicPolicy:      false
 63        IgnorePublicAcls:       false
 64        RestrictPublicBuckets:  false
 65
 66  liveBucketPolicy:
 67    Type: AWS::S3::BucketPolicy
 68    Properties:
 69      Bucket:                   !Ref liveBucket
 70      PolicyDocument:
 71        Id:                     PublicRead
 72        Version:                2012-10-17
 73        Statement:
 74          - Sid:        PublicRead
 75            Effect:     Allow
 76            Principal:  '*'
 77            Action:     's3:GetObject'
 78            Resource: !Join
 79              - /
 80              - - !GetAtt liveBucket.Arn
 81                - '*'
 82
 83  testBucket:
 84    Type: AWS::S3::Bucket
 85    Properties:
 86      AccessControl:            PublicRead
 87      BucketName:               !Ref testDomain
 88      WebsiteConfiguration:
 89        IndexDocument:          !Ref indexDocument
 90      PublicAccessBlockConfiguration:
 91        BlockPublicAcls:        false
 92        BlockPublicPolicy:      false
 93        IgnorePublicAcls:       false
 94        RestrictPublicBuckets:  false
 95
 96  testBucketPolicy:
 97    Type: AWS::S3::BucketPolicy
 98    Properties:
 99      Bucket:     !Ref testBucket
100      PolicyDocument:
101        Id:       PublicRead
102        Version:  2012-10-17
103        Statement:
104          - Sid:        PublicRead
105            Effect:     Allow
106            Principal:  '*'
107            Action:     's3:GetObject'
108            Resource: !Join
109              - /
110              - - !GetAtt testBucket.Arn
111                - '*'
112
113  liveCloudFrontDistribution:
114    Type: AWS::CloudFront::Distribution
115    Properties:
116      DistributionConfig:
117        Enabled: true
118        Aliases:
119          - !Ref liveDomain
120        Origins:
121          - DomainName: !Sub '${liveBucket}.s3-website.${AWS::Region}.amazonaws.com'
122            CustomOriginConfig:
123              HTTPPort: 80
124              HTTPSPort: 443
125              OriginProtocolPolicy: 'http-only'
126            Id: S3BucketOrigin
127        DefaultCacheBehavior:
128          TargetOriginId: S3BucketOrigin
129          Compress: true
130          ViewerProtocolPolicy: redirect-to-https
131          ForwardedValues:
132            QueryString: false
133          AllowedMethods:
134            - GET
135            - HEAD
136            - OPTIONS
137        DefaultRootObject: !Ref indexDocument
138        HttpVersion: http2
139        PriceClass: !Ref priceClass
140        ViewerCertificate:
141          AcmCertificateArn: !Ref certificateARN
142          SslSupportMethod: sni-only
143
144  testCloudFrontDistribution:
145    Type: AWS::CloudFront::Distribution
146    Properties:
147      DistributionConfig:
148        Enabled: true
149        Aliases:
150          - !Ref testDomain
151        Origins:
152          - DomainName: !Sub '${testBucket}.s3-website.${AWS::Region}.amazonaws.com'
153            CustomOriginConfig:
154              HTTPPort: 80
155              HTTPSPort: 443
156              OriginProtocolPolicy: 'http-only'
157            Id: S3BucketOrigin
158        DefaultCacheBehavior:
159          TargetOriginId: S3BucketOrigin
160          Compress: true
161          ViewerProtocolPolicy: redirect-to-https
162          ForwardedValues:
163            QueryString: false
164          AllowedMethods:
165            - GET
166            - HEAD
167            - OPTIONS
168        DefaultRootObject: !Ref indexDocument
169        HttpVersion: http2
170        PriceClass: !Ref priceClass
171        ViewerCertificate:
172          AcmCertificateArn: !Ref certificateARN
173          SslSupportMethod: sni-only
174
175  liveRoute53:
176    Type: AWS::Route53::RecordSet
177    Properties:
178      AliasTarget:
179        DNSName: !GetAtt liveCloudFrontDistribution.DomainName
180        HostedZoneId: Z2FDTNDATAQYW2
181      Type: A
182      HostedZoneId: !Ref hostedZoneId
183      Name: !Ref liveDomain
184
185  testRoute53:
186    Type: AWS::Route53::RecordSet
187    Properties:
188      AliasTarget:
189        DNSName: !GetAtt testCloudFrontDistribution.DomainName
190        HostedZoneId: Z2FDTNDATAQYW2
191      Type: A
192      HostedZoneId: !Ref hostedZoneId
193      Name: !Ref testDomain
194
195
196Outputs:
197  liveCloudfrontDistribution:
198    Description:  ID of live Cloudfront Distribution
199    Value: !Ref   liveCloudFrontDistribution
200  testCloudfrontDistribution:
201    Description:  ID of live Cloudfront Distribution
202    Value: !Ref   testCloudFrontDistribution
Update

Since I wrote this article, CloudFormation now support issuing ACM certificates - I'll update the above template soon to reference this.


comments powered by Disqus