My corner of the internet, and the little machine that runs it.
Hi, I'm Furkan. This is my personal site: my writing and stray thoughts, the projects I've built, the experience I've gathered, the gear I use, the photos I've taken, and the places I've been. It's also a small self-hosted machine I enjoy tending. Have a look around at furkanunsalan.dev.
The whole thing runs off a single VPS with no third-party CMS, no analytics, and no tracking. Content lives in Postgres and is edited through a custom admin I built. Even the images are self-hosted.
- Writing. Essays and short thoughts, written in Markdoc with a table of contents, reading time, and inline Mermaid diagrams.
- Projects. Hand-curated builds plus my public GitHub repos, with commit sparklines and a couple of bespoke project pages.
- Experience. Roles and communities, grouped by organization.
- Gadgets. The gear I use, drawn from a small blueprint-style SVG sketch library that I pick from in the admin.
- Photos. Self-hosted photography with the EXIF shot data behind each frame.
- Places. A self-owned map (Leaflet + Carto) with no Google dependency at runtime.
- Bookmarks. An Obsidian-style force graph of my saved links and their tags, fed from my own Karakeep instance.
- Résumé. A PDF rendered in-browser, generated from the same database.
- A terminal twin. The same content over SSH. Try
ssh -p 2222 furkanunsalan.dev.
flowchart LR
visitor([Visitor]) --> caddy["Caddy · auto-HTTPS"]
admin([Admin]) -->|iron-session + argon2| caddy
caddy --> web["Astro SSR · distroless<br/>Docker Compose"]
web --> pg[("PostgreSQL 17<br/>Drizzle ORM")]
web --> img["/api/img<br/>self-hosted photos & uploads"]
ssh([SSH client]) --> twin["Terminal twin · Go"]
twin --> pg
gh["GitHub Actions"] -->|build image · compose up| web
One box, one Caddy in front, everything else on localhost. The site is an
Astro SSR server (React components as islands) that ships as a distroless
container and runs, together with its Postgres, as a small Docker Compose stack.
It shares that Postgres with the Go terminal twin, so there is one source of
truth and no second copy to keep in sync. There's a
full write-up of the self-hosting stack
on the site.
- Postgres is the source of truth. Pages read through
lib/content.ts(Drizzle over postgres-js); the schema lives indb/schema.ts. - Custom admin CMS under
/admin/*, gated by iron-session with an argon2id password hash and a rate-limited login. - Cached, always-fresh reads. DB readers go through an in-memory tagged cache with per-collection tags; every admin edit busts the right tag, so pages serve from cache without hitting Postgres on each view while staying instantly fresh.
- Fail-soft everywhere. Every reader degrades to a safe default on a query error, so a database blip never takes the whole site down.
- Self-hosted images. Uploads are resized to WebP and EXIF-parsed server-side
with
sharp+exifr, then streamed from a volume outside the image so they survive every release. - Vercel-free OG images. Social cards are built as SVG and rasterized with
@resvg/resvg-jsusing a self-hosted Inter font — no hosted image service. - Sane security defaults. Security headers, a path-traversal-safe image server, sanitized DB errors, and no user-uploaded SVGs.
- Tested and gated. Vitest unit tests cover the security-critical helpers, and CI runs typecheck, tests, and build on every push.
| Area | Choice |
|---|---|
| Framework | Astro 4 (SSR, Node standalone adapter, islands) |
| Language | TypeScript (strict) |
| Data | PostgreSQL 17 + Drizzle ORM (postgres-js) |
| Styling | Tailwind CSS (AMOLED palette) |
| Content | Markdoc + Mermaid |
| Auth | iron-session + argon2id |
| Images | sharp + exifr, served from /api/img |
| OG images | @resvg/resvg-js (no hosted service) |
| Validation | zod at the API boundary |
| Maps | Leaflet + Carto |
| Terminal | Go + Charm Wish / Bubble Tea |
| Testing | Vitest + GitHub Actions CI |
| Hosting | Single VPS, Caddy, Docker Compose (distroless) |
npm install
npm run dev # http://localhost:3000npm run dev opens an SSH tunnel to the VPS Postgres before starting (the
predev step is idempotent). Create a .env.local with at least:
DATABASE_URL=... # points at the tunnel in dev
ADMIN_SESSION_SECRET=... # >= 32 chars
ADMIN_PASSWORD_HASH=... # argon2id hash (npm run admin:set-password)
VPS_SSH_TARGET=root@... # dev-only, for the auto-tunnel
You can also run the whole stack — app plus Postgres — locally with
docker compose up --build. See CLAUDE.md for the full
environment-variable reference and the conventions this repo follows.
npm test # vitest
npm run typecheck # astro check
npm run check # prettier --check.github/workflows/ci.yml runs typecheck, unit tests, and a production build on
every pull request and push.
CODEMAP.md is the map of where things live;
CLAUDE.md documents the conventions, environment, and ops
(backups, migrations, deploys). At a glance:
src/ Astro pages, layouts, API routes, OG images, middleware
components/ React islands (admin widgets under admin/)
db/ Drizzle schema + generated migrations
lib/ Server helpers: db client, auth, content readers, cache, uploads, OG
terminal/ Go SSH twin (its own module and deploy)
Push to release for production, or develop for the preview stack.
GitHub Actions builds the distroless image on its own runners and pushes it to
GHCR; the VPS only pulls (docker compose up -d --no-build) and then health-checks
the app. main has been retired — release and develop are the only branches.
The Go terminal twin ships from its own workflow to a systemd unit. Uploads,
secrets, and the database volume live outside the compose pull, so deploys never
touch them.