<?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>Lemmy on fullast.nu</title><link>https://blog.fullast.nu/tags/lemmy/</link><description>Recent content in Lemmy 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/lemmy/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 publishes and gets its comments from Mastodon, Bluesky and Lemmy, and the three bugs I found on the way, two with a browser and one by staring at a missing link card.</description><category>blog</category><category>hugo</category><category>mastodon</category><category>bluesky</category><category>fediverse</category><category>lemmy</category><category>self-hosting</category><category>security</category><category domain="lemmy">selfhosted@lemmy.world</category><content:encoded>&lt;p&gt;I wanted comments without running a comment system. Forms get spammed the moment a search engine finds them, a&#10;comment database is one more thing to patch, and the hosted widgets all phone home. And I already had a Mastodon&#10;server sitting there, where the conversation would end up anyway.&lt;/p&gt;&#10;&lt;p&gt;So the toot is the comment thread. I publish here, announce it on the fediverse, and the post page fetches the&#10;replies to its own toot (and its Bluesky post) and shows them underneath. Readers answer where they already have an&#10;account, and I moderate with the tools those networks already have. It took one evening to build and about three&#10;to debug, and the debugging is most of this post.&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="Flow: git push, build box with Hugo, redaction check and approval gate, web container, publisher reading the RSS feed, announcements on Mastodon, Bluesky, Lemmy and dev.to, ids saved in syndication.json, and the post page fetching replies from the public APIs" loading="lazy" src="https://blog.fullast.nu/images/blog-fediverse-flow.svg"&gt;&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 in a git repository on a box at home. The output is plain files, served by&#10;nginx in a small isolated container that only the reverse proxy can reach. &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt;; everything else gets a&#10;405.&lt;/p&gt;&#10;&lt;p&gt;Publishing is a git push. A cron job on the build box fetches the repository every five minutes and, when the commit&#10;has changed, builds twice: the public site, and a preview behind basic auth that includes drafts and scheduled posts.&#10;The public build goes through the redaction check described further down and is then unpacked next to the live&#10;directory and swapped in.&lt;/p&gt;&#10;&lt;p&gt;Scheduling came for free. A post with a future date is skipped by Hugo until the time has passed, so the build job&#10;keys its &amp;ldquo;anything changed?&amp;rdquo; test on the commit plus the list of still-future posts, and the first run after the time&#10;publishes and announces.&lt;/p&gt;&#10;&lt;p&gt;There is one more gate, and it exists because of the first post. A scheduled post only goes out if the version I&#10;reviewed is the version in git, recorded as a hash of the file. Edit the post after approving it and the build holds&#10;it with a line in the log until someone approves again. I found out how that feels on launch day: three small edits&#10;after the morning approval, and the 15:00 slot came and went while I was wondering why nothing had happened. Ten&#10;minutes of annoyance. It was doing its job.&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 and, for every post it hasn&amp;rsquo;t announced yet, does the rounds.&lt;/p&gt;&#10;&lt;p&gt;A public toot from my account: title, summary, link, up to eight hashtags from the post. The toot&amp;rsquo;s id is saved&#10;against the post&amp;rsquo;s path, and the request carries an idempotency key so a retry can&amp;rsquo;t post twice. Then a Bluesky&#10;post with a link card and the social-card image as its thumbnail. Then an IndexNow ping, and the full article to&#10;dev.to with a canonical link back here.&lt;/p&gt;&#10;&lt;p&gt;Lemmy is the interesting one. If the post&amp;rsquo;s front matter names a community, the toot mentions it, and Lemmy turns&#10;the mention into a post in that community; replies there federate back as replies to the toot. The catch is that&#10;the mention only becomes a real mention if my instance has &lt;em&gt;seen&lt;/em&gt; that community before. The first time it hadn&amp;rsquo;t.&#10;The mention stayed plain text, no Lemmy post appeared, and I sat there refreshing the community page until I&#10;understood why. Looking the community up once in the web UI and editing the toot fixed it; Lemmy created the post&#10;from the edit within seconds. The publisher now asks the instance to look the community up itself before posting,&#10;which needed a token that is allowed to search.&lt;/p&gt;&#10;&lt;p&gt;dev.to deserves its own paragraph, because its API has three habits the documentation doesn&amp;rsquo;t mention. It refuses&#10;the default Python user agent outright (&amp;ldquo;Forbidden Bots&amp;rdquo;). It renders every single newline as a line break, so&#10;hard-wrapped markdown arrives as a poem. And it drops SVG images. The publisher now sends a descriptive user agent,&#10;unwraps the paragraphs, swaps an SVG for its PNG twin when one exists, and picks the four tags dev.to readers&#10;actually follow.&lt;/p&gt;&#10;&lt;p&gt;All 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 public context endpoint for that status. No token needed: 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 whole security story. Everything from either API is inserted as text; reply HTML from&#10;Mastodon is converted to plain text with the paragraph breaks kept, and nothing is ever parsed as HTML on my page.&#10;Links in replies become plain anchors with &lt;code&gt;rel=&amp;quot;nofollow ugc noopener noreferrer&amp;quot;&lt;/code&gt;. Avatars load only from the two&#10;known hosts, as small thumbnails, and a missing one is dropped rather than shown broken. Only public and unlisted&#10;replies, at most 200. The page sends nothing anywhere.&lt;/p&gt;&#10;&lt;p&gt;Moderation is no extra work. Block or mute an account on Mastodon and its replies disappear from the API response,&#10;and so from the page.&lt;/p&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, the cross-origin isolation headers, rate limits per client, and an access log with the&#10;last part of every address removed. No cookies, so no banner.&lt;/p&gt;&#10;&lt;h2 id="the-redaction-check"&gt;The redaction check&lt;/h2&gt;&#10;&lt;p&gt;The biggest risk on a blog about your own network is you. My notes are full of internal addresses, hostnames,&#10;identifiers and the odd token, and the posts are written from those notes. So the public build is grepped before&#10;every deploy: private IP ranges, the public IP, MAC addresses, the street and the town, phone numbers, e-mail aliases,&#10;anything shaped like a bearer token or an API key, serial numbers, internal hostnames, and a list of things I have&#10;decided never to mention. One hit and the deploy stops with a notification. An allow-list covers deliberate&#10;mentions. It has already caught a home-page title, which is not where I expected the first hit.&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 loaded the preview in headless Chrome on a VM, with the comments pointed at a test thread.&#10;Every unit-level check had passed. The browser found two things that would have shipped.&lt;/p&gt;&#10;&lt;p&gt;The preview never showed comments. The page&amp;rsquo;s lookup key kept the &lt;code&gt;/preview&lt;/code&gt; prefix, so it never matched the keys in&#10;&lt;code&gt;syndication.json&lt;/code&gt;. Trivial, and invisible without a browser.&lt;/p&gt;&#10;&lt;p&gt;The second one was stranger: nobody at home would ever have seen comments. My other public hostnames resolve to the&#10;reverse proxy&amp;rsquo;s LAN address when you are on the LAN (split DNS), and the new blog name didn&amp;rsquo;t yet. To Chrome the blog&#10;was a &lt;em&gt;public&lt;/em&gt; page trying to read a &lt;em&gt;private&lt;/em&gt; address, and its private-network-access rule blocked the request.&#10;Visitors on the internet were fine. The author was not. One DNS override fixed it, and &amp;ldquo;every public name gets the&#10;override&amp;rdquo; is now written down.&lt;/p&gt;&#10;&lt;p&gt;That check is part of publishing now. Load the draft in Chrome, collect console errors, failed requests, broken images&#10;and links, confirm the comments widget works, 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.&lt;/p&gt;&#10;&lt;p&gt;Bluesky&amp;rsquo;s card was there. Every other crawler on the fediverse had fetched the page. My own Mastodon instance had&#10;not even tried: nothing in its logs, no failed job, no retry. I looked at that empty space under the toot for longer&#10;than I&amp;rsquo;d like to admit.&lt;/p&gt;&#10;&lt;p&gt;The cause was the fix from the second bug above. Split DNS makes the blog&amp;rsquo;s name resolve to the reverse proxy&amp;rsquo;s LAN&#10;address inside the house, and the Mastodon server lives inside the house. Mastodon&amp;rsquo;s HTTP client refuses to fetch&#10;private addresses, a sensible guard against server-side request forgery, and the link-card service swallows that&#10;refusal without a word. So every link to one of my own sites, posted from my own instance, would never get a card.&lt;/p&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t loosen the guard. The Mastodon containers got public DNS instead, so my names resolve to the public address&#10;for them and the fetch loops back in through the firewall like any outside visitor. Running the card service by hand&#10;on the toot produced the card a second later.&lt;/p&gt;&#10;&lt;p&gt;So the DNS lesson has a footnote: split DNS is right for the clients and wrong for any service on the LAN that is&#10;supposed to behave like a stranger. I&amp;rsquo;m fairly sure there are more of those on my network that I haven&amp;rsquo;t found yet.&lt;/p&gt;&#10;&lt;h2 id="after-the-first-post"&gt;After the first post&lt;/h2&gt;&#10;&lt;p&gt;Replies are fetched every ten minutes and each new one arrives as a push notification with its text and a link to&#10;answer. Answering within the hour instead of the next morning is what makes this feel like a comment system rather&#10;than a mailing list.&lt;/p&gt;&#10;&lt;p&gt;The Lemmy community mention did more than everything else combined. Within an hour of the post, a few hundred&#10;fediverse servers had fetched the page for their link previews, almost all of them subscribers of that community. Page&#10;views were a fraction of that, in the low hundreds on the first day, two thirds of them within two hours of the&#10;announcement. Previews are reach, not readers.&lt;/p&gt;&#10;&lt;p&gt;The comments came in the way I had hoped, more or less. The first 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, and since it is a public&#10;thread I answered it like any other reader. A few days later someone on another Mastodon server asked what the weekly&#10;spot-check actually catches and, in passing, described their own convention for records they are not sure about yet.&#10;I&amp;rsquo;m borrowing it.&lt;/p&gt;&#10;&lt;p&gt;Every page also 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 tick, and each&#10;post names my account as its author, so shared links show &amp;ldquo;More from&amp;rdquo; on servers that support it.&lt;/p&gt;&#10;&lt;p&gt;If I were starting again I&amp;rsquo;d keep almost all of it. Static files plus &amp;ldquo;comments live elsewhere&amp;rdquo; is a comfortable place&#10;to be: the attack surface is a web server serving files. The toot id is the only state, so save it first and never&#10;re-announce. Render other people&amp;rsquo;s content as text, always. And test from the network your readers are on, which is&#10;not your own.&lt;/p&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&#10;comments if you want more detail on a step.&lt;/em&gt;&lt;/p&gt;&#10;</content:encoded></item></channel></rss>