Zaw Aung's Blog

Building this blog with Astro, Node.js, nginx and Docker

I wanted a blog I fully understand, without the overhead of WordPress. This is a tour of what I built, and why each piece is there.

The idea: static first

Almost everything on this site is a plain HTML file. Posts are Markdown files in a git repository; Astro turns them into static pages at build time, and nginx serves those files straight from disk. There is no application code in the path when you read a post, which makes it fast and leaves very little to attack.

Only the interactive bits need a running program: comments, the contact form and a moderation page. Those live in a small Node.js server inside a Docker container, and nginx forwards just two paths to it.

browser ──► nginx ──┬─ /          static files from disk
                    ├─ /api/      ─► Node container (comments, contact form)
                    └─ /admin/    ─► Node container (password + IP allowlist)
                                         │
                                         └─ SQLite file on a Docker volume

The pieces

Astro handles the pages. Posts are a content collection with a schema, so a typo in a post’s front matter fails the build with a clear message instead of quietly producing a broken page. Drafts are a single draft: true line.

Search is Pagefind. After each build it indexes the generated HTML into small static files, and the browser searches them directly. There is no search server to run or secure.

Comments and the contact form use SQLite through better-sqlite3: one file, no database server. Every comment is held for moderation, so spam is never published unreviewed. Contact messages are stored, not emailed, because sending mail would mean SMTP credentials and a way to be abused as a relay. Spam protection is deliberately modest: a hidden honeypot field and a per-IP rate limit. IP addresses are stored only as a salted hash.

The editor is Keystatic, but only on my development machine. It writes plain Markdown into the repository, and it is not part of the production build at all. The server never gets a writable editor to attack.

The locked-down part

The container is where I was most careful:

On the nginx side, /admin/ needs both a password and an allowed IP address. Nothing else is proxied, so the editor and internal routes simply do not exist on the public site.

Things that bit me

Keeping it running

There is no auto-updating dashboard, so upkeep is mine: a nightly cron job takes a verified backup of the database, and about once a month I update dependencies, run npm audit, refresh the base image and redeploy with one script. The whole site, apart from the database, is in git, so rebuilding on a new server is a clone, a deploy and a restore.

I built it with help from Claude Code, working in four stages and confirming each before moving on. The full write-up, with the reasoning behind every decision, is in the repository’s guide.

Comments

Leave a comment

Comments are held for moderation before they appear.