Briter — Technical Companion
April 18, 2026 · 12 min read
Companion to Briter — Or, What Happens When You Diagnose the Wrong Problem. This one is for future-me, or anyone building the same thing. Precise where it helps; deliberately vague where specifics would narrow an attacker's target surface.
Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, standalone build) |
| Package manager + test runner | Bun |
| Content | MDX via next-mdx-remote |
| Live preview | unified + remark + rehype (plain markdown pipeline) |
| Images | sharp for variant generation, custom <picture> at render |
| Container | Single Docker container behind shared Traefik |
| Host | Hostinger VPS (Arch Linux), Docker Compose |
| Content store | Git — single repo, main is canonical |
| Auth | Password + TOTP (authenticator app), signed session cookie, in-memory rate limiter |
| Deploy | GitHub Actions with a content-only fast path |
Architecture
Two views of one repo
The container has two paths into the same content:
/app— Next.js standalone build output. Lean, almost no source files. This is what the web server runs from.- A second mount — the full host repo, used only by the git-sync worker when it needs to commit. Next.js never reads from here.
Both mounts expose the same content/notes/ and public/uploads/notes/ subtrees via sibling bind-mounts, so writes from the admin API end up visible to both views and to git. The separation exists because committing from /app would stage phantom deletions for every tracked-but-missing source file.
Auth model
The session cookie is signed (not encrypted) — enough to prevent forgery, cheap to verify. Rate limiter is in-memory per IP; single-container, single-user, so shared state isn't needed. Page routes under /admin/* are guarded by an auth layout; every admin API handler independently asserts a valid session before doing work. Login and preview live in route groups that bypass the auth layout on purpose.
Image pipeline
Paste, drag-drop, or file-picker all land at the same upload endpoint. From there:
Each upload produces eight files (four widths × two formats) plus a JSON manifest holding widths, formats, original dimensions, and byte counts. The editor inserts a plain markdown image reference pointing at one variant, so the MDX source stays portable. At render time a custom MDX <img> component intercepts references under /uploads/notes/, reads the sibling manifest, and emits a responsive <picture> with srcset entries for both formats.
Storage is capped at 500 MB under the uploads directory. The upload endpoint checks total usage before accepting a new file; over the threshold it returns 413 and the dashboard's usage bar flips red. No automatic eviction — I'd rather know I'm full than have images silently disappear.

Content-hashed filenames mean two pastes of the same screenshot produce zero extra bytes. They also make a long immutable cache header safe.
Editor polish: scroll sync and word count
The two-pane editor used to let the body and preview scroll independently with no coordination — fine for short notes, jarring for anything 1000 words +. Now each pane has its own fixed viewport height with overflow-auto, and a small React hook binds them together.
The sync is index-based, not offset-based. Parse the body for markdown heading lines (skipping fenced code blocks); you get an ordered list of line numbers. In the preview, query heading elements; you get a parallel ordered list. Scrolling the textarea past the N-th heading line scrolls the preview to the N-th rendered heading. And vice versa. A ref lock reset on the next animation frame prevents feedback loops. Fallback is proportional scroll when the body has no headings.
Word count and reading time live-update next to the "Body" label. Same ~200-wpm math as the public /notes listing, so what the editor shows matches what readers see.
Full preview with Site + RSS views
Previewing a draft used to give you a minimal centered article with none of the MDX component overrides — which meant Mermaid blocks rendered as literal code and uploaded images were broken. Fixed by moving the preview page into the same route group as the public site so it inherits SiteHeader, the reading-column constraint, and the footer. The preview now reuses the exact MDX component overrides from /notes/<slug>, so Mermaid renders as SVG and images come through the responsive <picture>.
On top of that, a view switcher toggles between two renderings:
- Site (default) — the real-site layout: back link, heading,
formatLongDate(publishedAt) · readingTime, prose body with Mermaid + responsive images, RSS footer.
- RSS reader — mocks how an RSS client renders the feed item: channel name, title, date, summary, tags, "Read on akashmishra.com →" link. A collapsed
<details>block shows the raw<item>XML so I can eyeball what the feed actually carries.
A dashed bar at the top marks the status (DRAFT / SCHEDULED / PUBLISHED) and holds the toggle. Preview links are token-gated and short-lived.
Git-sync queue
Writes from the admin UI don't commit synchronously. They land on a disk-backed queue of JSON jobs. A worker inside the container polls on an interval, picks any job whose scheduled next-run has passed, and walks a careful sequence:
- Branch assertion. Refuse to commit unless HEAD is
main. Prevents accidental commits on a stray feature branch. - Stage.
git addthe paths named in the job. - No-op check. If nothing is staged (duplicate enqueue, or retry after a prior attempt succeeded), drop the job — the content is already on
origin/main. - Commit. With a configured author identity, not whatever happens to be in
.gitconfig. - Push.
- Delete the queue file.
On failure the catch block increments an attempt counter, records the last error, and schedules the next retry with exponential backoff. The dashboard's sync badge reads pending count and turns red when any job has failed at least once. That badge is the most important piece of this — if sync is broken, I want to know before I've written three more posts on top of an unsynced tree.

