Creating a static website with Hugo, AWS, Cloudflare, and GitHub Actions


Creating a static website with Hugo, AWS, Cloudflare, and GitHub Actions

I wanted this blog to be inexpensive, easy to maintain, and secure. I also didn’t want to spend my time fiddling with HTML, CSS, and Javascript. There are several approaches I could have taken, but this approach met my needs the best.


To get the most out of this article, you should be familiar with DNS, running commands at the CLI (Command Line Interface), HTTP basics, and using Git for source code management. It also wouldn’t hurt to be familiar with AWS but the provided AWS CloudFormation template encapsulates most of the moving parts in a tidy package – the same can be said for GitHub and the provided GitHub Actions script. That said, if you aren’t familiar with these technologies and aren’t afraid of getting your hands dirty, jump right in and learn as you go!

You will also need AWS, Cloudflare, and GitHub accounts. If you don’t already have accounts for these services, use the following links to vendor-specific documentation for creating accounts.

  • Create an AWS Account - It is worth noting that you will need to enter a credit card when creating an AWS account, even if you only plan on using the free tier. It is possible to exceed AWS’s free tier and rack up a hefty bill if your site receives a significant volume of traffic. You are encouraged to read through AWS’s pricing documentation and to also learn how to set up billing alerts. YOU HAVE BEEN WARNED!
  • Create a Cloudflare Account
  • Create a GitHub account


Inexpensive - I don’t expect to make any money from this site, it is just a hobby. With that in mind, I want to keep costs to a minimum.

Easy to maintain - I wanted to spend my time writing content – not formatting, building, and deploying it. I just wanted to “push a button” when I was done and have the site update without any further involvement on my part – fire and forget.

Secure - I wanted to minimize the effort required to keep the site secure. I didn’t want to spend time applying security patches to software or recovering/rebuilding a compromised server. I have made a concerted effort to make this setup as secure as possible, but ultimately it is up to you to apply good security practices with your accounts and validate that I haven’t missed something in the provided instructions and files.



Hugo is a Static Site Generator (SSG). SSGs are based on some core ideas that satisfy many of my technical goals for this blog. Because the content is static, you don’t need to run a database or a specialized application server. This results in extremely fast load times and reduces your site’s attack surface. Most SSGs also use templating engines that abstract the user away from having to write HTML or CSS.

I ended up choosing Hugo due to its simplicity and its content generation speed. With Hugo, content is written in Markdown, a simple human-readable text format that is rendered into static HTML for your site. There are also well over 200 freely available Hugo Themes to choose from to help you personalize your site.

An additional bonus of Hugo is not having to install a runtime like node.js or ruby to generate your site. Hugo is written in Go which compiles to a single static executable. This means there are no dependencies to take care of and installation is simple.

Like most SSGs, Hugo has a built-in server that you can run while writing your site. As you save your changes your content will automatically refresh in your browser, giving you a live preview of what your site will look like.

If Hugo doesn’t meet your needs, there are plenty of other SSGs to choose from. maintains a comprehensive list of static site generators if you want to explore other options. It should be fairly straightforward to swap out Hugo and use the rest of these instructions for deploying and serving your content.

AWS S3, CloudFront, Lambda, IAM, ACM, and CloudFormation

This was a no-brainer. There are other options (e.g. Google Cloud), but having worked for Amazon I’m fairly familiar with the AWS stack. This is less a judgment of which tool was best for the job, and more an indictment of my laziness to evaluate another tool. That said, Amazon’s offerings in this area are more than adequate. It also doesn’t hurt that Amazon’s free tier is fairly generous for a site as small as this one.

This site is stored in S3 and fronted by CloudFront to reduce data transfer costs. Amazon Certificate Manager (ACM) is used to generate an HTTPS certificate for the CloudFront endpoint. Lambda@Edge is employed to address a deficiency with how CloudFront handles (it doesn’t) index documents in subdirectories. I also create a dedicated IAM user with just enough privileges to manage the content in the S3 site bucket. All of this is automated as much as possible through the use of a CloudFormation template so I don’t have to manually configure things in the AWS console.


