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.
npm i -g hexo-cli
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.
npm i -g serverless
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.
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.
# package directories
node_modules
jspm_packages
# Serverless directories
.serverless
# Hexo files and directories
db.json
public/
.deploy*/
# 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
# 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 them to be devDependencies
.
"devDependencies": {
# Add Serverless S3 Deploy plugin
The Serverless S3 Deploy plugin will enable uploading files to S3.
npm i serverless-s3-deploy --save-dev
Update the serverless.yml
file to include the required configuration.
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'
Also update the serverless.yml
file to include the plugin
# 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.
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.
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.
hexo serve
Now Hexo’s HTML generation has been verified it’s time to create the infrastructure on AWS and upload the files.
sls deploy
sls s3deploy
To verify the website works as expected, get the WebsiteURL
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.
npm i serverless-plugin-scripts --save-dev
Add the plugin to the serverless.yml
file
- 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.
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.
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.
# 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'
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.<your_domain>/
.
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.
sslCertificateARN: **ARN**
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.
# 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 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.
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.
sls deploy -s prod
# Next Steps
Securing A Test Environment Using AWS WAF# The Final serverless.yml
File
# 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