A blog that publishes itself

Close-up of a computer screen with a deployment configuration file open in the editor: the words "jobs", "deploy", "docker" and "steps" are legible, and the lines further down blur out of focus.
Ferenc AlmasiUnsplash License

In About this site I wrote about why I chose what I chose. This is the how: exactly what happens from the moment I type a sentence on my computer until that sentence is on the internet.

Along the way there is a Docker container, GitHub Actions building the site on a virtual machine that is destroyed when it finishes, and two GitHub repositories. None of the three pieces is obvious and all three have alternatives, so in this article I'll go through why I chose each one.

And it is the first of four articles about this site: the next three are about the CSS, the two languages and the one section that is not built with the rest.

The whole journey

I've organised each article as a directory where the article is kept as index.md, along with its images and attachments:

src/en/articles/
├── about-this-site/
│   ├── index.md
│   └── lapiz-sobre-libreta.jpg
└── blog-that-publishes-itself/
    └── index.md

The alternative was a loose file per article and a single image directory shared across the whole site. I ruled it out because of how the links end up: with the shared directory you have to write absolute paths —/img/diagram.png— and check them every time an article moves or gets renamed; with the image sitting next to the text, the Markdown says ![](diagram.png) and moving the whole directory breaks nothing. It also saves inventing site-wide unique names: the path tells them apart, so two articles can each have their own cover.jpg. The price is duplicating the odd file between the Spanish and English versions, and that is worth paying for the simplicity.

Being a directory and not a loose file is a requirement, though, and it comes from the shape of the URLs. By default —and 11ty documents this under Permalinks— each template is written out as …/about-this-site/index.html and served with a trailing slash: /en/articles/about-this-site/. The browser resolves relative paths against that directory, and that is what makes ![](photo.jpg) find the photo sitting next to the index.md.

The index.md name inside, on the other hand, is convention and not obligation: 11ty treats about-this-site/about-this-site.md exactly like about-this-site/index.md, and both end up at the same URL. I use index.md because that way the article's name is written once —in the directory— and renaming it means renaming one thing instead of two.

The directories are flat, not grouped by year: a year in the path would change the URL of everything already published. And the mess that grouping would avoid is a mess in the file system, which nobody actually browses — you reach an article through the site's index.

The images don't need preparing before they go in there either: the 11ty image plugin resizes them and converts formats during the build, so the photo goes in exactly as it is dropped into the article's directory. What it generates and what each screen ends up downloading is worth its own article.

This combination —images next to the article, and the plugin handling the rest— has a precedent, and the precedent tells the story of what has changed along the way. Graham F. Scott argued for the same structure in Using the Eleventy Image plugin without a central image folder, and back in 2022 holding that line took work: the plugin assumed the central directory, so he had to build his own shortcode to sidestep it. In 2024 he added a note at the top saying none of that is needed any more — relative paths work on their own and plain Markdown is enough. That "no longer needed" is the setup this site uses.

Once the article is finished, I open a Pull Request against main and merge it. That merge is the only trigger for everything that follows: from there on the whole process is automated.

GitHub Actions takes over. The workflow lives in .github/workflows/deploy.yml and fires like this:

on:
  push:
    branches: [main]
  workflow_dispatch:

Merging the PR produces a push to main, and that push launches the deploy. workflow_dispatch adds the manual trigger from the Actions tab, to deploy again with no change involved.

And here is the detail that ends up carrying the most weight: every run starts on a clean runner, which keeps nothing from the previous time and is destroyed when it finishes. There is no state to get dirty over time, and nothing to reuse either: every deploy builds from scratch.

In short, the steps in my case are four:

  1. actions/checkout clones the repository onto the runner.
  2. actions/setup-node installs Node.
  3. npm install and npm run build —plain eleventy— process src/, resolve the Markdown and the Nunjucks templates and write the HTML into _site/.
  4. The fourth publishes, and deserves its own section.

The last five deploys have taken between 22 and 30 seconds end to end.

Why I use two repositories

