<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Fediverse on fullast.nu</title><link>https://blog.fullast.nu/tags/fediverse/</link><description>Recent content in Fediverse on fullast.nu</description><generator>Hugo</generator><language>en</language><copyright>Text and images CC BY-SA 4.0, code snippets MIT - credit blog.fullast.nu</copyright><atom:link href="https://blog.fullast.nu/tags/fediverse/index.xml" rel="self" type="application/rss+xml"/><item><title>A static blog with the fediverse as its comment system</title><link>https://blog.fullast.nu/posts/2026-10-29-static-blog-fediverse-comments/</link><pubDate>Tue, 03 Nov 2026 14:30:00 +0100</pubDate><guid>https://blog.fullast.nu/posts/2026-10-29-static-blog-fediverse-comments/</guid><description>How this site works: a Hugo build that publishes on git push, a toot that doubles as the comment thread, replies from Mastodon and Bluesky rendered as plain text, a redaction check that refuses to deploy my own mistakes, and three bugs that got past every check but one: two found by a real browser before the first post, one found by the empty space where a link card should have been.</description><category>blog</category><category>hugo</category><category>mastodon</category><category>bluesky</category><category>fediverse</category><category>self-hosting</category><category>security</category><category domain="lemmy">selfhosted@lemmy.world</category><content:encoded>&lt;p&gt;I wanted a blog with comments and without a comment system. Comment forms attract spam the moment a page is&#10;indexed; a comment database is one more thing to back up and patch; third-party widgets bring tracking. Meanwhile I&#10;already run a single-user Mastodon instance where the conversation would happen anyway.&lt;/p&gt;&#10;&lt;p&gt;So the design: &lt;strong&gt;publish on my own site, announce on the fediverse, and let the announcement be the comment&#10;thread.&lt;/strong&gt; The post page fetches the replies to its own toot (and its Bluesky post) and shows them underneath. Readers&#10;reply where they already have an account; I moderate with the tools those networks already have. This post is how&#10;it is built and what went wrong on the way.&lt;/p&gt;&#10;&lt;h2 id="the-site"&gt;The site&lt;/h2&gt;&#10;&lt;p&gt;Hugo with the PaperMod theme, markdown posts in a git repository on a box at home. The output is plain files served&#10;by nginx in a small, isolated container that only the reverse proxy can reach. Nothing dynamic. &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt;&#10;only; everything else is a 405.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Publishing is a git push.&lt;/strong&gt; A cron job on a build box fetches the repository every five minutes and, when the&#10;commit changed, builds twice: the public site, and a preview that includes drafts and scheduled posts behind basic&#10;auth. The public build goes through a &lt;strong&gt;redaction lint&lt;/strong&gt; (below) and is then copied into the web container&#10;atomically: unpack next to the live directory, swap, delete the old one.&lt;/p&gt;&#10;&lt;p&gt;Scheduling is free: a post with a future date and &lt;code&gt;draft: false&lt;/code&gt; is skipped by Hugo until the time has passed. The&#10;build job keys its &amp;ldquo;anything changed?&amp;rdquo; check on the commit &lt;em&gt;plus the list of still-future posts&lt;/em&gt;, so the first run&#10;after the time rebuilds, publishes and announces.&lt;/p&gt;&#10;&lt;p&gt;One more gate, added after a near miss: a scheduled post only goes out if its &lt;strong&gt;reviewed content has been approved&lt;/strong&gt;,&#10;recorded as a hash of the file. Edit the post after approving it and the build holds it, with a line in the log, until&#10;someone approves the new version. The first post missed its own slot exactly like that: three small edits after the&#10;morning approval, and 15:00 came and went. Annoying for ten minutes, and exactly what the gate is for.&lt;/p&gt;&#10;&lt;h2 id="the-announcement"&gt;The announcement&lt;/h2&gt;&#10;&lt;p&gt;After each deploy, a script reads the site&amp;rsquo;s RSS feed. For every post it has not announced yet:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Mastodon:&lt;/strong&gt; one public toot from my account: title, summary, link, up to eight hashtags from the post&amp;rsquo;s tags.&#10;The toot&amp;rsquo;s id is saved against the post&amp;rsquo;s path. An &lt;code&gt;Idempotency-Key&lt;/code&gt; makes a retry safe.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Bluesky:&lt;/strong&gt; a post from my account there, with a link card, the social-card image uploaded as the&#10;thumbnail, and tag facets.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Lemmy:&lt;/strong&gt; if the post&amp;rsquo;s front matter names a community, the toot mentions it, and Lemmy turns that into a post&#10;there. Replies federate back as replies to the toot. One catch: the mention only resolves if my instance has&#10;&lt;em&gt;seen&lt;/em&gt; that community before. The first time it had not, the mention stayed plain text and no Lemmy post&#10;appeared, until I looked the community up once in the web UI and edited the toot; Lemmy then created the post&#10;from the edit within seconds. The publisher now asks the instance to look the community up itself before it&#10;posts, which needs a token that is allowed to search. Since then it just works.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Search engines:&lt;/strong&gt; an IndexNow ping with the new URL.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;dev.to:&lt;/strong&gt; the full article via its API with a canonical link back here. Three things its API does not tell you:&#10;it refuses the default Python user agent outright (&amp;ldquo;Forbidden Bots&amp;rdquo;), it renders every single newline as a line&#10;break, so hard-wrapped markdown arrives as a poem, and it drops SVG images. The publisher now sends a descriptive&#10;user agent, unwraps paragraphs, swaps an SVG for its PNG twin when one exists, and picks the four tags from the&#10;post that dev.to readers actually follow.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The saved ids go into a &lt;code&gt;syndication.json&lt;/code&gt; on the site. That file is the only state the comments need.&lt;/p&gt;&#10;&lt;h2 id="the-comments"&gt;The comments&lt;/h2&gt;&#10;&lt;p&gt;A small script on the post page reads &lt;code&gt;syndication.json&lt;/code&gt;, finds the toot id for the current path, and calls the&#10;Mastodon instance&amp;rsquo;s &lt;strong&gt;public&lt;/strong&gt; context endpoint for that status. No token: public statuses and their replies are&#10;public. Bluesky&amp;rsquo;s public API gives the same for the post thread.&lt;/p&gt;&#10;&lt;p&gt;The rendering rules are the security story:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Everything is inserted as text.&lt;/strong&gt; Reply HTML from Mastodon is converted to plain text (paragraph breaks kept);&#10;Bluesky gives text already. Nothing from either API is ever parsed as HTML on my page.&lt;/li&gt;&#10;&lt;li&gt;Links in replies become plain anchors with &lt;code&gt;rel=&amp;quot;nofollow ugc noopener noreferrer&amp;quot;&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Avatars load only from the two known hosts, as small thumbnails; a missing one is dropped instead of showing a&#10;broken image.&lt;/li&gt;&#10;&lt;li&gt;Only public and unlisted replies; at most 200.&lt;/li&gt;&#10;&lt;li&gt;The page sends nothing anywhere. Reading is the only direction.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Moderation is zero extra work: block or mute an account on Mastodon and its replies disappear from the API response,&#10;and therefore from the page.&lt;/p&gt;&#10;&lt;h2 id="the-headers"&gt;The headers&lt;/h2&gt;&#10;&lt;p&gt;Because nothing on the page is dynamic, the Content Security Policy can be strict: &lt;code&gt;default-src 'none'&lt;/code&gt;, scripts and&#10;styles from the site only (the theme needs inline for its dark-mode toggle), &lt;code&gt;connect-src&lt;/code&gt; limited to the Mastodon&#10;instance and Bluesky&amp;rsquo;s public API, &lt;code&gt;img-src&lt;/code&gt; the same two hosts plus self, no framing, no forms, no base tag. Add&#10;&lt;code&gt;nosniff&lt;/code&gt;, a referrer policy, cross-origin isolation headers, rate limits per client, and an access log with the last&#10;part of every address removed. No cookies, so no banner.&lt;/p&gt;&#10;&lt;h2 id="the-redaction-lint"&gt;The redaction lint&lt;/h2&gt;&#10;&lt;p&gt;The biggest risk on a blog about your own network is you. Project notes are full of internal addresses, hostnames,&#10;identifiers and the odd token. So the public build is grepped before every deploy for private IP ranges, the public&#10;IP, MAC addresses, the street and the town, phone numbers, e-mail aliases, anything that looks like a bearer token or&#10;API key, serial numbers, internal hostnames, and names of things I have decided never to mention. One hit and the&#10;deploy stops with a notification. An allow-list handles deliberate mentions. It has already caught a home-page title.&lt;/p&gt;&#10;&lt;h2 id="two-bugs-a-real-browser-found"&gt;Two bugs a real browser found&lt;/h2&gt;&#10;&lt;p&gt;Before the first post I ran the preview through headless Chrome on a VM, with the comments pointed at a test thread.&#10;Unit-level checks had all passed. The browser found two things that would have shipped:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;The preview never showed comments.&lt;/strong&gt; The page&amp;rsquo;s lookup key kept the &lt;code&gt;/preview&lt;/code&gt; prefix, so it never matched the&#10;keys in &lt;code&gt;syndication.json&lt;/code&gt;. Trivial, invisible without a browser.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Nobody at home would ever see comments.&lt;/strong&gt; My other public hostnames resolve to the reverse proxy&amp;rsquo;s LAN address&#10;when you are on the LAN (split DNS); the new blog name did not yet. To Chrome the blog was therefore a &lt;em&gt;public&lt;/em&gt;&#10;page trying to read a &lt;em&gt;private&lt;/em&gt; address, and its private-network-access rule blocked the request. Visitors on the&#10;internet were fine; the author was not. One DNS override fixed it, and &amp;ldquo;every public name gets the override&amp;rdquo; is&#10;now a rule.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;That check is now part of publishing: load the draft in Chrome, collect console errors, failed requests, broken&#10;images and links, verify the comments widget, take phone and desktop screenshots, and put it all on a review page&#10;behind the preview login.&lt;/p&gt;&#10;&lt;h2 id="the-bug-a-browser-could-not-find"&gt;The bug a browser could not find&lt;/h2&gt;&#10;&lt;p&gt;The announcement toot for the first post went out fine: text, hashtags, link. No link card. Bluesky&amp;rsquo;s card was there,&#10;every other crawler had fetched the page, and the Mastodon instance (mine) had not even tried. Nothing in its logs,&#10;no failed job, no retry.&lt;/p&gt;&#10;&lt;p&gt;The cause was the fix from bug 2. Split DNS makes the blog&amp;rsquo;s name resolve to the reverse proxy&amp;rsquo;s &lt;em&gt;LAN&lt;/em&gt; address inside&#10;the house, and the Mastodon server lives inside the house. Mastodon&amp;rsquo;s HTTP client refuses to fetch private addresses&#10;(a sensible guard against server-side request forgery), and the link-card service swallows that refusal silently. So&#10;every link to one of my own sites, posted from my own instance, would never get a card. The clean fix was not to&#10;loosen the guard but to give the Mastodon containers public DNS: my names then resolve to the public address and the&#10;fetch loops back in through the firewall, like any outside visitor. Running &lt;code&gt;FetchLinkCardService&lt;/code&gt; by hand on the&#10;toot produced the card a second later.&lt;/p&gt;&#10;&lt;p&gt;Lesson 2 therefore comes with a footnote: split DNS is right for the clients, and wrong for any service on the LAN that&#10;is supposed to behave like a stranger.&lt;/p&gt;&#10;&lt;h2 id="after-the-first-post"&gt;After the first post&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Replies&lt;/strong&gt; are fetched every ten minutes and each new one arrives as a push notification with its text and a link&#10;to answer it. Answering within the hour instead of the next morning makes the fediverse feel like a comment system.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Where the readers came from:&lt;/strong&gt; the Lemmy community mention did more than everything else combined. Within an hour&#10;of the post, a few hundred fediverse servers had fetched the page for their link previews, almost all of them&#10;subscribers of that community. Page views were a fraction of that, in the low hundreds on the first day and two&#10;thirds of them within two hours of the announcement; previews are reach, not readers.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The comments&lt;/strong&gt; came in the way I had hoped. The first one was from an account that describes itself as an AI&#10;agent; it asked a precise question about the matching rule and followed up with a sharper one. It is a public&#10;thread, so I answered it like any other reader. A few days later a human reader on another Mastodon server asked&#10;what the weekly spot-check actually catches, and in passing described their own convention for records they are not&#10;sure about yet, which I am borrowing. Two networks, two conversations, one page showing both.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identity:&lt;/strong&gt; every page carries a &lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt; link to my Mastodon profile, which gives the profile its verified&#10;tick, and each post names my account as its author (&lt;code&gt;fediverse:creator&lt;/code&gt;), so shared links show &amp;ldquo;More from&amp;rdquo; on&#10;servers that support it.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="what-i-would-tell-someone-starting-the-same-thing"&gt;What I would tell someone starting the same thing&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Static plus &amp;ldquo;comments live elsewhere&amp;rdquo; is a very comfortable place to be. The attack surface is a web server&#10;serving files.&lt;/li&gt;&#10;&lt;li&gt;The toot id is the only state. Save it before anything else happens; never re-announce.&lt;/li&gt;&#10;&lt;li&gt;Render third-party content as text. Always.&lt;/li&gt;&#10;&lt;li&gt;Put your own address names behind split DNS consistently, or Chrome will teach you about private network access.&#10;Then give the services that must look like outsiders (your fediverse server) public DNS, or they will silently&#10;refuse to fetch your own pages.&lt;/li&gt;&#10;&lt;li&gt;Lint the output for your own secrets. You will leak something otherwise.&lt;/li&gt;&#10;&lt;li&gt;Test in a real browser from the network your readers are not on: your own.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;em&gt;Tools: Hugo, PaperMod, nginx, Nginx Proxy Manager, Mastodon, Lemmy, Bluesky&amp;rsquo;s AT Protocol API, dev.to&amp;rsquo;s API,&#10;IndexNow, Playwright with Chrome, Python 3, cron. The scripts are specific to this setup and not published; ask in the comments if you want more&#10;detail on a step.&lt;/em&gt;&lt;/p&gt;&#10;</content:encoded></item></channel></rss>