A small, custom blog served from a Raspberry Pi at blog.tylerkno.ws. Posts
are markdown files in an Obsidian vault that already syncs to S3. The Pi
keeps a local mirror of the vault (cron aws s3 sync) and a Go service
renders posts on demand.
Companion to notnottyler.com — same earth-tone palette and fonts, designed to be linked from there when the time comes.
Obsidian (Mac) ──► S3 bucket ──► Raspberry Pi (Caddy → Go service)
├─ aws s3 sync (cron, every 5m)
├─ fsnotify wakes the renderer
└─ in-memory cache of rendered HTML
- Vault path the renderer looks at:
$BLOG_VAULT_DIR/blog/. Every subfolder with anindex.mdwhose frontmatter haspublished: trueis a post. - Caddy terminates TLS and reverse-proxies
blog.tylerkno.wstolocalhost:8106. The installer does not touch the Caddyfile — see Caddy setup below.
A post is a folder under vault/blog/ containing one index.md plus any
assets it references. Folder name is up to you (only slug controls the
URL).
vault/blog/
└── my-post/
├── index.md
├── image.png
├── template.html # optional — full-page override
└── style.css # only used if template.html references it
Frontmatter:
---
title: My post
slug: my-post
date: 2026-05-12
summary: One sentence shown on the index and in the RSS feed.
published: true
---published: trueis required. Missing orfalse→ invisible.slugbecomes the URL (/posts/<slug>). Falls back to the folder name if omitted.dateacceptsYYYY-MM-DDor full RFC3339. Bare dates are interpreted as noon in the binary'sBLOG_TZ(defaultAmerica/Denver) so that RSS readers don't roll the calendar day back to the previous day. Use RFC3339 (2026-05-12T08:00:00-06:00) if you want a specific time.- Images and links use ordinary markdown —
and[text](other.html). Relative paths are rewritten to/posts/<slug>/...at render time so they Just Work in GitHub previews too.
This uses the Templater community plugin (not the core Templates plugin — that can't create folders). One-time setup, then "new post" is a single command.
Open Settings → Templater and set:
- Template folder location:
Templates(or wherever you keep templates — must match where the file in step 2 lives). - Trigger Templater on new file creation: off (we'll invoke it explicitly so the prompt fires).
- Under Folder Templates, leave empty.
Create Templates/Blog Post.md in your vault with exactly this
content. Everything is wrapped in one <%* ... %> block; output is
built up via Templater's tR accumulator so there are no cross-block
variable references that some Templater versions render as raw text.
<%*
const slug = (await tp.system.prompt("Post slug (used as folder name + URL)")) || "";
const safe = slug.toLowerCase().trim().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!safe) { new Notice("Blog Post: aborted — empty slug"); return; }
await tp.file.move(`blog/${safe}/index`);
const today = tp.date.now("YYYY-MM-DD");
tR += `---
title:
slug: ${safe}
date: ${today}
summary:
published: false
---
`;
%>The renderer uses ordinary  (not Obsidian wikilinks),
so configure paste accordingly:
- Settings → Files & Links → Use [[Wikilinks]]: off
- Settings → Files & Links → New link format: Relative path to file
- Settings → Files & Links → Default location for new attachments: In subfolder under current folder (or Same folder as current file) — keeps pasted images co-located with the post.
- Anywhere in the vault: open the command palette (
Cmd+P) → run Templater: Open insert template modal → pick Blog Post. - Prompt asks for slug → type e.g.
my-post. - Templater creates
blog/my-post/index.md, drops you into it with the slug + today's date pre-filled. - Fill in
title,summary, write your post. Paste images — they land alongsideindex.mdasimage.pngetc. - When ready: flip
published: false→published: true. Save. Your existing Obsidian→S3 sync ships it; the Pi picks it up on the next 5-minute tick.
Slug collisions: if blog/<slug>/ already exists, tp.file.move
will surface an Obsidian error. Pick a different slug and retry — the
template doesn't auto-disambiguate by design (silently appending a
number would be a surprise later).
go test -race ./... # unit tests
go run ./cmd/blog -vault ./testdata/vault -addr :8106 # smoke serverThen open http://localhost:8106. The committed testdata/vault/ has
fixture posts that exercise the default template, a template.html override,
draft gating, and the folder-name slug fallback.
curl -fsSL https://raw.githubusercontent.com/tlugger/journal/main/install.sh | sudo bashThe installer:
- Drops a bare-minimum placeholder
/home/pi/blog/.envon first install — justBLOG_VAULT_DIR=/home/pi/blog/vault, which is enough to boot the service. Add any optional vars (BLOG_ADDR,BLOG_SITE_URL,BLOG_FEED_AUTHOR) yourself. - Detects architecture, fetches the latest release binary or builds from
source if no release is published yet. Templates and CSS are
//go:embed-ed into the binary — no separate asset directory. - Writes
blog.service(systemd), enables and starts it.
Populating BLOG_VAULT_DIR is out of scope — wire up an aws s3 sync
in your own crontab (or rsync, or whatever).
The installer deliberately does not touch your Caddyfile. Add:
blog.tylerkno.ws {
reverse_proxy localhost:8106
}
Then sudo systemctl reload caddy. Caddy provisions the TLS cert from
Let's Encrypt automatically given that DDNS already resolves the subdomain
to your Pi.
cmd/blog/main.go # entrypoint: flags, fsnotify, http.Server
internal/post/ # frontmatter, vault walk, goldmark rendering
internal/server/ # routes, cache, handlers
internal/feed/ # hand-rolled RSS 2.0
internal/assets/ # //go:embed templates + static into binary
internal/assets/templates/{base,index}.html
internal/assets/static/ # base.css + favicon bundle (palette matches notnottyler.com)
testdata/vault/ # hermetic fixture used by every test
install.sh # curl|bash installer for the Pi
For live iteration on templates/CSS without rebuilds, pass -templates
and -static flags pointing at the on-disk source (or set
BLOG_TEMPLATE_DIR / BLOG_STATIC_DIR env vars); otherwise the binary
serves the embedded copies.
Tests are in _test.go files next to each source file (stdlib testing
only, table-driven, hermetic). go test -race ./... is the contract.