I could have used AWS Route 53 DNS for this site, but I already use Cloudflare for all of my domains. Additionally, CloudFlare’s free tier also provides DDoS attack mitigation which can help prevent you from racking up a sizable bill if your site is attacked. It also doesn’t hurt that CloudFlare’s interface is nicer to use than Route 53.


I already keep projects in public and private GitHub repositories. With GitHub’s unlimited private repositories and the new GitHub Actions feature, you have a simple, effective, and inexpensive CI/CD pipeline to automatically build and deploy your site changes.

At the time of writing this article, GitHub includes 2000 free GitHub Actions minutes per month for private repository Actions of free accounts. The GitHub Actions workflow this site uses takes approximately 30 seconds to run, permitting more than enough site updates for my needs. If you chose to keep your site repository public, you don’t need to worry about GitHub Actions minutes because they are completely free for public repositories. GitHub Actions self-hosted runners are another option for addressing GitHub Actions minutes restrictions on free accounts – they are also an option if you have more specialized needs for your GitHub Actions.

Create Your AWS Resources Using Cloudformation

Hopefully, I haven’t lost you at this point. It may seem like there are a lot of moving parts, but the CloudFormation template and GitHub Actions script do most of the heavy lifting.

The CloudFormation Template

The CloudFormation template takes care of creating all the AWS resources needed to host this site. The template uses template parameters so it may be reused for multiple sites (e.g. my son’s short story site) without having to change the content of the template. I have also added template outputs so you don’t have to hunt for information needed in later steps of this article.

CloudFormation Template Parameters:

  • DomainName - This is the primary name that you want your site to be reached at. This value is used by CloudFront to correctly route requests and by ACM for the primary hostname of the SSL certificate that is created. e.g.
  • AdditionalCnames (OPTIONAL) - You can add additional hostnames you would like your site to be reached at (if any). These will be used to correctly route requests in CloudFront. They will also be used to create Subject Alternative Names (SANs) in the SSL certificate created by ACM. e.g.,
  • IndexDocument - The default document you would like to use for the root and subdirectories of your site. Note that due to an odd inconsistency in how CloudFront is configured, this should not be preceded by a ‘/’. e.g. index.html
  • ErrorDocument - The error document you would like to return when a request to your website can not be completed. Note that due to an odd inconsistency in how CloudFront is configured, this should be preceded by a ‘/’. e.g. /404.html
  • LogBucketTtl - An S3 log bucket is created to log access of the S3 bucket containing the site content. This parameter is the length of time in days you would like these logs to be kept around before they are automatically cleaned up. e.g. 7

CloudFormation Template Outputs:

  • CloudFrontDomain - The hostname of the CloudFront distribution that is created.
  • SiteBucketName - The name of the S3 bucket that is created to hold site content.

Download static-website-resources.yml

