Automatic static site deployment with 11ty, Cloudflare Workers, and GitHub actions

Time for my one blog post of the year! Approriately, it concerns my having completely reimplemented this blog's tech stack and workflow for the umpteenth time. This has become a more or less annual ritual. And, like many rituals, it is arguably totally pointless! Nevertheless!

Having said that, I've achieved something with this workflow that I've been seeking more or less since I started this blog: a simple workflow wherein I write one blog post in Markdown, push it to my GitHub repo, and the whole site is then automatically rebuilt and deployed. This blog post will be the inaugural test run.

What follows is a brief meta-tutorial explaining my workflow, and linking to external guides that I used to get set up. If you're completely unfamiliar with everything in the title of this post, you might still be able to get an idea of how it all fits together by reading through this. That said, this post will probably be most useful to folks that know how to use npm and that have a passing familiarity with some of the tech in the post title.



11ty is static-site generator. If you're totally unfamiliar with what that is, it basically means you can author your pages in Markdown and build a static HTML version of those pages. If you're already familiar, then 11ty is basically a lot like other static site generators, except that it's a bit simpler than some of the most popular solutions (like Next.js, which this blog used to use, or Gatsby, which this blog also used to use...) currently out there. Get started with it here.

Cloudflare Workers

This is what I'm using to host the static assets (in my case, just plain old HTML). Cloudflare Workers is a serverless execution environment that runs on Cloudflare's network. The specific workers platform is Workers Sites, which is built especially for deploying static sites.

GitHub actions

GitHub Actions is a CI/CD platform; it lets you automate all kinds of things. In my case, I use it to automate building the blog, and then publishing it to Cloudflare.

Step 1: Set up the blog

Using 11ty, this just means following the getting started docs. But 11ty isn't a requirement; you could use any static site generator, or even just author the HTML by hand (really!).

Step 2: Set up Cloudflare Workers

I'm using a modified version of Cloudflare's Workers Sites, which runs on Cloudflare workers. You can either follow these docs to start from an existing static site, or these to start from scratch.

That's it, if you like.

You don't need to set up GitHub actions or even use GitHub at all if you want to publish a static site to Cloudflare Workers. As noted in the docs, you can simply run wrangler publish once you're all set up, and your site will be published to a publically viewable URL.

GitHub actions

The advantage of using a GitHub action is that I can push a new blog post or page to my GitHub repo for this site, and the site will be rebuilt and published automatically. It's just a workflow improvement.

To set up the action, I added this file to .github/workflows/deploy.yml:

name: deploy

- "master"

runs-on: ubuntu-latest
name: deploy
- uses: actions/checkout@v2

- run: npm ci
- run: npm run build

- name: Publish
uses: cloudflare/wrangler-action@1.3.0
apiToken: ${{ secrets.CF_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_API_TOKEN }}
USER: root

This Yaml conforms to the required syntax for GitHub actions; it's taken from here. This action installs the necessary dependencies from npm, builds the site, and publishes it.


You can use GitHub secrets to store encrypted environment variables. The advantage of using secrets in this case is that I can store CF_API_TOKEN without writing it directly into the wrangler.toml file, where it might be unintentionally exposed.

You can set secrets for a GitHub repository by visiting Settings > Secrets in the repo's navigation.

You might note that the Yaml snippet is slightly different than the example given in the action's GitHub README. This is because I chose not to include my Cloudflare workers account id in the site's wrangler.toml file. According to this issue it's safe to do so, but you can also use GitHub secrets and a Cloudflare workers API token (with appropriate permissions) to do the same thing without potentially exposing your account ID.

It works!

With this action in place, your site will automatically be rebuilt and published to Cloudflare on any pushes to the master branch. You can modify that branch name to be something else, and/or add additional branch names.

This should be all you need to get going; read on for some additional tips and tricks.

Further notes

Skipping step #2

You can actually skip the step of manually setting up a Cloudflare Workers site, and use the GitHub action all by itself to do this. See this repo for an example. You'll note that repository has no workers-site/index.js file at all; it's handled by the Github Action directly.

