2 min read

How this blog is built and shipped

A static Astro site in a container image, deployed from a homelab through the same pull-request pipeline as everything else, and reachable without opening a single port.

The first post on a new blog is traditionally about the blog. I’ll keep the tradition, because the setup behind it is a small, complete example of how I like to run things: everything in Git, every change through a pull request, and nothing on the internet that doesn’t need to be.

Why not WordPress

I already run two WordPress sites at home. They work, but they come with chores: plugin and core updates, a database to back up, PHP workers to size so a burst of traffic doesn’t take the host down, and a steady stream of bots trying wp-login.php. For a site that is mostly text and code samples, that’s a lot of moving parts.

A static site has almost none of them. The server hands out files, there’s no login page to attack, and there’s nothing to patch except the web server itself.

Writing posts

The site is built with Astro. Posts are Markdown files in a Git repository, with a bit of frontmatter:

src/content/posts/how-this-blog-is-built.md
---
title: How this blog is built and shipped
description: A static Astro site in a container image, deployed from a homelab.
published: 2026-09-17
tags: [astro, docker, cloudflare, homelab]
---

Code blocks can carry a file name and mark lines as added or removed, which matters on a blog where half the content is configuration.

Building the image

On every merge to main, GitHub Actions builds the site and packages it into a small container image. The build happens in a Node stage; only the generated files end up in the final image, served by an unprivileged nginx:

Dockerfile
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginxinc/nginx-unprivileged:1.29-alpine
COPY nginx/ /etc/nginx/
COPY --from=build /app/dist /usr/share/nginx/html

The image is pushed to GitHub’s container registry, tagged with the commit it was built from.

Deploying it

The homelab is described in a separate infrastructure repository: Terraform for the virtual machines, Ansible for their configuration, and a Docker Compose file per application. The blog is one more Compose stack, pinned to an exact image digest:

docker-compose.yml
services:
  web:
    image: ghcr.io/bmiest/reniersworx_blog:latest@sha256:…
    read_only: true
    tmpfs: [/tmp] # nginx needs somewhere to write its pid and temp files
    cap_drop: [ALL]
    mem_limit: 64m

When a new image appears, Renovate opens a pull request that bumps the digest. Merging it is the deploy:

docker-compose.yml
-    image: ghcr.io/bmiest/reniersworx_blog:latest@sha256:4f1c…
+    image: ghcr.io/bmiest/reniersworx_blog:latest@sha256:9a0e…

That’s one extra step compared to deploying straight from the blog repository, and I’m fine with it. There’s exactly one way anything reaches the homelab, and every change to what’s running there has a pull request attached.

Getting it online

Nothing at home is exposed to the internet directly. A cloudflared connector runs in its own small container and keeps an outbound tunnel open to Cloudflare. Requests for this domain come in through that tunnel, go to Traefik, and end up at the nginx container.

The trade-off of hosting at home is that the site is only as available as my internet connection. Because every page is a static file, Cloudflare can cache them at the edge, which should cover short outages at home. I’ll write that up properly once I’ve tested what actually happens when the tunnel drops.

The design

The logo has a small pipeline in it: a finished stage, the current one, and dashes for what’s still to come. The site uses that as its only decoration. On the blog’s front page it’s the timeline of posts. On a post like this one, if your screen is wide enough, it’s the outline on the left: sections you’ve read turn solid, the one you’re reading is blue.