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.

Vintage wooden library card catalog drawers with brass pulls and label plates
Photo by Jan Antonin Kolar on Unsplash

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:

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):

Deployment

The sync pipeline between my home server and the cloud VPS is dead simple:

graph TD A["Linkding Container
(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"]
The linkding-ssg build and deployment pipeline
  1. Incremental Sync: linkding-ssg sync queries 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 local data/bookmarks.json. It merges edits, builds the HTML, and runs Pagefind.

  2. Restricted rrsync Deploy: On my cloud VM (aerostat02), Caddy serves /srv/www/links.lmorchard.com. The deployment uses an SSH key confined via OpenSSH command="/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.

  3. 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:

You can check out the source on GitHub at lmorchard/linkding-ssg, and browse the live result at links.lmorchard.com.

Comments loading...
The Only Conscious Caretaker  Previous