Customizing the workers site behavior

You may, like me, need to customize some aspect of your static site at the Cloudflare worker level. This is the main reason for setting up a Workers Site as outlined in step #2.


I had some redirects set up with Next.js, and 11ty, being a much more basic tool, has no way to pull this off. Fortunately, it's quite easy to do in Cloudflare Workers. Here's my entire workers/index.js file, for reference:

import {
} from "@cloudflare/kv-asset-handler";

const DEBUG = false;

addEventListener("fetch", (event) => {
try {
} catch (e) {
if (DEBUG) {
return event.respondWith(
new Response(e.message || e.toString(), {
status: 500,
event.respondWith(new Response("Internal Error", { status: 500 }));

async function handleEvent(event) {
const url = new URL(event.request.url);
const { pathname, search, hash, origin } = url;
// redirects
switch (pathname) {
case "/json":
return Response.redirect(`${origin}/feed.json`, 301);
case "/lighthouse-circleci":
return Response.redirect(`${origin}/posts/lighthouse-circleci`, 301);
case "/posts/parsing-urls-from-text-using-native-web-tech":
return Response.redirect(`${origin}/posts/parsing-urls-from-text`, 301);
case "/rss":
return Response.redirect(`${origin}/feed.xml`, 301);
// no default
let options = {};

* You can add custom logic to how we fetch your assets
* by configuring the function `mapRequestToAsset`

// options.mapRequestToAsset = handlePrefix(/^\/docs/)

try {
if (DEBUG) {
// customize caching
options.cacheControl = {
bypassCache: true,
return await getAssetFromKV(event, options);
} catch (e) {
// if an error is thrown try to serve the asset at 404.html
if (!DEBUG) {
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: (req) =>
new Request(`${new URL(req.url).origin}/404/index.html`, req),

return new Response(notFoundResponse.body, {
status: 404,
} catch (e) {}

return new Response(e.message || e.toString(), { status: 500 });

You'll note the switch block in the handleEvent function. That's what handles the redirects.


My Next.js-powered site had json and RSS feeds, generated dynamically in Node. But there's no reason these feeds can't also be generated statically along with the rest of the site. Doing so is easy enough using @11ty/eleventy-plugin-rss.

Custom domain

During testing, I was deploying the site to my subdomain. I set up my custom domain using Cloudflare's DNS management (the actual domain I purchased elsewhere). I had to add this line to my wrangler.toml:

route = "*"

Next.js -> 11ty

I still really like Next.js, and it remains my go-to framework for any project that needs such a thing. But for running a blog, it wasn't great. There is an example in their repo, but managing things simple things like post metadata, feeds, and having a list of all posts on the blog home page always felt harder to me than it needed to be. 11ty is just simpler; it has fewer moving parts, and it does its primary task—building a static site—quickly and easily. The major trade off is that by switching to a completely static build, I lose the server-side Node.js environment I had with Next.js. (Next.js does have a really good static export feature, but I ran the blog with server-side rendering in Node.) Fortunately, the use of Cloudflare Workers seems to more than make up for this loss; I can do everything in a worker I was already using the Node server for, plus, it seems, quite a bit more.

Vercel -> Cloudflare

For a while, this blog was hosted on Vercel. I like Vercel, and have been using the platform since their v1. But the platform has had a lot of churn in a really short period of time. Their first version was defined by a really nice CLI and the ability to deploy pretty much any kind of application using a Docker container. The second version moved to an entirely serverless model, which still suited my needs well. But along the way the CLI decayed, to the point where it was not as fun to use. There were also a ton of pricing changes, and I could never really sort what size bill I might receive if the blog were suddenly to get a lot of traffic. Cloudflare, I'm hoping, will be a bit more stable. It's also cheaper (I'm paying for a $5/month Cloudflare Workers account) and the site now loads a lot faster.

That's it. Thanks for reading!