AWSTemplateFormatVersion: 2010-09-09
    Description: The primary FQDN name for this site
    Type: String
    Description: Additional CNAMES for this site separated by commas
    Type: CommaDelimitedList
    Description: The default document to display when retrieving the root of the CloudFront distribution.
    Type: String
    Default: index.html
    Description: The document to display when a 4xx error occurs. Be sure that this is a full path from the root with a preceding '/'.
    Type: String
    Default: /404.html
    Description: Log bucket TTL in days
    Type: Number
    MinValue: 1
    MaxValue: 31

  HasAdditionalCnames: !Not [ !Equals [ "", !Join [ "", !Ref AdditionalCnames ] ] ]

    Description: >-
      The CloudFront domain name you should point a CNAME to.      
    Value: !GetAtt CloudFront.DomainName
    Description: >-
      The name of the S3 bucket for your site content.      
    Value: !Ref SiteBucket

    Type: AWS::S3::Bucket
      AccessControl: LogDeliveryWrite
        BlockPublicAcls: true
        IgnorePublicAcls: true
        BlockPublicPolicy: true
        RestrictPublicBuckets: true
        - Id: GlobalTtl
          Status: Enabled
          ExpirationInDays: !Ref LogBucketTtl

    Type: AWS::S3::Bucket
      AccessControl: Private
        BlockPublicAcls: true
        IgnorePublicAcls: true
        BlockPublicPolicy: true
        RestrictPublicBuckets: true
        DestinationBucketName: !Ref LogBucket

    Type: AWS::IAM::User
      UserName: !Ref DomainName
      - PolicyName: !Ref DomainName
          Version: 2012-10-17
          - Effect: Allow
            - s3:DeleteObject
            - s3:GetBucketLocation
            - s3:GetObject
            - s3:ListBucket
            - s3:PutObject
            - s3:PutObjectAcl
            - !Sub 'arn:aws:s3:::${SiteBucket}'
            - !Sub 'arn:aws:s3:::${SiteBucket}/*'

    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
        Comment: !Ref SiteBucket

    Type: AWS::S3::BucketPolicy
      Bucket: !Ref SiteBucket
        - Action: s3:GetObject
          Effect: Allow
          Resource: !Sub 'arn:aws:s3:::${SiteBucket}/*'
            CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId

    Type: 'AWS::IAM::Role'
        Version: '2012-10-17'
        - Effect: Allow
            - ''
            - ''
          Action: 'sts:AssumeRole'
      - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'

    Type: AWS::Lambda::Function
      Runtime: nodejs12.x
      Role: !GetAtt LambdaRole.Arn
      Handler: index.handler
      Description: Get an index document if path ends in /
      MemorySize: 128
      Timeout: 5
        ZipFile: |
          'use strict';
          const path = require('path');

          exports.handler = (event, context, callback) => {
            const { request } = event.Records[0].cf;

            console.log('Original request URI: ', request.uri);

            const parsedPath = path.parse(request.uri);
            let newUri = request.uri;

            console.log('Parsed Path: ', parsedPath);

            if (parsedPath.ext === '') {
              newUri = path.join(parsedPath.dir, parsedPath.base, 'index.html');

            console.log('New request URI: ', newUri);

            // Replace the received URI with the URI that includes the index page
            request.uri = newUri;

            // Return to CloudFront
            return callback(null, request);

    Type: 'AWS::Lambda::Version'
      FunctionName: !Ref IndexLambda

    Type: AWS::CertificateManager::Certificate
      DomainName: !Ref DomainName
      ValidationMethod: DNS
        - HasAdditionalCnames
        - !Ref AdditionalCnames
        - !Ref AWS::NoValue

    Type: AWS::CloudFront::Distribution
        - ErrorCode: 403 # Not found in S3 bucket "speak"
          ResponseCode: 404
          ResponsePagePath: !Ref ErrorDocument
        DefaultRootObject: !Ref IndexDocument
        Enabled: true
        PriceClass: PriceClass_All
          - HasAdditionalCnames
          - !Split [ ",", !Join [ ",", [ !Ref DomainName, !Join [ ",", !Ref AdditionalCnames ] ] ] ]
          - [ !Ref DomainName ]
        - Id: origin
          DomainName: !GetAtt SiteBucket.DomainName
            OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}'
          TargetOriginId: origin
          DefaultTTL: 300
          MaxTTL: 3600
            QueryString: false
          ViewerProtocolPolicy: redirect-to-https
          - EventType: origin-request
            LambdaFunctionARN: !Ref IndexLambdaVersionV1
            AcmCertificateArn: !Ref CloudFrontCertificate
            SslSupportMethod: sni-only

Create the CloudFormation Stack

Before you start, it’s worth noting that the CloudFormation stack must be created in “US East (N. Virginia) us-east-1”. This is due to a limitation of Lambda@Edge. Hopefully, AWS will address this limitation in the future.

It’s also worth noting that the output of the Lambda invocation is cached by CloudFront for each resource that is requested. This is a good thing. The Lambda will be invoked infrequently, significantly reducing compute time costs if your site is busy enough to incur them.


  1. Navigate to CloudFormation in the AWS Console.

  2. Make sure you have N. Virginia (us-east-1) selected as the region in the upper right.

    Select Region
  3. Select Create Stack.

    Create CloudFormation Stack
  4. Select Template is ready for Prepare template. Select Upload a template file for Template source. Finally, select Choose file and navigate to where you’ve saved static-website-resources.yml.

    Stack Wizard Page 1
  5. Select Next.

  6. For the Specify stack details step, enter a suitable identifier in Stack name (e.g. example-org-site). Also, choose suitable values for the requested parameters (e.g. AdditionalCnames ->,, DomainName ->, ErrorDocument -> /404.html, IndexDocument -> index.html, LogBucketTtl -> 7)

    Stack Wizard Page 2
  7. Select Next.

  8. You don’t need to change any settings at the Configure stack options step, but I add a tag named site with a value like Tags have many purposes, but in this particular case, I’m using one to keep track of what these resources were created for.

    Stack Wizard Page 3
  9. Select Next.

  10. At this point you should be presented with the Review <your-stack-identifier> step. Review the information and select I acknowledge that AWS CloudFormation might create IAM resources with custom names.

    Stack Wizard Page 4
  11. Select Create Stack.

ACM Certificate Validation

After you have selected Create Stack, you will need to wait for the stack creation to pause for validation of your newly generated ACM SSL certificate. The stack will remain in CREATE_IN_PROGRESS status until the certificate validation has completed. Once the stack creation is in this state, you are on a timer to complete the validation before the stack creation fails. Don’t worry. I believe the operation times out in 12 hours – more than enough time for the next steps.

Within a couple of minutes, you should see a CloudFrontCertificate event in your CloudFormation stack event log for your ACM SSL certificate. It should contain a status reason similar to the following: Content of DNS Record is: {Name:,Type: CNAME,Value:}. If you didn’t provide any additional CNAMEs for the site and certificate, this message should contain everything you need. Otherwise, you will need to navigate to Certificate Manager in the AWS Console and expand the entry for the domain name you specified – it should have a Status of Pending validation. In the Status section of the expanded entry you should see a list of domains. Expand each of those to display the information for each of the CNAMEs you will need to create to validate your certificate.

ACM Validation

I chose DNS validation rather than email validation because once you have added the validation CNAME no future work or complex mechanisms are needed when the certificate is renewed – ACM will continue to use that same CNAME for future validations to prove that you own the specified domain.

I use Cloudflare for managing my DNS – you will need to adapt the following steps if you choose to use a different DNS provider.


  1. Select the domain you would like to manage in the Cloudflare console.

  2. Select the DNS icon.

    DNS Management
  3. Select Add record.

    Add record
  4. Change the Type to CNAME.

  5. Enter the Name from AWS Certificate Manager into the Name field, including the trailing dot (e.g.

  6. Enter the Value from AWS Certificate Manager into the Target field, including the trailing dot (e.g.

  7. Switch the Proxy Status from Proxied to DNS only by clicking on the cloud icon under Proxy Status.

  8. Set the TTL field to Auto if it isn’t already.

    New record
  9. Select Save.

  10. Rinse and repeat for each domain that needs validation.

Assuming you have entered the information correctly, you should see the domain’s Validation status in AWS Certificate Manager change from Pending validation to Success. Be patient, this may take ~15 minutes because AWS Certificate Manager only checks periodically.

If the DNS validation seems to be taking a long time (> 2 hours), there may be errors in the CNAME records you created. Carefully review that the data you entered in the CNAME record is correct and address any issues you find.

CloudFormation Stack Creation Outputs

Once certificate validation has completed, the CloudFormation stack creation should resume. When it has completed, you should see its status change from CREATE_IN_PROGRESS to CREATE_COMPLETE. You will need to be patient with CloudFormation as it can take a while (15-30 minutes) to create the resources in the template. Once completed, navigate to the Outputs section of the CloudFormation stack and record the Value for the CloudFrontDomain and SiteBucketName keys. You will need these values for later steps.

If the stack creation reaches an error state, don’t panic. Carefully review the stack creation events for errors and address them. You will probably need to delete the stack and recreate it to kick things off again once you have addressed any issues.

Create Security Credentials for the S3 Bucket User

The CloudFormation template creates an IAM user with the minimum permissions required to manage the static website content S3 bucket. You will need to create security credentials for this user that will be used by GitHub Actions to publish your website when you commit new site content.

  1. Navigate to IAM in the AWS console.

  2. Select Users.

  3. Select the user that matches your site name (e.g. If the user is not immediately visible on the first page of users, use the search function to quickly find it.

    IAM User
  4. Select the Security credentials tab.

    IAM Security Credentials
  5. Select Create access key in the Access keys section.

    IAM Create Access Key
  6. You will be presented with a dialog containing the access key information. This information is comprised of two pieces of data, an Access key ID and a Secret access key. Copy this information or download the .csv file to use later. Once you close the dialog, you will no longer be able to access the Secret access key. Don’t worry though, access keys are easy to create – you can just delete it and recreate another. In fact, it’s good security practice to periodically rotate a user’s security credentials.

    IAM Access Key Dialog
  7. Close the dialog.


Congratulations, the hard part is complete – give yourself a well deserved pat on the back. You should be proud of yourself. You just created the serverless infrastructure for hosting your site. If that seemed involved, imagine if you had to manually create those resources in the AWS console. Trust me. This was much simpler and much less error prone.

The good news is that you may never need to make changes to these AWS resources again, other than periodic security credential rotations for the site bucket user.

Hugo Setup

I will leave the details of how to install Hugo to the Hugo installation documentation. You should then follow the instructions in the Hugo Quick Start guide to create your site.

As stated earlier, there are plenty of Hugo Themes to choose from. Experiment! Don’t be afraid of making mistakes. If you don’t like the way a theme works or looks, delete the directory and start over. The simplicity and speed of Hugo makes it easy to experiment.

Once you have an idea of what you like and what works best for you, it’s time to automate building and deploying your site.

Continuous Deployment

Import Your Hugo Project Into GitHub

Next, import your Hugo Project into GitHub using these GitHub’s instructions. Once you’ve completed that, go ahead and do any additional housekeeping like adding a or .gitignore to your project. Commit and push the changes.

Add Configuration Secrets to Your Repository

You should never commit sensitive information like passwords, PINs, private keys, and AWS credentials into a Git repository. Luckily, GitHub has an answer for that. GitHub repository secrets are purpose-built to protect your sensitive data. GitHub secrets are immediately encrypted, are not able to be viewed after creation, but can be updated whenever needed. These secrets are accessible to GitHub Actions, allowing your workflow to use sensitive information like AWS credentials without requiring you to commit them into your repository or enter them every time a workflow runs.

Do you remember the AWS user credentials and S3 bucket name we recorded earlier? It’s time to add them as secrets to your newly created GitHub repository to be used by the GitHub Action that will build and deploy your site.


  1. Navigate to your newly created repository in the GitHub console.

  2. Select Settings.

    GitHub Settings
  3. Select Secrets.

    GitHub Secrets
  4. Select New repository secret.

  5. Enter AWS_ACCESS_KEY into Name. Enter the Access Key ID from the user security credential you created into Value.

    GitHub Dialog for AWS Access ID Secret
  6. Select Add secret.

  7. Select New repository secret.

  8. Enter AWS_SECRET_KEY into Name. Enter the Secret Access Key from the user security credential you created into Value.

    GitHub Dialog for AWS Secret Key Secret
  9. Select Add secret.

  10. Select New repository secret.

  11. Enter AWS_S3_BUCKET into Name. Enter the value of the SiteBucketName output from the CloudFormation stack into Value.

    GitHub Dialog for AWS S3 Bucket Secret
  12. Select Add secret.

You should now have three secrets in your site repository – AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_BUCKET.

Add the GitHub Actions Workflow

You might think it would be somewhat involved to configure an automated build and deploy for your Hugo site. It’s not – it happens to be very straightforward with GitHub Actions. The following GitHub Actions workflow will run anytime you push your code to the master branch of your site’s GitHub repository. All you have to do is add the workflow to your repository and GitHub Actions will take care of the work.

My blog’s repository still uses a master branch for its default branch. In the future, GitHub and other vendors will be using more racially sensitive labels, such as main, for a repository’s default branch. GitHub does have instructions on how to rename your branch, but they are also going to create automated tools for addressing this soon. I’ll probably take the plunge and try GitHub’s instructions on my Hugo test repository and run through the instructions for all my older repositories that use a master branch, once I’m comfortable with the results. You will need to keep this in mind and edit the following workflow to use branches: main instead of branches: master.

When you push to the specified branch, the workflow will check out the most recent sources, build the site using Hugo, and then deploy the static site to S3 using the credential and bucket secrets you created earlier.

Download main.yml

name: Build and deploy Hugo site.
    branches: master

    runs-on: ubuntu-latest
    - name: Checkout sources
      uses: actions/checkout@v2
        submodules: true
    - name: Build with hugo
      uses: klakegg/[email protected]
        image: ext-alpine
        command: --minify
    - name: Deploy to S3
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}
        AWS_DEFAULT_REGION: us-east-1
      run: |
        aws s3 sync public/ s3://${{ secrets.AWS_S3_BUCKET }} --delete        


  1. Navigate to the base directory of your site repository.
  2. Create a subdirectory named .github.
  3. Create a subdirectory under the .github directory named workflows.
  4. Save main.yml into the newly created <repo_base>/.github/workflows/ directory. Make sure to edit the workflow to trigger on pushes to main instead of master if your repository is newer.
  5. Commit the main.yml file and push your changes to GitHub.

That’s it for setting up your Continuous Deployment pipeline! Navigate to Actions for your site repository in the GitHub console. You should see your workflow running. If all goes well, there won’t be any errors, and your content will be deployed to your S3 bucket.

GitHub Actions Build

Make Your Site Public

You are almost there. There is just one last thing to do. Point your domain name(s) at your new creation.


  1. Select the domain you would like to manage in the Cloudflare console.

  2. Select the DNS icon.

    DNS Management
  3. Select Add record.

    Add record
  4. Change the Type to CNAME.

  5. If the DomainName property you entered into the CloudFormation stack is your base domain (e.g., enter @ into the Name field. Otherwise, you will enter everything but your base domain into the Name field (e.g. becomes www, becomes

  6. Enter the CloudFrontDomain CloudFormation output value you recorded earlier from AWS Certificate Manager into the Target field.

  7. Make sure the Proxy Status is set to Proxied. If not you can switch it by clicking on the cloud icon under Proxy Status.

    New record
  8. Select Save.

  9. If you specified values for the AdditionalCnames CloudFormation template property, repeat these instructions for each of those values. You will use those values in place of the DomainName property for each additional CNAME you create.

That’s it! Now that you have pointed your domain name(s) at the AWS CloudFront Distribution created by the CloudFormation template, your site will be accessible to anyone that knows its address. Don’t worry if your site isn’t immediately available. You may need to wait ~5 minutes (or 1-2 days if your domain was recently created) for DNS changes to become visible.

Project Resources