Bridging This Blog into the Fediverse with Bridgy Fed
Chris Cowley
- 18 minutes read - 3735 wordsI have been using the Fediverse more and more and decided I would like to make this blog part of it. Why? Well, if I am honest it is “because I can” so I went down the rabbit hole of how to make that happen.
It turns out that what I actually wanted was for this site to be a fediverse account in its own right. It would be something people on Mastodon can follow, reply to and boost directly, with the real post living here the whole time. That is a different problem to solve, and the tool for it is Bridgy Fed.
This write-up covers the whole thing: what Bridgy Fed actually needs from a static Hugo site, what I built to provide it, and - because it is too good a cautionary tale to leave out - the week I spent not noticing an early, over-broad version of one Cloudflare rule had quietly broken my TLS certificate.
Fediverse, Bluesky, and what Bridgy Fed actually does
Worth being precise about this, because it shapes the whole architecture: Bluesky is not part of the fediverse. Mastodon and friends speak ActivityPub; Bluesky speaks its own AT Protocol. They are two separate decentralised networks, and Bridgy Fed’s job is to sit between them (and the IndieWeb) and translate - profiles, posts, likes, reposts, follows, mentions - so an account on one side can interact with an account on the other without either side knowing the difference.
For a personal site, this means Bridgy Fed makes cowley.tech itself into an addressable actor - something like @[email protected] on the fediverse side - rather than copying posts into an account I separately own. People follow the domain, not a copy of it.
A few caveats worth knowing before committing to this, mostly because none of them are obvious from the marketing:
- Bluesky’s 300-character limit means longer fediverse-side posts get truncated to roughly 260 characters plus a link back, when bridged over.
- Editing a bridged post does not propagate the edit to the other network.
- Bridgy Fed only delivers a post to a network where you actually have followers there - it is not pushing into a void and hoping.
- Posts older than about two weeks will not bridge, due to how fediverse software handles timestamps on delivery.
- It is a single-maintainer open source project (Ryan Barrett) running on Google App Engine. Worth weighing if you lean self-hosted, though there is no realistic self-hosted alternative for AT Protocol bridging today.
What Bridgy Fed actually requires
I went in assuming I would need the full IndieWeb stack - microformats2 markup everywhere, a webmention receiver, the works. The actual setup docs are more forgiving than that. A site needs either microformats2 and webmention support, or an RSS/Atom feed discoverable from the home page. Hugo already builds the second one for you, for free - every theme I have used links rel="alternate" type="application/rss+xml" on the home page without being asked, and Bridgy Fed polls that periodically with zero extra work on my part.
What is actually required, full stop:
- A representative
h-cardon the home page, withu-url(link to the home page) andu-photo(a profile image). - Either an RSS/Atom feed (automatic polling, slower) or webmentions (
h-entrymarkup plus proactively sending a webmention per post, near-instant). - If you want a custom-domain handle rather than a
fed.brid.gysubdomain one,/.well-known/webfingerand/.well-known/host-metaneed to redirect tohttps://fed.brid.gy/.
Everything else - nodeinfo, elaborate h-entry properties, inbound webmention handling for comments - is either not required at all or a layer on top you can add later. I ended up doing a bit more than the bare minimum, because once the webfinger/host-meta redirect was in place anyway, proper h-entry markup and a manual trigger for proactive bridging were cheap enough to be worth it.
Before getting into the implementation, here is the request-time path end to end - this is the piece that bit me, further down:
cowley.tech request (browser, Mastodon, Let's Encrypt, ...)
|
v
Cloudflare (DNS + proxy; this is the TLS cert visitors actually see)
|
+-- /.well-known/webfinger or /.well-known/host-meta
| |
| v
| Cloudflare Worker
| +-- bare request --> serve a local JRD/XRD document
| '-- ?resource=... query --> 302 redirect --> Bridgy Fed (fed.brid.gy)
| |
| +---------+---------+
| v v
| Mastodon Bluesky
| (ActivityPub) (AT Protocol)
|
'-- everything else (pages, assets, /.well-known/acme-challenge/*, ...)
|
v
GitLab Pages (Hugo static site; origin TLS cert lives here)
|
'-- /.well-known/acme-challenge/*
|
v
gitlab.com ACME handler
|
v
Let's Encrypt renews the origin cert
The thing worth noticing in that diagram: /.well-known/acme-challenge/* and the fediverse discovery paths are two completely separate branches off Cloudflare. They should never cross. They did, briefly - that is the whole next section.
Hugo side: the h-card
The theme I use (Ananke) has no author/profile markup anywhere, so this needed adding from scratch. Ananke’s site-header.html partial is the one used for the home page and list pages specifically - single post pages override the header block separately - which makes it the right place to drop a hidden, home-page-only h-card:
{{ if .IsHome }}
<span class="h-card dn">
<a class="u-url p-name" rel="me" href="{{ "/" | absURL }}">{{ .Site.Params.author | default .Site.Title }}</a>
<img class="u-photo" src="{{ "favicon.ico" | absURL }}" alt="">
</span>
{{ end }}
dn is Tachyons’ display: none - the theme already uses Tachyons everywhere, so hiding the card this way is consistent with how the rest of the site works, rather than introducing bespoke CSS. Microformats2 parsers do not care that the markup is invisible; they are reading the DOM, not rendering it.
I used the site favicon as the u-photo for now. It is not a proper profile photo, but it is a valid image at a stable URL, which is all the spec actually needs.
Hugo side: webmention discovery and h-entry
There are two separate webmention flows at play in this setup, in opposite directions - outbound, telling Bridgy Fed about a new post, and inbound, Bridgy Fed telling the blog about a reply (covered later, in the section on showing replies). For the outbound direction - proactively telling Bridgy Fed about a specific new post rather than waiting for its RSS poll - two things are needed on the post itself: a hidden link marking it for bridging, and proper h-entry markup so Bridgy Fed builds a correct ActivityPub object instead of falling back to guesswork.
<article class="h-entry flex-l flex-wrap justify-between mw8 center ph3">
<a class="u-url" href="{{ .Permalink }}" hidden></a>
<a class="u-bridgy-fed" href="https://fed.brid.gy/" hidden></a>
...
<h1 class="p-name f1 athelas mt3 mb1">{{ .Title }}</h1>
...
<strong class="p-author">...</strong>
...
<time class="dt-published ..." datetime="...">...</time>
...
<div class="e-content">{{ .Content }}</div>
The u-bridgy-fed link matters for a specific reason: a webmention is only considered valid if the source page actually links to the target. Without that hidden link, a webmention claiming “this page is relevant to fed.brid.gy” would fail validation the moment Bridgy Fed fetched the page to check.
I also tagged each tag link with p-category, so hashtags come through properly on the fediverse side rather than being dropped:
<a href="{{ .RelPermalink }}" class="p-category link f5 ...">{{ .LinkTitle }}</a>
None of this changes how the site looks. It is all either hidden anchors or extra classes layered onto markup that was already there.
The well-known redirects, and the Cloudflare Worker
Custom-domain handles need /.well-known/webfinger and /.well-known/host-meta to redirect to Bridgy Fed - note that nodeinfo is not part of the actual spec, despite showing up in some bridging write-ups. I run this as a Cloudflare Worker rather than a static redirect, because webfinger needs to behave differently depending on whether it is a bare request (serve a local profile document) or a resource lookup with a query string (defer to Bridgy Fed):
// worker.mjs
var WEBFINGER_JSON = JSON.stringify({
subject: "https://cowley.tech/",
aliases: ["https://cowley.tech"],
links: [
{ rel: "http://webfinger.net/rel/profile/page", type: "text/html", href: "https://cowley.tech/" },
{ rel: "self", type: "application/activity+json" }
]
}, null, 2);
var HOST_META_XML = `<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="https://cowley.tech/.well-known/webfinger?resource={uri}"/>
</XRD>`;
var BRIDGY_FED_ORIGIN = "https://fed.brid.gy";
export default {
async fetch(request) {
const url = new URL(request.url);
const path = url.pathname;
const hasQuery = url.search.length > 0;
if (!hasQuery) {
if (path === "/.well-known/webfinger") {
return new Response(WEBFINGER_JSON, { status: 200, headers: { "Content-Type": "application/jrd+json; charset=utf-8" } });
}
if (path === "/.well-known/host-meta") {
return new Response(HOST_META_XML, { status: 200, headers: { "Content-Type": "application/xrd+xml; charset=utf-8" } });
}
}
// Anything else under the matched routes - resource-specific lookups, etc - defers to Bridgy Fed
return Response.redirect(BRIDGY_FED_ORIGIN + url.pathname + url.search, 302);
}
};
Bare /.well-known/webfinger gets a small JRD document describing the domain itself; /.well-known/webfinger?resource=acct:... - an actual account lookup - falls through to the default branch and gets redirected to Bridgy Fed, which does the real work of answering it.
The Worker is only half the job, though - it still needs a Route bound to it in Cloudflare, and the exact pattern matters more than it looks like it should:
cowley.tech/.well-known/webfinger*
cowley.tech/.well-known/host-meta*
Two details worth getting right here. There is no trailing slash before the final * - a pattern like /.well-known/webfinger/* only matches paths with a literal / followed by something after webfinger, never the bare path or a ?query string, which is what every real webfinger request actually looks like. And the host is the bare apex, cowley.tech, not a subdomain wildcard - *.cowley.tech matches subdomains like www.cowley.tech, never the zone apex itself, which is where the blog actually lives.
I scope this to exactly these two paths rather than a /.well-known/* wildcard, which turns out to matter a lot more than it sounds like it should - see the next section.
An earlier, simpler attempt at bridging had left static files at static/.well-known/webfinger and static/.well-known/host-meta sitting in the Hugo repo itself, answering requests directly from the origin regardless of what was actually asked for. Once the Worker route above was confirmed to be answering correctly, those came out - no reason to leave two different things both willing to answer the same path.
The week the certificate broke
The Worker route above - scoped to exactly webfinger and host-meta - is the end state. It did not start that way, and the gap between the two is a good story.
My first version of the Cloudflare rule did not scope the path at all - it matched everything under /.well-known/*, on the basis that “it’s all fediverse stuff, redirect all of it.” A few days later, Cloudflare started telling me the certificate for cowley.tech was invalid. The site had run fine for years, so my first instinct was DNS - I checked GitLab Pages’ required records against what was in Cloudflare:
$ dig +short cowley.tech A
188.114.97.2
188.114.96.2
$ dig +short _gitlab-pages-verification-code.cowley.tech TXT
"gitlab-pages-verification-code=d2c40591f27380af1ec97dc0612d18cb"
Both correct. Re-adding records that were not actually missing obviously fixed nothing. Time to look past DNS.
The certificate a browser sees on a Cloudflare-proxied site is Cloudflare’s own edge certificate, not the origin’s - and that one was fine:
$ echo | openssl s_client -connect cowley.tech:443 -servername cowley.tech 2>/dev/null | openssl x509 -noout -issuer -dates
issuer=/C=US/O=Google Trust Services/CN=WE1
notAfter=Nov 30 16:13:08 2026 GMT
Cloudflare’s complaint was about the origin certificate instead - the one it checks when talking to GitLab Pages, in Full or Full (strict) SSL mode. Connecting straight to GitLab’s own IP, skipping Cloudflare entirely, told the real story:
$ dig +short chriscowleysound.gitlab.io
35.185.44.232
$ echo | openssl s_client -connect 35.185.44.232:443 -servername cowley.tech 2>&1 | grep -E "verify|notAfter"
verify error:num=10:certificate has expired
notAfter=Oct 4 06:22:41 2026 GMT
GitLab’s own Let’s Encrypt certificate had expired over a week earlier and simply was not renewing. DNS was never the problem - it just happened to be the thing GitLab’s own docs told me to check first.
GitLab Pages renews over the standard ACME HTTP-01 challenge: Let’s Encrypt requests http://<domain>/.well-known/acme-challenge/<token> and expects a specific answer. Direct to the origin, that works exactly as it should:
$ curl -sI -H "Host: cowley.tech" http://35.185.44.232/.well-known/acme-challenge/test-probe
HTTP/1.1 307 Temporary Redirect
location: https://gitlab.com/-/acme-challenge?domain=cowley.tech&token=test-probe
Through Cloudflare - the path Let’s Encrypt’s validation servers actually take - the exact same request got a completely different answer:
$ curl -sI http://cowley.tech/.well-known/acme-challenge/test-probe
HTTP/1.1 302 Found
location: https://fed.brid.gy/.well-known/acme-challenge/test-probe
My own unscoped /.well-known/* rule was catching the ACME challenge too, and redirecting it off to Bridgy Fed on every single renewal attempt. The challenge could never complete, the certificate quietly expired, and stayed expired, because the thing meant to renew it never had a chance to run.
The lesson, which is really the whole point of this section: /.well-known/ is a shared namespace, defined by RFC 8615 and used by a pile of completely unrelated protocols - ACME, fediverse discovery, security.txt, and more. A wildcard rule under it is a trap waiting for the next protocol you haven’t thought about yet. List the exact paths you mean to touch, every time, no matter how tempting the wildcard looks.
Narrowing the rule to an explicit list immediately let the ACME challenge back through:
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://cowley.tech/.well-known/acme-challenge/test-probe
307 -> https://gitlab.com/-/acme-challenge?domain=cowley.tech&token=test-probe
After that it was just a case of triggering a retry on the domain in GitLab’s Pages settings and waiting for the new certificate to issue.
Triggering a federation manually
RSS polling means a new post federates eventually, on whatever schedule Bridgy Fed happens to check the feed. For posts I actually want to show up promptly, I added a manual, opt-in CI job rather than wiring anything automatic into the deploy pipeline:
federate_post:
stage: deploy
image: curlimages/curl:8.9.1
variables:
POST_URL: ""
script:
- |
if [ -z "$POST_URL" ]; then
echo "Set the POST_URL CI/CD variable to the full URL of the published post to federate,"
echo "e.g. https://cowley.tech/posts/2026/10/my-post/ - then run this job again."
exit 1
fi
- |
curl --fail -sS -i -X POST "https://fed.brid.gy/webmention" \
--data-urlencode "source=${POST_URL}" \
--data-urlencode "target=https://fed.brid.gy/"
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
when: manual
allow_failure: true
It is a manual pipeline job (when: manual), not something that fires on every push. Set the POST_URL CI/CD variable to the post I want bridged, trigger it from the GitLab pipeline UI, and it sends a webmention to Bridgy Fed with source set to the post and target set to Bridgy Fed itself - which validates against the u-bridgy-fed link I added to the post template earlier, fetches the page, parses the h-entry, and bridges it out immediately instead of waiting for the next feed poll.
I kept this manual on purpose. Automating it on every push is an easy follow-up, but it means every single post - typos, drafts I flip live by accident, the works - gets pushed out to the fediverse the moment it deploys, with no chance to reconsider. A manual step costs me ten seconds per post I actually want federated promptly, which is a trade I am happy to make for now.
Following the blog from Mastodon
None of the above is much use if nobody actually follows the thing. The site’s fediverse handle, once bridged, is @[email protected] - though @[email protected] and @[email protected] are the exact same identity. Bridgy Fed is explicit that which one you use is purely cosmetic.
To follow it:
- Visit fed.brid.gy/web-site, enter the domain (
cowley.tech), and submit - this makes sure Bridgy Fed has the site registered before anything on the fediverse side goes looking for it. - Wait about a minute.
- From a Mastodon account, search for
@[email protected]and follow the profile that comes up, same as following anyone else.
There is nothing special about following your own bridged site rather than someone else’s - it is the same remote-follow flow either way. fed.brid.gy/web/cowley.tech is worth bookmarking regardless of who is following whom: it is Bridgy Fed’s status page for the domain, showing the bridged profile, follower counts, and any errors it has hit trying to parse or deliver a post - the first place to look if something that should have bridged does not show up.
Closing the loop: showing replies as comments
Following solves half the problem. The other half: if someone on Mastodon replies to or likes a bridged post, where does that go? By default, nowhere near the blog - it exists on the fediverse side, visible on Mastodon and on Bridgy Fed’s own status page, but never on the post itself. For that to change, two things have to happen: Bridgy Fed has to tell my site about the interaction, and my site has to do something with that information at build time, since Hugo has no runtime to speak of.
The first part needs a <link rel="webmention"> tag, declaring where webmentions about a page should be sent. When Bridgy Fed translates a Mastodon reply or like into a webmention, it sends that webmention to whatever endpoint this link declares - and that endpoint needs to be a receiver I actually control, not Bridgy Fed itself (pointing it back at Bridgy Fed’s own address would just mean telling Bridgy Fed to notify itself, which accomplishes nothing):
<link rel="webmention" href="https://webmention.io/cowley.tech/webmention">
webmention.io is the standard hosted option for this - free, and it verifies domain ownership through IndieAuth rather than a new password. IndieAuth just checks that the domain’s home page links back, via rel="me", to an account I can already log into. I already have a GitLab account tied to this project, so that was the obvious choice over creating something new:
{{ if .IsHome }}<link rel="me" href="https://gitlab.com/chriscowleysound">{{ end }}
Signing in at webmention.io then becomes: enter the domain, get redirected through GitLab to confirm it is really me, done. No new credentials anywhere.
Pulling the data into a static site
A webmention lands on webmention.io’s server, not mine, and Hugo has no server of its own to go ask it questions at request time. So something has to pull that data down before each build and hand it to Hugo as a plain data file. I added a job to the CI pipeline that does exactly that, using webmention.io’s JF2 API:
fetch_webmentions:
stage: build
image: curlimages/curl:8.9.1
script:
- mkdir -p data
- |
if [ -z "$WEBMENTION_IO_TOKEN" ]; then
echo "WEBMENTION_IO_TOKEN not set - skipping webmention fetch, site will build with an empty feed."
echo '{"type":"feed","children":[]}' > data/webmentions.json
else
curl -sS --fail "https://webmention.io/api/mentions.jf2?domain=cowley.tech&token=${WEBMENTION_IO_TOKEN}&per-page=1000" \
-o data/webmentions.json || echo '{"type":"feed","children":[]}' > data/webmentions.json
fi
artifacts:
paths:
- data/webmentions.json
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
That writes an empty feed rather than failing whenever the token is not set, on purpose - I added this job before I had actually signed up and generated a token, and the last thing I wanted was my own blog’s deploy pipeline blocked on a step that depends on a third-party account I had not finished creating yet. The main pages job now declares needs: [fetch_webmentions], so the data file is always there by the time Hugo builds, real mentions or an empty placeholder either way.
Hugo picks up anything under data/ automatically and exposes it as .Site.Data.webmentions, so a template can filter it down to just the mentions aimed at the current page:
{{ $feed := .Site.Data.webmentions }}
{{ if $feed }}
{{ $mentions := where $feed.children "wm-target" .Permalink }}
{{ $replies := where $mentions "wm-property" "in-reply-to" }}
...
{{ range $replies }}
{{ $author := .author }}
<!-- author photo/name, then .content.html -->
{{ end }}
{{ end }}
Each entry in webmention.io’s feed already carries an author (name, URL, photo) and content (plain text and rendered HTML) that Bridgy Fed built from the original Mastodon reply - no extra parsing needed on my side, just rendering what is already there under each post, split into actual replies versus likes/reposts/other reaction types.
That last manual step - signing in to webmention.io and setting the resulting token as a CI variable - was the nicest one to leave for last: everything downstream of it was buildable and testable without it, right down to confirming the templates render nothing-but-also-break-nothing against an empty feed. It is done now, so the next build onwards pulls real data instead of the empty-feed fallback. All that is left is for someone to actually reply to a bridged post.
Put together, the outbound trigger and the inbound reply loop look like this:
Outbound: publishing a new post
|
v
federate_post (manual GitLab CI job, POST_URL set by hand)
|
v
POST a webmention to fed.brid.gy (source=new post, target=fed.brid.gy)
|
v
Bridgy Fed fetches the post, parses its h-entry markup
|
+------------------------+------------------------+
v v
Mastodon Bluesky
Inbound: someone replies on Mastodon
|
v
Bridgy Fed translates the reply into a webmention
|
v
sent to the rel="webmention" endpoint --> webmention.io (stores it)
|
v
fetch_webmentions (GitLab CI job, runs before every build)
|
v
data/webmentions.json
|
v
pages (hugo build - renders replies under the matching post)
|
v
deployed to GitLab Pages
Two one-way streets rather than a single round trip: the post goes out through a manual trigger, and a reply only comes back in on the next build after it, not live. Good enough for a blog that is not expecting a conversation in real time.
Takeaways
- Check what a service actually requires before building for what you assume it requires - Bridgy Fed’s bar for a static site is lower than the full IndieWeb stack I went in expecting.
/.well-known/is shared ground. Never wildcard a rule under it - list exact paths, always, for every protocol that might ever want to live there.- A proxied site’s “certificate invalid” error can be about the origin certificate, not the edge one your browser sees. Connecting straight to the origin IP with
curl/openssl -servernameand aHostheader is the fastest way to tell the two apart. - Test the actual headers and content of a response, not just its status code - a 200 can come from a stale static file just as easily as from the thing you meant to build, and the two look identical until you check.
- When something that worked for years suddenly breaks with no DNS changes, look at what did change recently - usually something you added yourself, for an entirely different reason, a few days earlier.
- When a CI step depends on a third-party account you have not finished setting up yet, make it fail soft (an empty-but-valid fallback) rather than fail hard - it let me build and ship everything downstream of the webmention.io signup before that signup even existed.