To build the site and publish it I decided to use two separate repositories.

  • jestemrique-personal-site, private. The source: the Markdown, the templates, the CSS, the 11ty configuration, the documentation and my notes. This is where I work.
  • jestemrique.github.io, public. Only the already-built HTML, which is what GitHub Pages serves. Never edited by hand.

There are two reasons for splitting them this way.

The URL. GitHub Pages only gives you the address at the root — jestemrique.github.io— when the repository is named exactly that. With any other name you publish in a subdirectory: jestemrique.github.io/my-blog/. And that isn't just uglier: it forces you to configure pathPrefix in 11ty so that every internal link and every CSS and image path carries that prefix in front. With a dedicated, well-named repository, the problem never comes up.

The mess stays private. Since what's public is only the result, I can write whatever I want in the working repository: half-finished drafts, interminable planning documents, notes on why I ruled each thing out. None of it goes out to the internet.

The bridge between the two is a third-party action, peaceiris/actions-gh-pages, which does exactly one thing: take a directory and publish it as the branch of another repository.

- name: Publicar en jestemrique.github.io
  uses: peaceiris/actions-gh-pages@v4
  with:
    deploy_key: $
    external_repository: jestemrique/jestemrique.github.io
    publish_branch: main
    publish_dir: ./_site

The permission is the interesting detail. By default a workflow can only touch its own repository, and this one has to write to another. The easy way would be to give it my personal GitHub token, which opens up my whole account. The correct way is a Deploy Key: an SSH key pair tied to a single repository. The public half goes to the target repository with write permission; the private half is stored encrypted as a secret in the source repository, and that is the secrets.DEPLOY_KEY above.

The difference matters the day something goes wrong. If that key leaks, the damage reaches as far as the key reaches —a repository of generated HTML, rebuilt in its entirety in 25 seconds— and you revoke it by deleting it, without touching anything else in my account. It's the principle of least privilege, and it's one of the few security practices that cost the same to do right as to do wrong.

One last detail about that action, also worth remembering for later: it publishes with keep_files: false, meaning it deletes from the public repository anything not present in the new build. It sounds aggressive but it's exactly what I need.

The site doesn't live on my computer

To write I need to see the article as it will look, not as plain text. 11ty comes with a development server that rebuilds and reloads the browser every time I save. The question is where that runs.

The usual thing is to install it on my computer: Node, npm, the project's dependencies. It works, but it has two drawbacks. The first, that it fills your computer with things only one project uses. The second is worse: the Node version becomes a property of your machine, not of the project. The day you update Node for any other reason, or want to start this up on another computer, nothing guarantees it will still work.

With Docker, the environment travels inside the project. It's four lines:

FROM node:22-alpine
WORKDIR /site
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

That describes a whole machine: it starts from an official image with Node 22 on Alpine —a tiny Linux distribution, meant for exactly this—, installs the dependencies inside and starts the server. I run a script, the container comes up, and the site is served at localhost:8080. When I'm done I shut it down and no trace is left.

The docker-compose.yml adds the piece that makes this usable for writing:

volumes:
  - ./src:/site/src

src/ isn't copied into the container: it's mounted. The directory on my disk and the one the container sees are the same one. I write in my editor, the server inside sees it instantly and reloads the browser. Without that you'd have to rebuild the image with every sentence and this would be unusable.

What I get:

  • The Node version belongs to the project. It's written in the Dockerfile, it travels in the repository, and it doesn't depend on what I happen to have installed today.
  • And it's the same one running in GitHub Actions: node:22-alpine here, node-version: 22 in the workflow. What I see locally is built with the same thing as the published version.
  • Working on another computer breaks nothing. Clone the repository, run the script, and that's it.
  • My computer stays clean. No Node, no dependencies, no versions fighting each other between projects.

Writing without publishing

I need to be able to leave an article started without it going out into the world. Here that is solved by working with drafts in the front matter: one line in the file's header.

---
title: Un blog que se publica solo
draft: true
---

What that line does is subtler than it looks: the draft is visible locally and not visible in production. I don't have to remove and re-add the mark to look at it. While I write I see it with its typography, its layout and its menu, exactly as it will look; and when GitHub Actions builds the public version, that article simply doesn't exist. It's two lines in .eleventy.js:

