When setting up this website, I was thinking of a scalable hosting solution that is easy to deploy and requires as low maintenance as possible (as every dev knows, maintenance isn’t our favorite kind of work).

So I decided to go with a rather simple solution by using Hugo as SSG (Static Site Generator) hosted on Github, whereby on each Push the generated website will be deployed on Cloudflare Pages. The latter one has very generous usage limits so that every “normal” user should be fine with the free plan. Furthermore they offer quite a lot of nice security & privacy features by default.

Disclaimer: I won’t go into much detail on setting up your Cloudflare zones, domains etc. So please assure that your zone is setup.

Cloudflare Setup

Setting up a new Page on Cloudflare

Go to Workers & Pages -> Create -> Pages. Click “Upload assets”, enter the name of your website and upload any file to finalize the creation.

Create new Page

Cool, we got our website now running and can open it using the link we saw (in my case http://my-project.7z0.pages.dev). Now we need to retrieve some credentials in order to deploy our generated website.

Setup of Cloudflare credentials

At first we will generate a new API token:

  1. Log in to the Cloudflare Dashboard
  2. Click the Avatar in the top right corner and select “My Profile”
  3. Select API Tokens -> Create Token -> Create Custom Token
  4. Give the token a name and select as permission Account -> Cloudflare Pages -> Edit
  5. Continue and note down your token

Lastly, we need the Account ID which can be found in the dashboard of your website: Websites -> {your website}. Now we are prepared on the CF side of things.

Hugo

I assume you have Hugo installed (available in most package managers such as brew, chocolatey and apt). If you encounter some issues in this section, please consider reading the official documentation of Hugo since this post may be outdated by then.

Create a new project

We at first need to setup a new project, which is quite straight-forward since Hugo does all the boilerplate for us.

hugo new site my-website

Afterwards, we need will setup a new (local) git repository and add a theme as submodule. This theme will then be activated.

cd my-website
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml

Now we can test, if everything works as expected by opening http://localhost:1313/ . This should open some default content.

Hugo Homepage

Creating content will not be part of this post. Check out the Hugo documentation, if you need help there.

Configure Base URL

We will need to adjust the base URL of our Hugo installation. To do so, edit the base URL in hugo.toml and set it to the one you saw when setting up your Cloudflare Page (in my case https://my-project.7z0.pages.dev).

Add Cloudflare headers

One very important thing is to configure the headers that are being sent by your Cloudflare deployment. By default, you will face an error like the following:

The resource from “https://your-deployment/bundle.js” was blocked due to MIME type mismatch (X-Content-Type-Options: nosniff).

To do so, we add a new file _headers in our root directory with the following content (if you need additional files to be supported, you will need to add them manually).

/*
  Cache-Control: max-age=1209600, s-maxage=1209600, stale-if-error=600
  Cloudflare-CDN-Cache-Control: max-age=1209600, stale-if-error=600
  CDN-Cache-Control: 1209600, stale-if-error=600
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Access-Control-Allow-Origin: *
  Referrer-Policy: strict-origin-when-cross-origin
  Server: cloudflare
/*.js
  Content-Type: application/x-javascript; charset=utf-8
/*.html
  Content-Type: text/html
/*.png
  Content-Type: image/png
/*.css
  Content-Type: text/css

This assures, that the Content-Type is being set correctly for the respective file types and uses some optimized Caching settings.

Github Setup

Overall setup

We are now done with the overall setup and are ready to deploy it. Since we do not want to upload the content each time manually, we are going to automate it so that the most recent version is always being deployed on our Cloudflare page.

Create a Github repository at first. Then we will add our code, setup the remote repo and commit it.

git add --all
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:Sapp00/hugo-cloudflare-github.git
git push -u origin main

Now our code should be pushed, what is left is the automation aka Github Workflow.

Github Workflow

Create the directory structure for our workflow.

mkdir -p .github/workflows

Next, we will create the workflow, by adding a new file called pages.yaml.

name: Pages

on:
  workflow_dispatch: # allows manual triggering
  push:
    branches:
      - main

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

defaults:
  run:
    shell: bash

jobs:
  cloudflare-upload:
    runs-on: [ubuntu-latest]
    env:
      HUGO_VERSION: 0.128.0
    permissions:
      contents: read
      deployments: write
    name: Publish to Cloudflare Pages
    steps:
      - name: Install Hugo CLI
        run: |
          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
          && sudo dpkg -i ${{ runner.temp }}/hugo.deb                    
      - name: Install Dart Sass
        run: sudo snap install dart-sass
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
      - name: Install Node.js dependencies
        run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
      - name: Build with Hugo
        env:
          HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
          HUGO_ENVIRONMENT: production
          TZ: Europe/Berlin
        run: |
          hugo \
            --gc \
            --minify \          
      - name: Add Cloudflare Headers
        run:
            mv _headers ./public/_headers
      - name: Cloudflare Upload
        uses: cloudflare/pages-action@1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: website
          directory: ./public
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}

So, what is happening here? Let’s go through some important points shortly, that are (in my opinion) not self-explaining.

on:
  workflow_dispatch: # allows manual triggering
  push:
    branches:
      - main

We allow that the workflow can be triggered manually (makes debugging way easier) and trigger it automatically each time we push changes to our main branch.

permissions:
  contents: read
  pages: write
  id-token: write

These are the permissions that are necessary in order to deploy our code.

jobs:
  cloudflare-upload:
    runs-on: [ubuntu-latest]
    env:
      HUGO_VERSION: 0.128.0
    permissions:
      contents: read
      deployments: write
    name: Publish to Cloudflare Pages
    steps:
      - name: Install Hugo CLI
        run: |
          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
          && sudo dpkg -i ${{ runner.temp }}/hugo.deb                    
      - name: Install Dart Sass
        run: sudo snap install dart-sass
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
      - name: Install Node.js dependencies
        run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"

Here, we setup our jobs, so what is being executed on each push. It should use Ubuntu as OS and define our Hugo version that is being used and the necessary permissions. Then it will install the necessary tooling and checkout the most recent version of our repository. Finally, it will install all the Node.js dependencies we need.

- name: Build with Hugo
  env:
    HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
    HUGO_ENVIRONMENT: production
    TZ: Europe/Berlin
  run: |
    hugo \
      --gc \
      --minify    

Next, it will build our project and compress the output (which is very recommended for production).

- name: Add Cloudflare Headers
  run:
    mv _headers ./public/_headers

Since the _headers file needs to be in the root directory of our deployment, we are copying it to the directory that is going to be pushed to Cloudflare Pages.

- name: Cloudflare Upload
  uses: cloudflare/pages-action@1
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    projectName: my-project
    directory: ./public
    gitHubToken: ${{ secrets.GITHUB_TOKEN }}

Finally, our built website needs to be pushed to Cloudflare. To do so, we need to define our projectName, the name of our Cloudflare Page we created earlier. Additionally, it is necessary to provide the apiToken and the accountID. Since these are sensitive information, we don’t want to include it as plaintext, but store it as a Github secret.

Github Secrets

In your Github repository go to Settings -> Secrets and Variables -> Actions. We will need to add two repository secrets:

CLOUDFLARE_API_TOKEN = {your API token}
CLOUDFLARE_ACCOUNT_ID = {your account ID}

We are done with our setup and just need to push our changes.

git add --all
git commit -m "added workflow"
git push origin main

Now, the workflow will be triggered and the code deployed on Cloudflare Pages. You should be able to open your Hugo project using the link created earlier.

The full source code can be found on my Github.