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.

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

# Example Site