eleventyConfig.addPreprocessor("borradores", "md", (datos) => {
  if (!esProduccion) return;
  if (datos.borradores?.retenidos?.[datos.transKey]) return false;
});

esProduccion comes from comparing the ELEVENTY_RUN_MODE variable against "build": 11ty sets it on its own depending on whether you're serving locally or building to publish, with nothing to adjust by hand. More detail in the 11ty documentation on environment variables.

A preprocessor runs over each file before rendering it, and returning false means "this page doesn't exist". In development it leaves by the first line without filtering anything.

But the real rule doesn't fit inside the file. The site is bilingual, and an article isn't published until both its versions are: releasing only the Spanish leaves the English reader facing a broken link. So the minimum unit isn't the file, it's the pair.

And that's where this stops being trivial. 11ty processes templates one at a time, with none of them aware of the others: when the Spanish one's turn comes, there's nobody to ask whether the English one exists. You have to look at every article before starting to build.

That's solved by a global data file, src/_data/borradores.js, which runs once at the start of the build and whose result is available to every page. It walks both article directories, reads the header of each index.md and returns the list of the ones that aren't going out, with the reason:

[borradores] 1 artículo(s) NO se publican:
             · blog-que-se-publica-solo — falta la versión en: en

That file reads the disk directly, rather than asking 11ty's collections, and it's deliberate: the collections are built after deciding which pages exist, so asking them here would be circular — I'd need the answer in order to work out the question.

There's an awkward side effect left over. A finished article, merged and waiting for its translation, is invisible: it isn't on the site, it isn't in the way, and that makes it terribly easy to forget. The solution is daft and it works: the script that starts the site writes that list on screen every time. Whatever is waiting shows up on its own, without having to remember to go looking for it.

The first version fished the notice out of the Docker logs with a grep, and it didn't sit right with me: it was looking for a piece of text. Change the label on the message, or the indentation, or the prefix Docker puts on every line, and the script stops finding it. With no error: it stops warning me, and I carry on trusting it. I tried it with fake logs, and some of those changes took the whole list away, but others left half of it, which is worse — it looks like it worked.

Now it doesn't read the logs: it runs borradores.js and asks. It can do that because the file doesn't need 11ty for anything, so it answers the same inside the build and outside it. And if the question fails, it says so.

Decisions I make just once

In About this site I wrote about the part Claude Code plays in this. What's missing is the mechanical side: how the work gets into the repository.

Each task lives on its own branch, and the prefix says what kind it is: feature/ changes what the site does, fix/ repairs something broken and articulo/ is content. The distinction isn't cosmetic, because the risks aren't alike: breaking the CSS breaks the whole site; getting an article wrong leaves a typo. When it's done, PR against main and merge — the trigger for the deploy above. I don't repeat those rules every time: they're written in a CLAUDE.md file at the root of the project, which gets read at the start. I say "let's do part 2" and the branch is created with the right prefix. This isn't specific to this site: it's the general way I work with Claude across all my projects, and I'll probably write about it in a separate article.

The next step up is a command of my own. /articulo-nuevo "Un blog que se publica solo" checks that main is clean, creates the articulo/blog-que-se-publica-solo branch, writes src/es/articulos/blog-que-se-publica-solo/index.md with the front matter in place, brings the container up and hands me the URL.

Creating a directory and a file isn't the hard part. What the command saves me is deciding all over again. The decisions are written into it: the topic ids are exactly three and go in English, transKey is always the Spanish slug even when the English one differs, draft: true hides nothing locally, and the cover image takes an absolute path in both languages —with a relative one on one side and an absolute one on the other, eleventy-img generates two identical sets of variants—. These are decisions already made that, if they weren't written down, I'd have to work out again with every new article. It's documentation that also executes.

It has one loose end still unresolved: .gitignore excludes the whole .claude/ directory —local configuration, not part of the site—, so the command doesn't travel in the repository. The most specific thing this project has is precisely the only thing not backed up.

New markup with old styles

