Hexo, AWS and Serverless Framework

This post was initially going to be the first post on this blog, but during writing it became apparent that it was necessary to describe the development environment first. This post will describe the process used to create the blog and publish it to AWS. Each step will describe the process for an Apple Mac, but links will be included to enable the process to be completed on any common operating system. This post assumes that all pre-requisites from macOS Development Environment have been installed.

Architecture

A decision was made early in the creation of this blog to utilise a static site generator for the majority of pages in this site, to use AWS S3 with AWS CloudFormation for hosting and to run deployments through Serverless Framework to enable the easy addition of AWS Lambda functions at a later date if required.

After trialing a number of different static site generators it was eventually decided to use Hexo due to its simplicity.

Initial Set-up

This post assumes that a development environment similar to the one described in macOS Development Environment has been set-up.

Install Hexo

The installation of Hexo is simple; however, there are two options for installing it. The remainder of this post will assume Hexo has been installed globally.

Install Hexo globally
1
npm i -g hexo-cli

Install Hexo locally so it can be run with npx
1
npm i hexo-cli --save-dev

Install Serverless Framework

The installation of Serverless Framework is similar to Hexo. Again, for the rest of this post it will be assumed that Serverless Framework has been installed globally.

Install Serverless Framework globally
1
npm i -g serverless

Install Serverless Framework locally so it can be run with npx
1
npm i serverless --save-dev

Blog Creation

Once all the pre-requisites are set-up it's time to begin the project.

Initialise Hexo Project

The first step is to create the Hexo project. The initialisation command will create the project directory, so if you keep all of your projects in ~/code you will need to be within this directory when you run the command.

Initialise Hexo (replace <project> with your project name)
1
hexo init <project>

Configure Serverless Framework

Serverless Framework's initialisation command can only be run to create an empty project. For this project the configuration is simple enough that the files can be created manually.

Replace the content of .gitignore
1
2
3
4
5
6
7
8
9
10
11
# package directories
node_modules
jspm_packages

# Serverless directories
.serverless

# Hexo files and directories
db.json
public/
.deploy*/

<!-- markdownlint-disable MD013 -->

Create a serverless.yml file (replacing <project> with your project name and <domain> with either your domain name or a domain compliant name for your s3 bucket)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# The name of your project
service: <project>

# Configuration for AWS
provider:
name: aws
runtime: nodejs8.10
profile: serverless
# Some future functionality requires us to use us-east-1 at this time
region: us-east-1

# Custom configuration options
custom:
# This enables us to use the default stage definition, but override it from the command line
stage: ${opt:stage, self:provider.stage}
# This enables us to prepend the stage name for non-production environments
domain:
fulldomain:
prod: ${self:custom.domain.domain}
other: ${self:custom.stage}.${self:custom.domain.domain}
domain: <domain>
domainname: ${self:custom.domain.fulldomain.${self:custom.stage}, self:custom.domain.fulldomain.other}

# Define the resources we will need to host the site
resources:
Resources:
# Set-up an S3 bucket to store the site
WebsiteS3Bucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
WebsiteConfiguration:
IndexDocument: index.html
ErrorDocument: error.html
BucketName: ${self:custom.domain.domainname}
# Set-up a policy on the bucket so it can be used as a website
WebsiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
PolicyDocument:
Id:
Fn::Join:
- ""
- - ${self:service.name}
- BucketPolicy
Statement:
- Sid: PublicReadForGetBucketObjects
Effect: Allow
Principal: '*'
Action: 's3:GetObject'
Resource:
Fn::Join:
- ''
-
- 'arn:aws:s3:::'
- Ref: WebsiteS3Bucket
- /*
Bucket:
Ref: WebsiteS3Bucket
Outputs:
WebsiteURL:
Value:
Fn::GetAtt:
- WebsiteS3Bucket
- WebsiteURL
Description: URL for my website hosted on S3
S3BucketSecureURL:
Value:
Fn::Join:
- ''
-
- 'https://'
- Fn::GetAtt:
- WebsiteS3Bucket
- DomainName
Description: Secure URL of S3 bucket to hold website content

<!-- markdownlint-enable MD013 -->

Ensure Dependencies Only Load When Needed

The package.json file will default to having all Hexo as required when the site is published. As the dependencies are only required during development change then to be devDependencies.

Update line 8 of package.json
1
"devDependencies": {

Add Serverless S3 Deploy plugin

The Serverless S3 Deploy plugin will enable uploading files to S3.

Install Serverless S3 Deploy plugin with NPM
1
npm i serverless-s3-deploy --save-dev

Update the serverless.yml file to include the required configuration. <!-- markdownlint-disable MD013 -->

Configure the plugin within the custom element of the YAML file (starting at line 23 of the previously constructed file)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
assets:
targets:
# Configuration for HTML files (overriding the default cache control age)
- bucket:
Ref: WebsiteS3Bucket
files:
- source: ./public/
headers:
CacheControl: max-age=${self:custom.domain.cacheControlMaxAgeHTML}
empty: true
globs:
- '**/*.html'
# Configuration for all assets
- bucket:
Ref: WebsiteS3Bucket
files:
- source: ./public/
empty: true
globs:
- '**/*.js'
- '**/*.css'
- '**/*.jpg'
- '**/*.png'
- '**/*.gif'

