A small personal blog. SvelteKit + SQLite, deployed in Docker behind a shared
Caddy gateway. Posts are written from iOS Shortcuts or a password-protected
/admin page; images live in Cloudflare R2; and every night the full post
archive (markdown files + a copy of posts.db) is bundled into a single
YYYY-MM-DD.zip on the VPS bind mount and mirrored to R2 as insurance against
posts.db going away.
PLAN.md is the phase-by-phase build log. SPEC.md is the design document.
This file is the architectural overview — what the running app actually is.
| Concern | Choice |
|---|---|
| Framework | SvelteKit 2 (Svelte 5 runes) |
| Adapter | @sveltejs/adapter-node |
| Data store | SQLite via better-sqlite3 (single file: posts.db) |
| Schema management | Versioned SQL files in migrations/, applied by src/lib/migrate.ts on every connection |
| Image pipeline | sharp — EXIF stripped, max 1600×1600, converted to WebP |
| Object storage | Cloudflare R2 via @aws-sdk/client-s3 (images + nightly archive mirror) |
| Markdown | marked (rendered server-side at request time) |
| Short IDs | sqids — reversible, hash-like tokens for /p/[token] short URLs |
| RSS | feed — full-text RSS 2.0 at /feeds/posts.xml |
| Validation | zod safeParse on every API route |
| Tests | vitest + @testing-library/svelte + happy-dom, 150+ tests across the codebase |
| Container | Docker + Docker Compose (no host ports — joins external web network) |
| TLS / routing | Caddy, running in a separate gateway container; this repo only ships a Caddyfile that the gateway mounts |
| CI/CD | GitHub Actions: build + test gate, then plain ssh runs scripts/deploy.sh on the VPS |
.
├── src/
│ ├── lib/
│ │ ├── db.ts # createDb() + all query functions (typed PostRow/Post/Tag/ImageRow)
│ │ ├── migrate.ts # Runs pending migrations/*.sql per PRAGMA user_version
│ │ ├── slug.ts # slugify, hashSlug, dateParts, permalink, shortlink
│ │ ├── shortid.ts # Sqids encode/decode for short post tokens
│ │ ├── shortid-freeze.ts # Freezes current tokens into shortlink_redirects (migration tool)
│ │ ├── markdown.ts # marked wrapper used by feed + post pages
│ │ ├── auth.ts # timing-safe Bearer token + session cookie check
│ │ ├── r2.ts # S3Client + uploadToR2()
│ │ ├── schemas.ts # Shared zod schemas (postInputSchema, postUpdateSchema, imageMetadataSchema)
│ │ └── components/ # FeedItem, Dateline, TagList + admin/ (PostForm, PostsTable, ImagesTable, ImageUploadModal, AdminNav, SignOut)
│ ├── routes/
│ │ ├── +layout.{server.ts,svelte} # Site chrome, session detection
│ │ ├── +page.{server.ts,svelte} # Feed (reverse-chronological)
│ │ ├── [year]/[month]/[day]/[slug]/ # Single-post permalink
│ │ ├── tag/[slug]/ # Tag feed
│ │ ├── admin/ # Login, posts table + new/edit pages, images table
│ │ ├── feeds/posts.xml/+server.ts # GET — RSS 2.0 (full-text)
│ │ └── api/
│ │ ├── post/+server.ts # POST — create
│ │ ├── post/[slug]/+server.ts # PATCH — edit
│ │ ├── upload/+server.ts # POST — image to R2 (also records in image ledger)
│ │ └── session/+server.ts # POST — admin login → httpOnly cookie
│ ├── hooks.server.ts # Bridges $env/dynamic/private into process.env for scripts
│ ├── app.html, app.css, app.d.ts
├── scripts/
│ ├── init-db.ts # One-shot: open posts.db so migrations run
│ ├── export-and-backup.ts # Nightly archive/YYYY-MM-DD.zip (posts.db + markdown), mirrored to R2
│ ├── freeze-shortlink-tokens.ts # One-shot: persist current short tokens before changing Sqids config
│ ├── seed.ts # Local-only fixture data
│ └── fixtures.ts
├── migrations/
│ ├── 001_init.sql # Baseline schema: posts, tags, post_tags, images, post_images
│ ├── 002_slug_redirects.sql # Path → post_id ledger for slug/date renames
│ └── 003_shortlink_redirects.sql # old_token → post_id ledger for Sqids config migrations
├── static/ # Static assets served at `/` (favicon, etc.)
├── Dockerfile # Two-stage; runtime stage includes migrations/, scripts/, build/, node_modules/
├── docker-compose.yml # One service, joins external `web` network — no host ports
├── Caddyfile # reverse_proxy compostmodernism:3000 — mounted into the gateway
├── .github/workflows/deploy.yml # Build/test gate → SSH deploy on push to main
├── DEPLOY.md # One-time bootstrap checklist for cornhill
├── PLAN.md # Phase-by-phase TDD plan
└── SPEC.md # Design spec
- Create —
POST /api/postwithAuthorization: Bearer $POST_SECRET. Zod validates the body.insertPost(insrc/lib/db.ts) derives a slug (from the title viaslugify, or an 8-char hash viahashSlugfor untitled posts), handles slug collisions by appending-2,-3, …, writes tag join rows, and callssetPostImagesto record any R2 URLs found inside the markdown body. - Read — the feed loader calls
getPosts(default limit 50, reverse-chronological, hydrated withtagsand adatealias). Each post is rendered byFeedItem.svelte, which switches on link/titled/plain. - Edit —
/admin/posts/[slug]issuesPATCH /api/post/[slug]. Omitted fields keep their old values; the body re-runssetPostImagesto rebuild image join rows. A slug can be changed: the old(year, month, day, slug)tuple is recorded inslug_redirectsand the single-post loader 301s old URLs to the post's new canonical path on next visit. - Archive — nightly cron runs
scripts/export-and-backup.ts: bundlesposts.dbplus aposts/YYYY/MM/DD/slug.mdtree (YAML frontmatter per post) into a singlearchive/YYYY-MM-DD.zipon the bind mount, thenPutObjects the same zip to R2 underbackups/YYYY-MM-DD.zip. The two surfaces are independent — either alone is a complete restore source.
Inspired by Daring Fireball and Kottke. All three are the same row shape; +page.svelte switches
on which fields are present:
- Link post —
url+title→ title links externally, marker→rendered. - Titled post —
titleonly →<h2>heading. - Plain post — body only, no heading.
The schema lives in migrations/NNN_*.sql. createDb() calls migrate() on every
connection: it reads PRAGMA user_version, sorts files by their numeric prefix, and
applies each newer file inside its own transaction (rolling back atomically on failure,
bumping user_version only when every statement in the file succeeds). Dev and prod
use the same path — restart the dev server and pending migrations apply.
Never edit a committed migration. Always add a new one. See PLAN.md §Schema Migrations for the convention.
The image ledger (images + post_images) is part of the baseline 001_init.sql.
Every successful /api/upload calls recordImage(key); every post create/update
calls setPostImages(postId, body) which scans the body for R2 URLs (respecting
R2_PUBLIC_URL) and rebuilds the join rows. The ledger lets future tooling answer
"which posts reference this image?" and "which images are orphaned?".
The slug-redirect ledger (slug_redirects, migration 002) records every
(old_year, old_month, old_day, old_slug) tuple a post used to live at. Rows point
to post_id (not a path string), so successive renames automatically resolve to
the post's current canonical URL via a single JOIN — no chain walking. The
single-post route loader (src/routes/[year]/[month]/[day]/[slug]/+page.server.ts)
consults the ledger only when the live slug lookup misses; a hit becomes a 301 to
the post's current permalink. ON DELETE CASCADE cleans up ledger rows when a
post is deleted, so stale redirects can't outlive their targets.
Two paths, both checking against process.env:
- API write endpoints —
Authorization: Bearer $POST_SECRET, used by iOS Shortcuts. - Admin UI —
POST /api/sessionwith$ADMIN_PASSWORDsets an httpOnly session cookie.admin/+page.server.tschecks the cookie and either renders the login form or the post list.
Both checks use crypto.timingSafeEqual (see src/lib/auth.ts). There is no user
table, no sessions table, no auth library — the cookie value is the secret itself.
posts.db— single SQLite file, baked into the container's working directory. Volume-mounted indocker-compose.ymlso it survives redeploys.- Cloudflare R2 — two prefixes in one bucket:
images/(public, served atR2_PUBLIC_URL) andbackups/(private, 90-day lifecycle rule — receives the nightlyYYYY-MM-DD.zip). archive/— gitignored bind-mount directory on the VPS holding oneYYYY-MM-DD.zipper nightly run. Each zip contains a copy ofposts.dbalongside aposts/YYYY/MM/DD/slug.mdtree, so any single archive is a complete restore source. The same file is mirrored to R2.
cp .env.example .env # fill in POST_SECRET, ADMIN_PASSWORD, R2_* values
npm install
npm run dev # http://localhost:5173 — migrations run on first connection
npm test # vitest, ~150 tests, runs against in-memory SQLite
npm run check # svelte-check
npm run seed # populate posts.db with fixtures (idempotent)A posts.db file appears in the repo root on first run; it's gitignored.
CI/CD: pushing to main runs .github/workflows/deploy.yml, which gates on
npm run check && npm test && npm run build and then opens an SSH session to the
VPS that pipes scripts/deploy.sh over stdin. The script does git pull and
docker-compose up -d --build — migrations apply on the next createDb() call
during container boot. The VPS uses Docker Compose v1 (docker-compose,
hyphenated); see NOTES.md for the install-time gotcha.
Caddy is not part of this project's compose stack. It runs in a separate
~/gateway/ stack on the VPS; this repo's Caddyfile is bind-mounted into that
container as a per-site config.
First-time bootstrap (SSH keys, GitHub Secrets, gateway mount, container init) is
documented step-by-step in DEPLOY.md.
All write endpoints require Authorization: Bearer $POST_SECRET unless noted. All
responses are JSON. All bodies are validated with zod.safeParse; invalid input
returns 400 before any DB or R2 call.
Authenticates the admin. Sets an httpOnly session cookie on success.
| Status | Body | Condition |
|---|---|---|
| 400 | { error } |
Body missing or malformed |
| 401 | { error: "Unauthorized" } |
Wrong password |
| 200 | { ok: true } |
Authenticated |
Request body: { password: string }
| Status | Body | Condition |
|---|---|---|
| 200 | { ok: true } |
Session cookie cleared (sign out) |
Creates a new post.
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing or wrong Bearer token |
| 400 | { error } |
Invalid body, or url provided without title |
| 201 | { ok: true, slug: string, permalink: string } |
Post created |
Request body: { body: string, title?: string, url?: string, tags?: string[] }
Updates an existing post. All fields are optional; omitted fields keep their
existing values. Passing slug or created_at moves the post: the old path
tuple is recorded in slug_redirects and future GETs on the old URL 301 to
the new canonical permalink.
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing or wrong Bearer token |
| 400 | { error } |
Invalid body, or url provided without title |
| 404 | { error: "Not found" } |
No post with that slug |
| 409 | { error } |
Requested slug is already in use by another post |
| 200 | { ok: true, slug: string } |
Post updated; slug is the current (post-rename) slug |
Request body: { body?: string, title?: string | null, url?: string | null, tags?: string[], slug?: string, created_at?: number }
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing or wrong Bearer token |
| 404 | { error: "Not found" } |
No post with that slug |
| 200 | { ok: true } |
Post deleted |
Processes and uploads an image to R2. Resizes to ≤ 1600×1600 and converts to WebP.
Records the resulting key in the images ledger.
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing or wrong Bearer token |
| 400 | { error } |
image field missing or not a file |
| 500 | { error: "Image processing failed" } |
sharp pipeline threw |
| 500 | { error: "Upload failed" } |
R2 upload threw |
| 201 | { ok: true, url: string } |
Uploaded; url is the public R2 URL |
Request body: multipart/form-data with an image file field.
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing auth |
| 200 | { images: [...], page, perPage, total, totalPages } |
Paginated list of ledger rows; each row includes url and usage_count |
Query params: ?page=N (default 1).
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing auth |
| 400 | { error } |
Invalid id, or invalid metadata payload |
| 404 | { error: "Not found" } |
Image id does not exist |
| 200 | { ok: true, image: { ... } } |
Metadata updated; row returned |
Request body: JSON object with optional title, alt, caption, credit (each string or null).
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing auth |
| 404 | { error: "Not found" } |
Image id does not exist |
| 409 | { error, posts: [{ slug, title }] } |
Image is referenced and ?force=true was not passed — re-issue with ?force=true to override |
| 500 | { error } |
R2 delete failed (DB row preserved) |
| 200 | { ok: true } |
R2 object deleted then DB row removed |
| Status | Body | Condition |
|---|---|---|
| 401 | { error: "Unauthorized" } |
Missing auth |
| 400 | { error } |
Invalid id, or image field missing |
| 404 | { error: "Not found" } |
Image id does not exist |
| 500 | { error } |
Sharp pipeline or R2 upload threw |
| 200 | { ok: true, url: string } |
Bytes uploaded to the same R2 key; uploaded_at bumped. url is unchanged from the prior version, so posts referencing the image stay intact. |
Request body: multipart/form-data with an image file field.
Resolves a short post token (Sqids-encoded id) to the post's current canonical
permalink. Decodes the token, looks up the post by id, and 301-redirects. On a
decode miss or unknown id, falls back to shortlink_redirects (the
migration-time backstop for old tokens). Public — no auth.
| Status | Body | Condition |
|---|---|---|
| 301 | (Location header) | Resolved to canonical /YYYY/MM/DD/slug |
| 404 | (text) | Token does not decode and is not in the redirect ledger, or decoded id has no post |
Designed to be reached via the short-URL alias cmpst.org/p/[token] →
Cloudflare rewrites the host to compostmodernism.org/p/[token]. See DEPLOY.md.
Full-text RSS 2.0 feed of every post returned by getPosts() (default 50,
reverse-chronological). For link posts, <link> is the external URL and
<guid> is the canonical permalink — so a reader's identity for the entry
survives renames, while clicking through still goes to the source.
| Status | Body | Condition |
|---|---|---|
| 200 | application/rss+xml body |
Always |
The site origin used to build absolute URLs is $SITE_URL (defaulting to
https://compostmodernism.org). Discoverable from any page via
<link rel="alternate" type="application/rss+xml"> in app.html.
After signing in at /admin/login, the admin area exposes:
/admin/posts— paginated table of every post with View / Edit / Delete actions./admin/posts/new— composer for a new post (uses the sharedPostForm)./admin/posts/[slug]— standalone editor for an existing post./admin/images— image ledger as a table with thumbnail, usage count, and per-row Copy URL / Edit metadata / Replace / Delete actions.
Both posting channels share the same backend: the admin UI uses fetch against the same API endpoints that iOS Shortcuts hit.
When signed in, the public site shows two extra affordances (gated on the
root layout's data.admin flag, so unauthenticated readers see nothing
different):
- The site header gains an "Admin" link and a "Sign out" button in place of the byline.
- Each single-post page shows an "Edit" link in its right-hand rail that
jumps to
/admin/posts/[slug].
Inline edit affordances on the feed and inline composer were tried and
rolled back; see NOTES.md for the post-mortem.