Step 3 was learned the hard way. Without it, a duplicate enqueue would hit step 4 with an empty index, git commit would fail with "nothing to commit," and the job would loop forever even though the content was already safely on origin/main.
Content-only deploy fast path
Publishing from the admin UI is already fast (inline revalidatePath + bind-mounted content). The CI fast path is for pushes that originate elsewhere — e.g. a laptop pushing a content-only diff directly.
The revalidate endpoint reads its secret from a request header (never a query string — those leak into reverse-proxy access logs and process lists). It calls revalidatePath("/notes") and revalidatePath("/notes/[slug]", "page"). Next.js picks up the new MDX on the next request; content-only pushes end-to-end in well under ten seconds. Full rebuilds still take a few minutes, but I only trigger them when I actually change code.
Scheduled publishing
publishedAt in frontmatter is the source of truth. The notes loader filters out entries with publishedAt in the future so the list page never surfaces them, and individual note pages 404 until the date passes. Both routes use short-interval ISR so scheduled posts appear within a few minutes of their date. Slug pages also opt into dynamic params so a slug that didn't exist at build time can render on demand.
If I ever need minute-precision publishing I'll add a cron that hits the revalidate endpoint at scheduled timestamps. Current cadence doesn't need it.
Gotchas (a field guide for future-me)
Next.js 16 standalone doesn't serve public/ at runtime
This cost me the most. The standalone build copies public/ into the image at build time. Files written to public/uploads/notes/ at runtime exist on disk but are not served — the built-in static handler reads from a build-time manifest.
Fix: a narrow route handler that streams files off disk. Validate the filename strictly (only the characters your hash format can produce, only the extensions you actually generate) so the handler doubles as a traversal guard. Set a long Cache-Control: immutable — safe because content-hashed filenames.
Standalone build vs. git worktree
If you run a git-sync worker inside a standalone container, do not run it in /app. The standalone output is a skeleton — most source files aren't there. If .git in that directory tracks the full repo, every commit will stage deletions for everything that's "missing" (i.e. most of the source tree).
Fix: give the worker a separate bind-mount of the full host repo. Next.js reads content from its view, the worker runs git in its view, and sibling mounts keep the mutable subtrees (notes and uploads) in sync.
Idempotent retries
git add is a no-op when nothing changed; git commit then fails with "nothing to commit." If the same job gets enqueued twice — or if a retry runs after a successful commit on a prior attempt — the worker loops forever. A cheap "is anything staged?" check between add and commit gates this cleanly. Exit zero → drop the job as already-done.
docker compose restart does not re-read .env
It restarts the running process with the environment it already has. Env changes require docker compose up -d (compose detects the config diff and recreates). I lost thirty minutes to this during rollout.
Container-readable SSH key
A deploy key in /root/.ssh/… with 600 perms is unreadable by a container running as a non-root user. Either copy the key to a path owned by the container's uid (and keep it out of git), or mount it from a host-side location with matching ownership. Either way: the container's uid must be able to read the file.
Root-owned .git objects
First time you run git as root on the host, it writes objects into .git/ owned by uid 0. Later, when the container worker (non-root) tries to read or write them, git throws error: object <hash> is corrupt or insufficient permission for adding an object. Always run host-side git as the deploy user. When you forget (you will), re-chown the .git directory.
Route group for layout isolation
The public site's root layout used to wrap everything in a narrow reading column. That's right for reading; wrong for the admin dashboard. Split into a minimal root layout plus a (site) route group that holds the public chrome + reading-column constraint. /admin/* now gets its own wide layout without fighting inherited constraints. Moving the preview page into the (site) group made it inherit the exact reading column and chrome as the real /notes/<slug> — which is what "full preview" means.
"Dubious ownership" inside the container
Git refuses to operate on a repo whose .git is owned by a different uid than the running process. Even when host and container uids match, Git sometimes flags a bind-mounted path because of filesystem metadata quirks. Whitelist the relevant directories via container config so you don't have to write a .gitconfig file per container.
What's explicitly out of scope
- Multi-user. Single-user app by design. Auth, rate limiter, session model all assume one person.
- Collaborative editing. No. Personal site.
- A media library UI. Coming in v1.1 (rename / replace / usage-per-image). For now I
lsthe uploads directory on the VPS. - Minute-precise scheduling. A few minutes of ISR drift is acceptable.
- Rich-text / WYSIWYG editing. Plain Markdown textarea + live preview is a feature, not a limitation. MDX stays hand-authored.