<!-- markdownlint-enable MD013 --> Also update the serverless.yml file to include the plugin

Add a plugin top level element in the YAML file (starting at line 4) and with a blank line at the end
1
2
3
# Plugins for additional Serverless functionality
plugins:
- serverless-s3-deploy

The previous snippet includes a reference for dynamic cache control. This needs to be added to the serverless.yml file within the custom.domain section.

Add cache control data within the custom.domain element of the YAML file (starting at line 27 of the file)
1
2
3
4
5
6
cacheControlMaxAgeHTMLByStage:
# HTML Cache time for production environment
prod: 3600
# HTML Cache time for other environments
other: 0
cacheControlMaxAgeHTML: ${self:custom.domain.cacheControlMaxAgeHTMLByStage.${self:custom.stage}, self:custom.domain.cacheControlMaxAgeHTMLByStage.other}

First Test

That's it! A basic Hexo static site with deployment to AWS S3 has been configured. The default Hexo initialisation includes a sample post, and Serverless Framework has been configured to upload everything to S3. Now it's time to test everything.

Begin by testing Hexo's site generation.

Test Hexo site generation
1
hexo clean && hexo generate

Next, run the Hexo local server and in a web browser navigate to http://localhost:4000/ and verify the site works.

Test Hexo site functionality (use CTRL-C to quit this process when you're done)
1
hexo serve

Now Hexo's HTML generation has been verified it's time to create the infrastructure on AWS and upload the files.

Deploy the infrastructure
1
sls deploy

Upload the site
1
sls s3deploy

To verify the website works as expected, get the WebsiteURL

Get extended Serverless information
1
sls info --verbose

Automation

In a commercial situation it makes sense to keep the content and infrastructure deployment processes independent. However, for a blog that is maintained by a single person it can be easier to combine both processes; this is especially true with Serverless Framework and Hexo as updates will only be deployed when required. It was determined that Serverless Framework should be the controlling system for deployments.

To integrate the two systems it was first necessary to install the Serverless Plugin Scripts plugin.

Install the Serverless Plugin Scripts plugin
1
npm i serverless-plugin-scripts --save-dev

Add the plugin to the serverless.yml file

Add serverless-plugin-scripts to the configuration (at line 7)
1
- serverless-plugin-scripts

Configure the Serverless Plugin Scripts plugin to run hexo generate when building the deployment and sls s3deploy after infrastructure has been deployed.

Add Serverless Plugin Scripts configuration under the custom YAML element (insert the configuration at line 58)
1
2
3
4
5
6
7
8
9
scripts:
hooks:
# Run these commands when creating the deployment artifacts
package:createDeploymentArtifacts: >
hexo clean &&
hexo generate
# Run these commands after infrastructure changes have been completed
deploy:finalize: >
sls s3deploy -s ${self:custom.stage}

It is now possible to run a full deployment.

Run a full deployment, generating the Hexo static site and eploying infrastructure and the site to AWS
1
sls deploy

Customisation

Although a static site is now online it isn't suitable for public release yet. The URLs provided by AWS S3 are not suitable for production use; SSL is not available; the site hasn't been customised to look nice; and apart from static content further customisations are limited.

It's time to resolve these issues.

Domain Name

If you want SSL and/or a CDN for your site, skip this step.

This step will assume a domain has already been registered and is hosted with AWS Route53. If you need to register a domain name this can be done through Route53 and will automatically have appropriate DNS management set-up.

If you set-up a real domain in the serverless.yml file while configuring the Serverless Framework you can continue with this step; otherwise you will need to adjust the value of custom.domain.domain on line 25 of the serverless.yml file.

To configure the domain to point to AWS S3 two record sets need to be added to the serverless.yml file. You will need to lookup the correct Hosted Zone ID prior to completing this step. <!-- markdownlint-disable MD013 -->

Add two record sets to the serverless.yml file at line 104 (you will need to find the correct hosted zone ID to replace <hosted_zone_id> for each record)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# DNS Record for the domain
WebsiteDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteS3Bucket
- WebsiteURL
HostedZoneId: <hosted_zone_id>
HostedZoneName: ${self:custom.domain.domain}.
Name: ${self:custom.domain.domainname}
Type: 'A'
# DNS Record for www.domain
WebsiteWWWDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteS3Bucket
- WebsiteURL
HostedZoneId: <hosted_zone_id>
HostedZoneName: ${self:custom.domain.domain}.
Name: www.${self:custom.domain.domainname}
Type: 'A'

<!-- markdownlint-enable MD013 --> That's all the configuration required. Deploy the changes and you should be able to access the development version of your site at http://dev.&lt;your_domain&gt;/.

Deploy the changes
1
sls deploy

SSL & CDN

As with the previous section for adding a domain name to your site you will require a domain name to be registered and for DNS management to be set to AWS Route53.

You will also require an SSL certificate registered through AWS Certificate Manager. To use a certificate with AWS CloudFront it MUST be registered through the us-east-1 region.

At a later date a link will be provided here to demonstrate how to register a certificate.

Once a domain is set-up in AWS Route53 and a certificate has been registered through AWS Certificate Manager it's time to adjust the Serverless Framework configuration.

If you set-up a real domain in the serverless.yml file while configuring the Serverless Framework you can continue with this step; otherwise you will need to adjust the value of custom.domain.domain on line 25 of the serverless.yml file.

To configure SSL for the CDN the ARN for the Certificate will need to be added to the serverless.yml file within the custom.domain element. <!-- markdownlint-disable MD013 -->

Add the Certificate to the custom.domain serverless configuration on line 34 (replace <ARN> with the ARN of the certificate
1
sslCertificateARN: <ARN>

<!-- markdownlint-enable MD013 --> The next step is to add the configuration for the AWS CloudFront distribution and to add the DNS records for our domain. To prevent having to lookup the S3 URL required for the configuration add a new element under custom within the serverless.yml file to store all the S3 mappings.

Add an array of S3 mappings to serverless.yml at line 68
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# AWS Region to S3 website hostname mapping
s3DNSName:
us-east-2: s3-website.us-east-2.amazonaws.com
us-east-1: s3-website-us-east-1.amazonaws.com
us-west-1: s3-website-us-west-1.amazonaws.com
us-west-2: s3-website-us-west-2.amazonaws.com
ap-south-1: s3-website.ap-south-1.amazonaws.com
ap-northeast-3: s3-website.ap-northeast-3.amazonaws.com
ap-northeast-2: s3-website.ap-northeast-2.amazonaws.com
ap-southeast-1: s3-website-ap-southeast-1.amazonaws.com
ap-southeast-2: s3-website-ap-southeast-2.amazonaws.com
ap-northeast-1: s3-website-ap-northeast-1.amazonaws.com
ca-central-1: s3-website.ca-central-1.amazonaws.com
eu-central-1: s3-website.eu-central-1.amazonaws.com
eu-west-1: s3-website-eu-west-1.amazonaws.com
eu-west-2: s3-website.eu-west-2.amazonaws.com
eu-west-3: s3-website.eu-west-3.amazonaws.com
eu-north-1: s3-website.eu-north-1.amazonaws.com
sa-east-1: s3-website-sa-east-1.amazonaws.com

Now add the remaining configuration.

Configure CloudFront and Route53 at line 124 of serverless.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# Configure CloudFront to get all content from S3
WebsiteCloudFrontDistribution:
Type: 'AWS::CloudFront::Distribution'
Properties:
DistributionConfig:
Aliases:
- ${self:custom.domain.domainname}
- www.${self:custom.domain.domainname}
CustomErrorResponses:
- ErrorCode: '404'
ResponsePagePath: "/error.html"
ResponseCode: '200'
ErrorCachingMinTTL: '30'
DefaultCacheBehavior:
Compress: true
ForwardedValues:
QueryString: false
Cookies:
Forward: all
SmoothStreaming: false
TargetOriginId: defaultOrigin
ViewerProtocolPolicy: redirect-to-https
DefaultRootObject: index.html
Enabled: true
Origins:
- DomainName: ${self:custom.domain.domainname}.${self:custom.s3DNSName.${self:provider.region}}
Id: defaultOrigin
CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: http-only
PriceClass: PriceClass_All
ViewerCertificate:
AcmCertificateArn: ${self:custom.domain.sslCertificateARN}
SslSupportMethod: sni-only
# DNS Record for the domain
WebsiteDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteCloudFrontDistribution
- DomainName
HostedZoneId: Z2FDTNDATAQYW2
HostedZoneName: ${self:custom.domain.domain}.
Name: ${self:custom.domain.domainname}
Type: 'A'
# DNS Record for www.domain
WebsiteWWWDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteCloudFrontDistribution
- DomainName
HostedZoneId: Z2FDTNDATAQYW2
HostedZoneName: ${self:custom.domain.domain}.
Name: www.${self:custom.domain.domainname}
Type: 'A'

Everything has now been configured, so the changes can be deployed. When an AWS CloudFront distribution is first deployed there is a long spin-up time; Serverless Framework will wait for this process to complete and it can take in excess of 30 minutes.

Deploy the changes using Serverless Framework
1
sls deploy

Further Customisation

If you wish to use a custom theme or add any custom plugins for your Hexo site, now is the time to do it.

Hexo has a directory of themes and also a directory of plugins.

At any time you can validate your site locally by running hexo serve within your project directory and navigate to http://localhost:4000/ in your web browser. Once you're happy with you changes simply deploy the site to you development environment by running sls deploy

Final Testing

Now the development site is online you can access it by going to https://dev.<your_domain>. Verify that it is working as expected and you're almost ready to publish your site.

First Blog Post

WAIT! Before you deploy your site to production you need to create a blog post.

Check out Hexo's Writing, Front-matter and Tag plugins pages to get started.

Deploy to Production Environment

Finally, it looks like everything is ready. You've configured Hexo to manage and generate your static site; you've configured Serverless Framework to manage all your AWS infrastructure; you've set-up a custom domain name and a CDN; and you've got your first post ready. Everything works on the test environment.

Now it's time to publish your blog to production. Thankfully this bit is really easy.

Publish your blog to production
1
sls deploy -s prod

Next Steps

Securing A Test Environment Using AWS WAF

The Final serverless.yml File

[serverless.yml] []view raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# The name of your project
service: <project>

# Plugins for additional Serverless functionality
plugins:
- serverless-s3-deploy
- serverless-plugin-scripts

# Configuration for AWS
provider:
name: aws
runtime: nodejs8.10
profile: serverless
# Some future functionality requires us to use us-east-1 at this time
region: us-east-1

# Custom configuration options
custom:
# This enables us to use the default stage definition, but override it from the command line
stage: ${opt:stage, self:provider.stage}
# This enables us to prepend the stage name for non-production environments
domain:
fulldomain:
prod: ${self:custom.domain.domain}
other: ${self:custom.stage}.${self:custom.domain.domain}
domain: <domain>
domainname: ${self:custom.domain.fulldomain.${self:custom.stage}, self:custom.domain.fulldomain.other}
cacheControlMaxAgeHTMLByStage:
# HTML Cache time for production environment
prod: 3600
# HTML Cache time for other environments
other: 0
cacheControlMaxAgeHTML: ${self:custom.domain.cacheControlMaxAgeHTMLByStage.${self:custom.stage}, self:custom.domain.cacheControlMaxAgeHTMLByStage.other}
sslCertificateARN: <ARN>
assets:
targets:
# Configuration for HTML files (overriding the default cache control age)
- bucket:
Ref: WebsiteS3Bucket
files:
- source: ./public/
headers:
CacheControl: max-age=${self:custom.domain.cacheControlMaxAgeHTML}
empty: true
globs:
- '**/*.html'
# Configuration for all assets
- bucket:
Ref: WebsiteS3Bucket
files:
- source: ./public/
empty: true
globs:
- '**/*.js'
- '**/*.css'
- '**/*.jpg'
- '**/*.png'
- '**/*.gif'
scripts:
hooks:
# Run these commands when creating the deployment artifacts
package:createDeploymentArtifacts: >
hexo clean &&
hexo generate
# Run these commands after infrastructure changes have been completed
deploy:finalize: >
sls s3deploy -s ${self:custom.stage}
# AWS Region to S3 website hostname mapping
s3DNSName:
us-east-2: s3-website.us-east-2.amazonaws.com
us-east-1: s3-website-us-east-1.amazonaws.com
us-west-1: s3-website-us-west-1.amazonaws.com
us-west-2: s3-website-us-west-2.amazonaws.com
ap-south-1: s3-website.ap-south-1.amazonaws.com
ap-northeast-3: s3-website.ap-northeast-3.amazonaws.com
ap-northeast-2: s3-website.ap-northeast-2.amazonaws.com
ap-southeast-1: s3-website-ap-southeast-1.amazonaws.com
ap-southeast-2: s3-website-ap-southeast-2.amazonaws.com
ap-northeast-1: s3-website-ap-northeast-1.amazonaws.com
ca-central-1: s3-website.ca-central-1.amazonaws.com
eu-central-1: s3-website.eu-central-1.amazonaws.com
eu-west-1: s3-website-eu-west-1.amazonaws.com
eu-west-2: s3-website.eu-west-2.amazonaws.com
eu-west-3: s3-website.eu-west-3.amazonaws.com
eu-north-1: s3-website.eu-north-1.amazonaws.com
sa-east-1: s3-website-sa-east-1.amazonaws.com

# Define the resources we will need to host the site
resources:
Resources:
# Set-up an S3 bucket to store the site
WebsiteS3Bucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
WebsiteConfiguration:
IndexDocument: index.html
ErrorDocument: error.html
BucketName: ${self:custom.domain.domainname}
# Set-up a policy on the bucket so it can be used as a website
WebsiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
PolicyDocument:
Id:
Fn::Join:
- ""
- - ${self:service.name}
- BucketPolicy
Statement:
- Sid: PublicReadForGetBucketObjects
Effect: Allow
Principal: '*'
Action: 's3:GetObject'
Resource:
Fn::Join:
- ''
-
- 'arn:aws:s3:::'
- Ref: WebsiteS3Bucket
- /*
Bucket:
Ref: WebsiteS3Bucket
# Configure CloudFront to get all content from S3
WebsiteCloudFrontDistribution:
Type: 'AWS::CloudFront::Distribution'
Properties:
DistributionConfig:
Aliases:
- ${self:custom.domain.domainname}
- www.${self:custom.domain.domainname}
CustomErrorResponses:
- ErrorCode: '404'
ResponsePagePath: "/error.html"
ResponseCode: '200'
ErrorCachingMinTTL: '30'
DefaultCacheBehavior:
Compress: true
ForwardedValues:
QueryString: false
Cookies:
Forward: all
SmoothStreaming: false
TargetOriginId: defaultOrigin
ViewerProtocolPolicy: redirect-to-https
DefaultRootObject: index.html
Enabled: true
Origins:
- DomainName: ${self:custom.domain.domainname}.${self:custom.s3DNSName.${self:provider.region}}
Id: defaultOrigin
CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: http-only
PriceClass: PriceClass_All
ViewerCertificate:
AcmCertificateArn: ${self:custom.domain.sslCertificateARN}
SslSupportMethod: sni-only
# DNS Record for the domain
WebsiteDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteCloudFrontDistribution
- DomainName
HostedZoneId: Z2FDTNDATAQYW2
HostedZoneName: ${self:custom.domain.domain}.
Name: ${self:custom.domain.domainname}
Type: 'A'
# DNS Record for www.domain
WebsiteWWWDNSRecord:
Type: "AWS::Route53::RecordSet"
Properties:
AliasTarget:
DNSName:
Fn::GetAtt:
- WebsiteCloudFrontDistribution
- DomainName
HostedZoneId: Z2FDTNDATAQYW2
HostedZoneName: ${self:custom.domain.domain}.
Name: www.${self:custom.domain.domainname}
Type: 'A'
Outputs:
WebsiteURL:
Value:
Fn::GetAtt:
- WebsiteS3Bucket
- WebsiteURL
Description: URL for my website hosted on S3
S3BucketSecureURL:
Value:
Fn::Join:
- ''
-
- 'https://'
- Fn::GetAtt:
- WebsiteS3Bucket
- DomainName
Description: Secure URL of S3 bucket to hold website content

Example Site