Replacing a Public Linkding Instance with a Static Site
TL;DR: I stopped exposing my homelab's dynamic Linkding instance to the public internet via Tailscale. Instead, I built linkding-ssg, a self-contained Go CLI that snapshots my 35,000 bookmarks into a fast static site with client-side Pagefind search—embedded binaries, Ivy Bridge quirks, and all.
Hosting overkill
I’ve been running Linkding in my homelab for a while now to collect bookmarks. It’s a great app—clean UI, snappy, and it plays nicely with the rest of my feed reading Rube Goldberg machine. And, until tonight, I had my cloud VPS reverse-proxying links.lmorchard.com directly back into my home network over Tailscale so anyone on the web could browse my links.
It was neat & clever to set up, but I realized how silly it was.
I’m the only person who adds, tags, or edits bookmarks in this collection. Why was I exposing a dynamic Django application to the public internet, tunneling inbound web traffic straight into my home server, and fretting over bots abusing my search form—when it's all essentially read-only anyway?
If my home internet hiccups or my Proxmox server reboots, my public links shouldn't vanish. And flat files served by Caddy with zstd/gzip compression will run circles around a dynamic backend.
Plus, having a static snapshot means my archive—stretching all the way back to del.icio.us imports from 2003—lives locally in a clean, portable format, independent of whether Linkding itself survives the next decade.
This isn't the first time I've gone down this road, either. I built fossilizer with a similar mindset: collecting all my Mastodon posts across multiple servers and years, turning them into a searchable static archive over at old-toots.lmorchard.com.
There's something deeply satisfying about "fossilizing" your personal data out of ephemeral web apps into permanent, resilient static files.
So, I built linkding-ssg: a standalone Go CLI that pulls bookmarks from Linkding's REST API, snapshots them to JSON, and builds a fully searchable static archive.
The scale (isn't really a) problem
I've got over 35,000 bookmarks. A lot of static site generators fall over when you throw that much at them.
The usual /page/N/ pagination is especially toxic for large static archives: adding five new bookmarks at the top shifts the boundaries of every single page on the entire site. That means thousands of file modifications on every build, cache invalidations across the board, and massive rsync deltas.
To keep the site fast and the files mostly immutable, I settled on a temporal + sharded structure:
dist/
├── index.html # Recent bookmarks (latest 50) + RSS link
├── feed.xml # RSS 2.0 feed of recent bookmarks
├── archive/
│ ├── index.html # 25-year timeline grid linking to months
│ ├── 2026/10/index.html # Monthly archive (immutable once past)
│ └── 2003/09/index.html
├── tags/
│ ├── index.html # All 9,100+ tags sorted by frequency
│ └── webdev/
│ ├── index.html # Tag listing
│ └── feed.xml # Scoped RSS feed for [#webdev](/tag/webdev)
├── b/
│ └── 038/38825/index.html # Sharded canonical permalinks (/b/{shard}/{id}/)
├── about/index.html # Rendered from custom markdown in data/pages/
└── pagefind/ # Client-side full-text search index & UI
Cool URLs don't change
Once a month is in the past (like September 2003 or May 2018), that page is frozen. Incremental updates only touch index.html, feed.xml, the current month, and newly added bookmarks.
Directory sharding
Dumping 35,000 directories in a single folder can cause filesystem headaches. Sharding bookmark permalinks by ID bucket (/b/038/38825/) caps any single folder to at most 1,000 subdirectories.
Static site search with Pagefind
A static bookmark collection without search is almost useless. I first discovered Pagefind while building client-side search for this blog (which is also a static site). It builds compact, chunked search indices and executes queries inside a Web Worker in the browser without needing a dynamic search backend.
However, I wanted linkding-ssg to be a completely standalone Go binary running on a minimal Linux VM—without dragging along Node.js, npm, or npx.
Pagefind's indexer is written in Rust and publishes standalone musl binaries for Linux and macOS. To make the CLI 100% self-contained:
I embedded Pagefind's compressed binaries directly into the Go binary using Go build tags (
//go:build linux && amd64,//go:build darwin && arm64).Go's compiler only embeds the binary matching the target platform, adding just ~4.5 MB to the executable.
When running
linkding-ssg build --index, the CLI extracts Pagefind todata/bin/pagefindon first run and executes it directly. Zero external runtime dependencies.
A fun hardware discovery
When I first deployed the standalone binary to my Proxmox server, Pagefind immediately crashed with: signal: illegal instruction (core dumped)
The server runs an Intel Core i7-3770 (Ivy Bridge, released in 2012). Checking /proc/cpuinfo revealed the processor supports avx and sse4_2, but lacks avx2 and bmi1/bmi2 (which Intel didn't introduce until Haswell in 2013). Pagefind's v1.6.0 release was compiled targeting x86-64-v3 (AVX2), tripping illegal instruction traps on Ivy Bridge.
Dropping back to Pagefind v1.5.2—which was compiled for the standard x86-64-v2 baseline—fixed the crash immediately while retaining the full Web Component search UI.
Speedy site generation
The scale here is just a little bit gratuitous. But, it's just files, and computers can go really fast with files these days.
Generating 44,000 HTML pages, 9,100 tag pages, and 9,100 RSS feeds sequentially took around 57 seconds on a single core.
Because each bookmark card and tag page is completely independent, and Go's html/template parse tree is read-only and thread-safe during execution, this was trivially parallelizable.
I added a bounded worker pool defaulting to runtime.NumCPU() (or --concurrency):
- Tag pages & tag RSS feeds: 38s → 10s
- 35,000 bookmark permalinks: 14s → 5s
- Total build time: ~20 seconds to generate 53,000 files.
Deployment
The sync pipeline between my home server and the cloud VPS is dead simple:
(localhost:19090)"] -->|"1. linkding-ssg sync"| B["Local Snapshot & Build
data/bookmarks.json + dist/"] B -->|"2. rsync via SSH / rrsync"| C["Cloud VPS (aerostat02)
/srv/www/links.lmorchard.com"] C -->|"3. Caddy HTTPS static serve"| D["Public Visitors"]
Incremental Sync:
linkding-ssg syncqueries the Linkding REST API (/api/bookmarks/). Because Linkding serves bookmarks newest-first, the CLI stops fetching as soon as it sees an ID already present in the localdata/bookmarks.json. It merges edits, builds the HTML, and runs Pagefind.Restricted
rrsyncDeploy: On my cloud VM (aerostat02), Caddy serves/srv/www/links.lmorchard.com. The deployment uses an SSH key confined via OpenSSHcommand="/usr/bin/rrsync /srv/www/links.lmorchard.com",restrict. The key cannot spawn a shell or forward ports into my network; it can only write files to that one folder.Automated via Makefile: A simple Makefile on the home server ties it together:
publish: ./linkding-ssg sync rsync -rltvz --delete --omit-dir-times \ -e "ssh -i ~/.ssh/id_ed25519" \ dist/ \ webdeploy@aerostat02.lmorchard.com:
A cron job runs make publish hourly.
Headless homelab, static web
This setup scratches a lot of personal itches:
Self-hosting doesn't mean everything needs to be a public web app: Treating dynamic applications as headless CMSs inside the LAN while publishing static output publicly gives you the best of both worlds. The editing UX stays interactive, but the public surface is bulletproof, fast, and un-hackable.
Embedded single binaries are great: Packaging default templates, styles, and external CLI tools inside one Go binary makes deployments onto minimal Linux containers painless.
Old hardware is still plenty capable: A 12-year-old Ivy Bridge box in a closet can index 35,000 documents and push a static site across the internet in under two minutes without breaking a sweat.
You can check out the source on GitHub at lmorchard/linkding-ssg, and browse the live result at links.lmorchard.com.