All of the above sounds very tidy. Now for the two times it wasn't.

Early on I deployed the light/dark theme switcher. Locally it was perfect. On the published site it was broken: giant icons and the switcher unfolded, with styles from two different versions mixed together.

My first thought was the build: GitHub Actions must have got something wrong while building. I was mistaken, and the way to find out was to stop supposing and measure. I brought down the CSS the public site was serving, measured it and compared it with the one I had built: exactly the same size. The server was sending the right thing, so the problem was at the other end: in my browser.

It was the cache. GitHub Pages serves files with cache-control: max-age=600 —ten minutes— and doesn't let you touch the headers: there is no option to change that number. My stylesheet was always called /css/main.css, so the browser asked for the new HTML, saw it already had the CSS stored and reused the previous one. New markup with old styles, for up to ten minutes.

And Ctrl+Shift+R is not the solution: it fixes things for whoever presses it, not for the visitor.

If you can't change the headers, the only thing left is to change the URL when the content changes. In production the file becomes main.<fingerprint>.css, where the fingerprint is the first eight digits of a SHA-256:

const huella = crypto.createHash("sha256").update(codigo).digest("hex").slice(0, 8);

Three details about that line, which are the ones that took thinking:

  • The hash is computed over the built CSS, not over the source files. It's the output that the browser caches. If I ever change minifier and the result comes out identical, nobody will have to download anything again.
  • It's a hash of the content, not the date of the deploy. With the date, every deploy would get a new URL even if the CSS hadn't changed, throwing away a perfectly valid cache. Tweaking an article shouldn't force anyone to re-download the stylesheet.
  • Production only. In development the server writes to disk on every reload and doesn't delete what came before, so with a fingerprint an afternoon of tweaks would leave the directory strewn with dead main.<fingerprint>.css files.

It looked like the build and in the end it was the cache.

A deleted page that was still being served

A few weeks later I changed the blog's list of topics. The ones I had —development, learning and tools— were the ones from the trial phase, not the final ones. "Personal" came in and "tools" went out, and I did it right then because it was cheap: only two articles used it.

The articles stayed. What disappeared was the topic, and with it its two pages: /es/tema/herramientas/ and /en/topic/tools/. That was the known and accepted cost.

I checked that they had disappeared. They had not disappeared. /es/tema/herramientas/ was still answering on my local server, with its title and its listing, as if I'd touched nothing.

The reason is the kind of thing you only learn by tripping over it: 11ty writes everything it builds into _site/, but deletes nothing it has stopped building. I was looking at a leftover file from an earlier build, served as happily as any other. A textbook 200 pointing at a ghost. This had already cost me a headache or two on other occasions.

And here those two details pay off. In production this doesn't happen, for two reasons: GitHub Actions builds on a freshly made virtual machine, where there is no old _site/ leaving remains, and the action that publishes deletes from the public repository anything not in the new build. The deploy cleans up; my computer doesn't. Which is exactly the opposite of what you'd expect.

The same change uncovered a second silent failure, this one inside the templates. When painting an article's topics, post.njk skipped, without saying anything, any identifier that didn't exist. It's fine that a typo shouldn't bring down the site, but it meant an article could go on naming a retired topic and the label would simply not appear, with nothing warning about it. Now the build says so out loud, and it was put to use in this very retirement:

[temas] ⚠ tema desconocido: "herramientas" — en es/articulos/...

What I learned

The two failures are the same one, seen from both sides.

In the first, the server was sending the right CSS and I was seeing the previous one. In the second, the server answered 200 for a page I had already deleted. In both cases I requested an address, got a perfectly correct response, and the correct response wasn't the proof I thought it was.

A 200 says "I found something to give back to you". It doesn't say when it's from, nor who put it there, nor whether there's still a reason for it to exist.

And both times the way out was the same: stop reasoning about what should be happening and measure what is. In the first failure that was a curl and a byte count, and it was enough to move the suspicion from the wrong place —GitHub Actions— to the right one —my browser— in a minute, after a good long while staring at the workflow and seeing nothing.

← Back to the articles

Jestemrique

Development, projects, learning…

Theme