A blog that publishes itself

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  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  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:
actions/checkoutclones the repository onto the runner.actions/setup-nodeinstalls Node.npm installandnpm run build—plaineleventy— processsrc/, resolve the Markdown and the Nunjucks templates and write the HTML into_site/.- 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-alpinehere,node-version: 22in 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>.cssfiles.
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.