Go to file
2026-09-08 19:54:06 +02:00
app feat: new-version 2026-09-08 19:54:06 +02:00
content/posts feat: new-version 2026-09-08 19:54:06 +02:00
public feat: new-version 2026-09-08 19:54:06 +02:00
server feat: new-version 2026-09-08 19:54:06 +02:00
shared feat: new-version 2026-09-08 19:54:06 +02:00
.dockerignore feat: new-version 2026-09-08 19:54:06 +02:00
.env.example feat: new-version 2026-09-08 19:54:06 +02:00
.gitignore feat: new-version 2026-09-08 19:54:06 +02:00
docker-compose.yml feat: new-version 2026-09-08 19:54:06 +02:00
Dockerfile feat: new-version 2026-09-08 19:54:06 +02:00
nuxt.config.ts feat: new-version 2026-09-08 19:54:06 +02:00
package-lock.json feat: new-version 2026-09-08 19:54:06 +02:00
package.json feat: new-version 2026-09-08 19:54:06 +02:00
README.md feat: new-version 2026-09-08 19:54:06 +02:00
tsconfig.json feat: new-version 2026-09-08 19:54:06 +02:00

nak0x.dev

A monospace, terminal-shaped portfolio in Nuxt 4, with a blog whose posts live in a separate git repo as plain markdown. Push a .md, a webhook fires, the post is live. No rebuild, no CMS, no database.

  browser ──► nuxt (nitro, SSR)
                 │
                 ├─ /          ──► site.json  ─┐
                 ├─ /blog/*    ──► posts/*.md ─┼──► gitea api ──► content repo
                 └─ /dash      ──► edits both ─┘         ▲
                                                          │
                          push ────────────────────────────┘
                            └─ webhook ──► /api/webhook/content (busts cache)

Two things live in the content repo: posts/*.md (the blog) and site.json (the portfolio — bio, skills, links, projects). /dash edits both and commits through the git API. Everything else is code.

quick start

npm install
cp .env.example .env      # then edit it
npm run dev               # http://localhost:3000

Out of the box .env.example points at Gitea. To write offline against the content/posts/ folder in this repo instead:

CONTENT_PROVIDER=local npm run dev

where your content goes

Two repos, on purpose: the site is code, the blog is prose, and they have very different commit rhythms.

Journal/                     # the content repo (git.nak0x.dev/Nak0x/Journal)
├── site.json                # portfolio content — written by /dash
└── posts/
    ├── 2026-09-08-hello.md
    └── rust-on-old-thinkpads/
        ├── index.md
        └── bench.png        # ![](./bench.png) resolves against the post's dir

site.json is optional. Without it the site uses the seed compiled into shared/site.ts; the first save from /dash creates the file. A malformed or partial site.json degrades field by field back to that seed rather than taking the site down.

Frontmatter — everything is optional except, in practice, title:

---
title: how this blog works
description: one line for the list view and the og tags
date: 2026-09-08              # or just name the file 2026-09-08-*.md
updated: 2026-09-09
tags: [meta, nuxt]
slug: how-this-blog-works     # defaults to the filename, date prefix stripped
draft: true                   # hidden in production, visible in dev
---

content/posts/2026-09-08-hello.md in this repo is a working example — copy it into the content repo as your first post.

wiring the webhook

Gitea — repo → Settings → Webhooks → Add Webhook → Gitea:

field value
Target URL https://nak0x.dev/api/webhook/content
HTTP Method POST
Content Type application/json
Secret same value as CONTENT_WEBHOOK_SECRET
Trigger On Push Events
Branch filter main

GitHub — same URL and secret, content type application/json, "Just the push event". The handler accepts both X-Gitea-Signature and X-Hub-Signature-256.

Generate the secret with openssl rand -hex 32. Requests without a valid HMAC get a 401, so the endpoint is safe to leave public.

The webhook is an optimisation, not a dependency: CONTENT_TTL (default 600s) expires the cache anyway, and a stale copy is served while the refresh runs — so a Gitea outage degrades to "slightly old posts", never to a 502.

deploying on Coolify

  1. New Resource → Application → Dockerfile (or Public/Private Repository with Build Pack = Dockerfile). Coolify picks up the Dockerfile at the repo root.
  2. Port: 3000. The image already sets HOST=0.0.0.0.
  3. Health check path: /healthz (the image also has a HEALTHCHECK).
  4. Environment variables — paste from .env.example and fill in: CONTENT_PROVIDER, CONTENT_REPO, CONTENT_BRANCH, CONTENT_DIR, GITEA_URL, CONTENT_WEBHOOK_SECRET, NUXT_PUBLIC_SITE_URL, plus DASH_PASSWORD and CONTENT_TOKEN if you want to edit from /dash.
  5. Build variable — NUXT_PUBLIC_SITE_URL is inlined into the client bundle, so it also has to exist at build time. In Coolify, tick "Build Variable" on it, or set the build arg SITE_URL.
  6. Domain: https://nak0x.dev. Coolify terminates TLS in front.

Redeploy on push to this repo is the usual Coolify webhook. Posts do not need a redeploy at all — that is the whole point.

Locally, docker compose up --build runs the same image.

environment

variable default what it does
NUXT_PUBLIC_SITE_URL https://nak0x.dev canonical URL for rss, sitemap, og tags
CONTENT_PROVIDER gitea gitea | github | local
CONTENT_REPO Nak0x/Journal owner/repo of the content repo
CONTENT_BRANCH main branch to read
CONTENT_DIR posts sub-directory holding the markdown ('' = root)
GITEA_URL https://git.nak0x.dev Gitea base URL (ignored for github)
CONTENT_TOKEN — PAT, only for private repos or GitHub rate limits
CONTENT_TTL 600 seconds before a cached post goes stale
CONTENT_WEBHOOK_SECRET — HMAC secret; without it the webhook returns 503
CONTENT_LOCAL_DIR content repo root used when CONTENT_PROVIDER=local
CONTENT_SITE_FILE site.json portfolio data, relative to the repo root
PROJECTS_GITHUB_USER nak0x github account to list repos from ('' disables)
PROJECTS_GITEA_USER Nak0x gitea account to list repos from ('' disables)
PROJECTS_GITEA_URL GITEA_URL gitea instance to read the repos from
PROJECTS_GITHUB_TOKEN — PAT; lifts github's 60 req/h anonymous limit
PROJECTS_GITEA_TOKEN CONTENT_TOKEN PAT for the gitea instance
PROJECTS_EXCLUDE — repos to drop, comma-separated name or owner/name
PROJECTS_INCLUDE_FORKS false true lists forks as projects too
PROJECTS_TTL 900 seconds before the repo list is refetched
DASH_PASSWORD — unset disables /dash entirely
DASH_SESSION_SECRET derived from password HMAC key for the session cookie
DASH_SESSION_HOURS 168 how long a login lasts
SHOW_DRAFTS false true renders draft: true posts in production

/dash — editing without a terminal

/dash is a small single-user CMS for this site. It is disabled until DASH_PASSWORD is set, and it can only write once CONTENT_TOKEN holds a token with write access to the content repo (Gitea: scope write:repository). Without the token it still works, read-only.

DASH_PASSWORD=$(openssl rand -hex 24)   # login
CONTENT_TOKEN=…                         # gitea/github PAT, write scope
page what it does
/dash repo status, post list, cache refresh, create/delete posts
/dash/posts/:slug markdown editor with live preview; new creates a post
/dash/site bio, skills, links and the whole project list

Every save is one commit through the git API — no separate deploy, no rebuild, and the history is the same one you get from pushing by hand. The editor sends the sha it loaded with each save, so if you also edited the file from nvim in the meantime you get a 409 instead of a silent overwrite.

The preview pane posts the body to /api/dash/preview and renders the HTML the published page would show, Shiki highlighting included — the client never gets a markdown parser.

Auth. A password check sets an HttpOnly, SameSite=Lax, HMAC-signed cookie (Secure behind an https proxy), valid for DASH_SESSION_HOURS. The signing key defaults to a value derived from the password, so changing the password logs every session out. Failed logins are rate-limited per IP (8 per 10 minutes). /dash is Disallowed in robots.txt and sends noindex.

Every /api/dash/* route checks the session server-side; the route middleware only spares you a page that would 401 on load.

where the project list comes from

/projects is not a list anybody maintains: it is every public repository of PROJECTS_GITHUB_USER and PROJECTS_GITEA_USER, read from both forges on every cache miss and merged. Forks are skipped unless PROJECTS_INCLUDE_FORKS=true, and a repo on both forges is shown once (github wins). Each repo maps to a card on its own:

card comes from
tagline the repo description
stack the primary language, then the repo topics
year the year the repo was created
status archived if archived, active if pushed in the last 120 days, else shipped

The projects block in site.json is an overlay on top of that, not the list itself. An entry whose href (preferred) or name matches a repository replaces that repo's fields wherever it actually says something — a nicer name, hand-written bullets, featured: true for the home page, hidden: true to drop it from the site. An entry that matches no repository is still shown, so a project with no repo behind it does not disappear. All of it is editable from /dash.

Both forges are independent: if github rate-limits you, the gitea repos still render and /projects says one source is missing. If both fail, the overlay entries are all that is left — which is exactly the old hand-written list.

editing the portfolio in code instead

shared/site.ts holds the seed and the types. Edit it when you want a change tracked in this repo rather than the content one — but note that a site.json in the content repo overrides it.

routes

route what
/ hero, about, skills, featured work, latest posts
/projects every public repo, github + gitea
/blog, /blog?tag=x index, filterable by tag
/blog/:slug a post, with TOC and prev/next
/dash, /dash/* the editor (password-protected, noindex)
/rss.xml feed
/sitemap.xml sitemap
/healthz liveness probe
/api/webhook/content push webhook (POST, HMAC-signed)

stack

Nuxt 4 · Nitro (node-server) · markdown-it + Shiki · gray-matter · hand-written CSS. No UI framework, no client-side markdown, no database, ~zero JavaScript on the blog pages beyond hydration.