Architecture

I built the CMS this site runs on.

Every word, project, post and number on bitsar.net is authored through an admin panel I wrote, stored as flat JSON and Markdown on disk, and rendered by the same Node process that serves 10 developer tools, compiles strangers' Java in a sandbox, and runs a prime sieve that has not stopped since the last deploy.

There is no database, no headless CMS and no third-party service in the request path. This page explains why, how, and — the part usually left out — where the design stops being the right one.

databases
0
Node process
1
JSON files hold it all
7
CMS licence
$0

The shape of it

One request, top to bottom

From TLS termination to a rename() on disk. Five layers, one process, no network hop leaves the box.

  1. client

    Browser

    Pages arrive server-rendered and complete. The devtools then run locally, in the tab.

    • SSR first paint
    • no data fetch on load
  2. HTTPS

    edge

    nginx

    TLS termination and security headers. Uploaded media is served straight off disk by alias — it never touches Node.

    • TLS
    • security headers
    • alias /uploads/
  3. proxy_pass → 127.0.0.1:3002

    gate

    Middleware

    Every navigation and API call passes a two-tier rate limiter, then an HS256 session check if the path starts with /admin. Runs in the edge runtime, so an unauthenticated request is rejected before a Node handler boots.

    • per-IP + global budget
    • jose HS256
    • static assets excluded
  4. NextResponse.next()

    app

    Next.js standalone · pm2 · one fork

    A single Node process on a single EC2 box carries the public site, the CMS, the API and a background compute loop.

    (site)

    Public pages. force-dynamic — every render reads the store.

    /admin

    The CMS. Server actions, no REST layer in between.

    /api

    Engagement, the live math board, an SSRF-guarded proxy.

    engine

    Background compute loop, started by instrumentation.ts.

    sandbox

    Strangers' Java, in a transient systemd unit with no network.

    • server actions
    • revalidatePath
    • instrumentation hook
    • one run at a time
  5. fs read / atomic write

    data

    DATA_DIR

    Flat JSON and Markdown on disk. It lives outside the app directory, which is what makes the rsync --delete on every deploy safe.

    content.json

    Hero, stats, pillars, contact, about.

    projects · skills · timeline

    Typed collections.

    posts.json + posts/*.md

    Metadata split from body.

    engagement.json

    Views, likes, comments — pseudonymous.

    • tmp + rename()
    • survives deploys
    • git-diffable

Decisions

Thirteen choices worth defending

Anyone can wire a CMS together. These are the calls that decided what kind of system it became.

  1. 01

    Why not Contentful, Sanity or WordPress

    The content model here is about five shapes, and they were already typed in TypeScript before there was a CMS at all. A hosted CMS would have meant adding a network hop and an API key to every page render in exchange for an editor UI I could write in an afternoon.

    The deciding argument was different, though: a portfolio is judged on what you built. Wiring up someone else’s CMS is a configuration task. Building one is an engineering artifact — and this page exists because of that choice.

    • build vs buy
    • no vendor lock-in
    • content is git-diffable
  2. 02

    Flat files instead of a database

    The entire corpus is a few hundred kilobytes and is read far more often than it is written. The read path is fs.readFileSync plus JSON.parse — measurably faster than a socket round-trip to a Postgres running on the same one-vCPU box.

    Everything a database would buy — concurrent writers, transactions, a query planner — has no customer here. There is exactly one writer, and it is me. That is the honest justification, and it is also the limit: this design stops working the moment there are two writers or two processes.

    • read-heavy
    • single writer
    • stated limit
  3. 03

    Every write is atomic

    Saves write to <file>.tmp and then rename() over the target. Within a filesystem, rename is atomic, so a crash mid-write leaves either the old file or the new one — never a half-written JSON.

    That matters more than it sounds. These files are read on every request, so one truncated write would take down every page at once, and it would happen at the worst possible moment: while publishing.

    • tmp + rename()
    • crash-safe
    • no torn reads
  4. 04

    Reads merge over a typed default

    getContent() spreads the stored JSON over a default object built from the TypeScript type. Adding a field to the content model ships as code — production data never needs a migration, and a key the stored file has not got yet can never render as undefined in the page.

    It is the cheapest possible schema evolution story, and it is the reason the model has been extended a dozen times without a single migration script.

    • schema evolution
    • no migrations
    • undefined-proof
  5. 05

    Server actions, not a REST API

    The admin panel posts FormData straight into functions marked ‘use server’. There is no fetch layer, no request/response types duplicated on both sides, and no second API surface that has to be authenticated separately.

    Auth is checked twice on purpose: middleware rejects the navigation before the action is reachable, and requireSession() re-checks inside the action itself — because a server action is a POST endpoint whether or not a page ever links to it.

    • no client fetch layer
    • defence in depth
    • FormData in, redirect out
  6. 06

    Publishing tells the search engines

    Saving in /admin does three things beyond writing the file: revalidatePath() for the routes that data actually renders, an IndexNow submission so Bing and Yandex recrawl within minutes, and — implicitly — a bump of the file mtime.

    sitemap.ts reads those mtimes as lastmod, and each page tracks only the files that render it: editing a project does not claim the resume changed. Code-driven pages report the build time instead. Neither is new Date() — a sitemap that claims everything is fresh on every crawl is a sitemap Google learns to ignore.

    • revalidatePath
    • IndexNow
    • honest lastmod
  7. 07

    The password hash is not in the deploy bundle

    The bcrypt hash lives in DATA_DIR/auth.json at mode 0600 — outside the application directory. It is therefore not in the rsync bundle, not in the repository, and not readable by the nginx user even though nginx can traverse the parent directory to serve uploads.

    The session itself is an HS256 JWT signed with jose rather than a server-side session table. That is a deliberate pick: jose runs in the edge runtime, which is what allows middleware to verify the cookie and turn away an unauthenticated /admin request before any Node code runs.

    • bcrypt
    • jose HS256
    • edge-verifiable
  8. 08

    Uploads are typed by their bytes, never their name

    nginx serves /uploads/ off disk and picks Content-Type from the extension — so the extension, not the content, decides whether a file is a picture or same-origin script.

    The validator reads the magic number, rejects anything outside the allowlist, and stores the extension it derived rather than the one that was uploaded. That defeats a polyglot file that is simultaneously a valid PNG and valid HTML. SVG is excluded on purpose: it is an XML document that can carry a script tag.

    • magic-number sniffing
    • derived extension
    • no SVG
  9. 09

    Engagement counts without identifying anyone

    Unique views and likes are deduped by a salted SHA-256 of the IP address, truncated to 64 bits. The salt is server-side only, so the hashes cannot be reversed by walking the small IPv4 space, and no raw address is ever written to disk.

    Comments carry the same pseudonymous hash so moderation is possible, and they are capped per post — the oldest fall off — so the file has a ceiling by construction rather than by hope.

    • salted + truncated hash
    • no raw IPs
    • bounded growth
  10. 10

    One writer at a time, enforced

    Every engagement mutation is read → mutate → write. Two overlapping requests would both read the old file and the second write would silently discard the first: a lost like, a dropped comment.

    They are chained onto a single promise so they run one at a time. This is correct for one pm2 fork and it is exactly wrong for two — which is the sort of thing worth writing down next to the code rather than discovering later.

    • serialized queue
    • lost-update fix
    • single-process only
  11. 11

    Untrusted code, on the box that holds my credentials

    The playground had to execute Java somewhere. Python compiles to WebAssembly and JavaScript is already JavaScript, so both run in the visitor’s own tab — free, instant, and impossible to abuse. Java has no honest browser story, so it runs here, which means code written by strangers executes a few processes away from the admin session key.

    The first instinct was Docker, and it was wrong for this host: the daemon plus a JDK image is around 1.6 GB against 980 MB free, and it would have put the app’s user in the docker group — root-equivalent on the box that also runs my other apps. The JDK is installed directly instead, and every run becomes a transient systemd unit: no network (which is what keeps it away from the EC2 metadata endpoint and its IAM credentials), /home invisible, /var/www inaccessible, a read-only root, a throwaway uid, capped memory, CPU and tasks, and a kill at ten seconds.

    Those flags are the second line of defence. The first is structural: the app may run exactly one command as root, and that wrapper accepts two tokens — a 32-hex job id and a Java identifier — each matched against an anchored pattern before a fixed argv is built. Program text never crosses the privilege boundary as an argument, only as a file.

    It also ships disabled. The feature needs an explicit environment flag before it will run at all, because the failure mode of accidentally-on is worse than the failure mode of quietly-off.

    • no daemon, no image
    • validated argv
    • off by default
  12. 12

    A compute loop that survives deploys

    instrumentation.ts starts a background loop when the process boots. Each tick advances a segmented prime sieve and a Collatz search by a fixed, tiny amount, and the state is checkpointed to DATA_DIR — so a restart resumes rather than starting over.

    The sizing is deliberate and hard-won: an earlier version accumulated without bound, pinned the single vCPU, and took the site down. Every tick now does a few milliseconds of work and every persisted value is small.

    • checkpointed state
    • bounded per-tick work
    • learned the hard way
  13. 13

    Deploy is one command

    next build in standalone mode, the bundle and static assets rsynced to EC2 over an ephemeral Instance Connect key, then pm2 restart. Downtime is a single process restart.

    DATA_DIR sits outside the synced directory, which is the whole reason rsync --delete is safe to run against the app directory. Content and deploys are genuinely independent: publishing a post needs no build, and a deploy cannot lose a post.

    • standalone output
    • EC2 Instance Connect
    • pm2

Where it breaks

What I would change at scale

A design without a stated failure point is a design nobody has thought hard about.

A second process breaks the store

The serialized write queue is in-process. Scaling to a pm2 cluster or a second box needs a real lock — or the database this deliberately avoids.

One instance, so no HA

A box reboot is downtime. Acceptable trade for a portfolio; not a pattern to copy into anything with an SLA.

Search is client-side

Blog and devtool search is fuzzy matching over metadata in the browser. Correct at tens of posts, wrong at thousands — that is when a real index earns its keep.

No image pipeline

images.unoptimized is still set from the static-export phase, so uploads are served at whatever size they were saved at. The discipline lives in what gets uploaded, not in a resize step.