Skip to main content

This blog runs on a two-core box at home

Markdown in a repo, static pages from Next.js 15, one small Docker container and a Cloudflare Tunnel, so nothing on my network is open.

Updated 7 min readMarkdown
This blog runs on a two-core box at home

I've started a blog maybe four times. Every one of them lived on somebody else's platform, and every time I stopped writing around the point the platform changed something I didn't like (a paywall popup, a "members only" badge on a post I'd written for free, an editor that mangled my code blocks on paste).

So this time I wanted the dull version: my words in a git repo, my URLs, my HTML. If I disappear for six months, the site should still be up and look the same when I come back. These are my notes from setting it up, mainly so that future me remembers why things are the way they are.

Why not Medium or Hashnode

Both are perfectly fine, and Hashnode in particular is nice for developers. You can even point your own domain at it. Two things kept bothering me though.

The first was links. If I write something useful I'd like the URL to still work in five years, and a platform can change its URL scheme, put a login wall in front, or shut down. With my own domain and plain files, the only person who can break a link is me.

The second was the HTML itself. I care how a code block renders, whether a table scrolls on a phone and what the page weighs. I've spent years telling people at work that Core Web Vitals matter, and it felt a bit silly to then publish on a page that loads three trackers before the first paragraph.

The trade-off is that nobody will stumble on me through a feed. I can live with that.

What it's made of

Next.js 15 with the App Router and React 19. Posts are markdown files in content/posts/ with a bit of frontmatter on top:

---
title: "How I built this blog"
date: "2025-01-11"
summary: "Markdown in a repo, Next.js static pages..."
tags: ["infra", "nextjs"]
---

I've started a blog maybe four times...

At build time I read each file, split the frontmatter off with gray-matter, and push the body through unified, remark and rehype. Shiki does the syntax highlighting in the same pass:

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeShiki from "@shikijs/rehype";
import rehypeStringify from "rehype-stringify";

export async function render(markdown: string) {
  const file = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeSlug)
    .use(rehypeShiki, { themes: { light: "github-light", dark: "github-dark" } })
    .use(rehypeStringify)
    .process(markdown);
  return String(file);
}

generateStaticParams lists every slug and dynamicParams = false turns anything else into a 404, so there's no server sitting there trying to render a post that doesn't exist. I like that the highlighting happens at build. The browser gets coloured spans and not a single byte of highlighting JavaScript.

I did try MDX first. It's powerful, but I kept reaching for components inside posts, and after a while the posts weren't portable any more. Plain markdown I can move to anything.

Where it runs

There's an Ubuntu machine at home that is not impressive at all: 2 CPU cores, under 6 GB of RAM, and a 48 GB disk that's always fuller than I'd like. It already runs a self-hosted Supabase, n8n and a few side projects in Docker.

Because it's that small, I don't build on it. Running next build on two cores while Postgres is busy is slow, and it pushes the load past what the box can comfortably take. So the build happens on my Mac and the server only ever receives the runtime.

Next.js has output: "standalone" for exactly this. It works out which files the server really needs and copies them into .next/standalone, trimmed node_modules included. Add the static folder and public/, and you've got a directory that runs with node server.js.

My deploy script is about forty lines of bash, and this is most of it:

npm ci && npm run build
cp -R .next/standalone/. out/web/
cp -R .next/static out/web/.next/static
cp -R public out/web/public

tar -C out -czf - . | ssh home-box "tar -C ~/apps/blog -xzf -"
ssh home-box "cd ~/apps/blog && docker compose up -d --build"

The Dockerfile on the server copies that folder into node:22-alpine and runs it as the node user. No compiler and no install step.

Locking the container down

It's only a blog, but it is still a Node process on my home network, so it gets as little as I can give it:

services:
  web:
    image: blog-web:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8102:3000"
    read_only: true
    tmpfs:
      - /tmp:size=16m
    security_opt: ["no-new-privileges:true"]
    cap_drop: [ALL]
    mem_limit: 256m
    pids_limit: 256

With a read-only filesystem, a misbehaving dependency can't write a file anywhere useful. There are no Linux capabilities, a memory cap stops a leak from dragging Postgres down with it, and the port is bound to 127.0.0.1, so nothing else on my LAN can reach it directly, never mind the internet.

No open ports

This is the part I'm happiest with. My router has no port forwards and nothing listens on a public IP.

Requests come in through a Cloudflare Tunnel instead. cloudflared runs on the box and opens an outbound connection to Cloudflare; when someone loads the blog, Cloudflare sends the request back down that connection and cloudflared passes it to 127.0.0.1:8102. The config is just a list of hostnames and where each one goes:

ingress:
  - hostname: blog.example.tech
    service: http://127.0.0.1:8102
  - service: http_status:404

Keep the catch-all at the bottom. Without it, a hostname you forgot to list could fall through to something you never meant to expose.

TLS and caching come from Cloudflare as well, and on a mostly static site that means plenty of requests never reach my box, which is handy because home broadband upload speeds are what they are. When I need to get into the machine from outside I use a Zero Trust VPN, so SSH isn't open to the world either.

Cost, and the catch

The domain, plus electricity for a machine that was already switched on.

The catch is that I'm the ops team. If the box dies, the blog is down until I fix it. I'm okay with that because the files are in git and the whole deploy is one script, so moving to a VPS would take an evening.

A couple of things are still on the list. I've switched off Next's image optimisation because I don't want two cores resizing images on request, which means I size them by hand for now. And there's no RSS feed yet, but there's also only one post, so that can wait.

If you fancy doing something similar, I'd get the deploy working before writing a word. I wrote the script first, and I'm fairly sure that's the only reason this post exists.

Update (22 Oct 2026): it's on Next.js 16.4 and React 19.3 now. The upgrade was mostly the async params change and a couple of renames, Turbopack builds are noticeably quicker on the Mac, and there is finally an RSS feed. Same box at home, same tunnel, same forty-ish lines of bash.


First published on my blog. Cover photo: USDAgov, Public domain, via Wikimedia Commons (source).

About the author

  • Full stack engineer in London with 5+ years on production web platforms. I lead AI-assisted modernisation at Xanda: React and Go for the UK energy market, a government claims portal, and moving old Java and C++ systems onto the cloud. Outside work I run a small home server behind a Cloudflare Tunnel with a handful of public tools on it: Doorstep (rental alerts across Rightmove, OnTheMarket and OpenRent), SeenSooner (NHS A&E and planned-care waits), GridPulse (live GB grid carbon), Sift (LLM ticket triage that survives model failures) and AI Radar (a news digest written hourly by an n8n workflow). I write here about React and Next.js upgrades, Node, coding with AI agents, and the CVEs I've had to patch. Originals live at blogs.kishankanejiiya.tech.

No comments yet. Be the first to comment.