# 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:

```md
---
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:

```ts
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:

```bash
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:

```yaml
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:

```yaml
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](https://blogs.kishankanejiiya.tech/posts/how-i-built-this-blog). Cover photo: USDAgov, Public domain, via Wikimedia Commons ([source](https://commons.wikimedia.org/wiki/File:North_Central_Telephone_Cooperative_Rural_Broadband_keeps_TN_and_KY_High-Speed_-_RUS_(20180927-RD-LSC-0988).jpg)).*

