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
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
- create S3 bucket using a domain name as the name
- Set permissions to public (unless using CloudFront OAIs)
- 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
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
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
Since I wrote this article, CloudFormation now support issuing ACM certificates - I'll update the above template soon to reference this.