I run a small, private Matrix homeserver — Synapse, Element Web, Element Call, a Postgres database and an admin API, all bolted together with Docker Compose and reachable only over my Tailnet. It does everything I want for chat. What it did not do was let me read and write Mastodon without opening yet another browser tab.

This is the story of fixing that: what I wanted, the options I looked at, why I picked the one I did, how I installed it, the things that broke along the way, and how the flipboard.social application and its keys fit into the picture.

The End Goal

The brief was deliberately modest, because scope creep is how homelab projects die.

  • Read my timeline in Matrix: my Mastodon home stream, plus notifications such as mentions, boosts and favourites, should land in a dedicated Matrix room so I can catch up in the same client I already have open all day.
  • Post from Matrix: I should be able to compose a toot from Element on my phone or laptop without logging into Mastodon at all.
  • Boost and favourite from Matrix: the two interactions I actually use should be available as commands in the room, not just read-only text.
  • Stay inside the Tailnet: nothing about this should require exposing my homeserver to the public internet, because the entire stack is deliberately Tailnet-only with no inbound ports open.
  • Fit the existing pattern: it had to be a Compose service, configured from .env via the existing template renderer, with no hand-edited config and no secrets in Git.
  • Single account, single room: this is a personal account (@[email protected]), not a multi-user community bridge, so per-user puppeting was explicitly a non-goal.

That last point turned out to be the most important constraint of the lot, and it shaped every decision that followed.

The Options I Explored

Option 1: matrix-hookshot with an RSS/Atom feed

Hookshot is the obvious first stop. It is actively maintained by the Matrix.org team, it is already the de facto integration bridge, and it can subscribe a room to any RSS or Atom feed — including the feed Mastodon exposes for a user profile.

  • What appealed: maturity and support are unmatched, it needs no external database because it stores state in Matrix itself, it supports end-to-bridge encryption, and it would give me a tidy !hookshot feed command in the room.
  • What killed it: RSS is strictly read-only. Hookshot’s feeds integration can receive events but cannot send anything back, so posting, boosting and favouriting were all off the table.
  • The other problem: a profile RSS feed only carries my own public posts. It does not carry my home timeline, and it carries no notifications or mentions at all, so it fails the “read my timeline” requirement as well as the write requirement.
  • Verdict: excellent tool, wrong job. I would happily run Hookshot for GitHub or GitLab notifications, but as a Mastodon client it is a glorified blog reader.

Option 2: A proper ActivityPub appservice bridge

There are efforts to build a real bidirectional Matrix–Fediverse bridge — a Matrix appservice that also speaks ActivityPub, with ghost users, double puppeting, threading, media and moderation. On paper this is exactly what “bridging Mastodon into Matrix” should mean.

  • What appealed: it is the architecturally correct answer, with @user@instance ghosts appearing as real Matrix users, replies threading properly and content warnings mapping onto spoilers.
  • The killer requirement: ActivityPub is a server-to-server protocol. It needs a public domain, valid TLS, and a publicly reachable inbox so remote instances can deliver activities. My stack binds to a Tailscale address and has no public ingress whatsoever, and I was not going to punch a hole in that just to read toots.
  • The supporting infrastructure: the serious implementations want Node, PostgreSQL, Redis, an appservice registration, database migrations and a webfinger endpoint. That is a lot of moving parts to babysit for one personal account.
  • The maturity question: the projects in this space are young, thinly contributed and low-star. For something holding an access token to my social identity, “two stars and one contributor” is not a comfortable place to be.
  • Verdict: right architecture, wrong deployment model. If I ever run a public homeserver for a community, I will revisit this.

Option 3: A read-only toot forwarder bot

There are small bots in the Matrix ecosystem whose entire job is to forward your Fediverse feed into a Matrix room.

  • What appealed: dead simple, tiny footprint, and it does solve the “read my timeline in Matrix” half of the brief properly — including the home stream, not just a profile feed.
  • What killed it: it is a one-way pipe. I would still have had to open Mastodon to reply, boost or post, which is precisely the friction I was trying to remove.
  • Verdict: a decent fallback if everything else failed, but only half a solution.

Option 4: Roll my own bot

The DIY route: a small Python service using a Mastodon API client on one side and a Matrix client library on the other, or a plugin for an existing bot framework like maubot.

  • What appealed: total control over behaviour, formatting and command syntax, and it would slot neatly alongside the other custom bot already running in the stack.
  • What killed it: I would be writing and then maintaining streaming reconnection logic, deduplication of posts I had already seen, media upload handling in both directions, rate limiting and token refresh. All of that is unglamorous plumbing that somebody else has already debugged.
  • The honest assessment: this was the “if nothing exists, build it” option, and something did exist.
  • Verdict: rejected on maintenance cost, not on capability.

Option 5: mycete

mycete is a small Go bridge that connects a single Matrix room to a single Mastodon account. It is not an appservice and it does not pretend to be a full bridge — it is a bot that sits in a room, streams your Mastodon timeline into it, and posts anything you prefix with a guard string back out as a toot.

  • What appealed most: it is genuinely bidirectional for exactly the interactions I care about — posting, boosting and favouriting — while also streaming the home timeline and notifications inbound.
  • The deployment fit: it is a single static Go binary with one INI-style config file and no database, no Redis and no public ingress. It talks outbound to the Mastodon API and inbound to Synapse’s client-server API on the internal Docker network. That is a perfect match for a Tailnet-only stack.
  • The guard prefix design: nothing gets tooted unless you deliberately prefix it with t>, which means the room is safe to talk in without accidentally broadcasting to the Fediverse. This is a small design decision that does a lot of work.
  • The obvious caveat: it is not actively developed. The version I pinned dates from 2022, and there is no end-to-end encryption support, so the bridged room has to stay unencrypted.
  • The scope caveat: one account, one room, no ghost users. Everything appears as messages from a single bot user rather than as individual Fediverse identities.
  • Verdict: chosen.

Why mycete Won

The decision came down to matching the tool to the actual constraints rather than to the most impressive feature list.

  • Bidirectionality was non-negotiable: only mycete and the DIY option could post, boost and favourite from Matrix, and only mycete came pre-written.
  • The Tailnet constraint eliminated the “correct” answer: any true ActivityPub bridge needs public inbound HTTPS, which my threat model does not allow. mycete only ever makes outbound calls to the Mastodon API, so it works perfectly behind Tailscale with no ports opened.
  • Single-account scope made puppeting pointless: I am one person with one account. The complexity of ghost users and double puppeting buys me nothing, so paying for it in operational overhead would have been irrational.
  • Operational cost was near zero: one Compose service, one rendered config file, no database, no migrations and no appservice registration to wire into homeserver.yaml.
  • The staleness was an acceptable risk: the Mastodon API surface it uses is stable and long-established, the binary is pinned to a specific version so builds are reproducible, and if it ever breaks the blast radius is one bot in one unencrypted room. That is a risk I can live with; an unmaintained appservice holding ghost users across my homeserver would not have been.
  • Losing encryption was tolerable in context: the room exists purely to mirror content that is already public on Mastodon, so there is nothing in it that needed protecting in the first place.

How It Was Installed

Everything followed the pattern the rest of the stack already uses: a pinned image built locally, a config template rendered from .env, and a Compose service mounting the result read-only.

Building the container

mycete is distributed as source, so it needed a multi-stage build. The build stage uses golang:1.22-bookworm with CGO_ENABLED=0 to produce a fully static binary, and the runtime stage is debian:bookworm-slim with nothing in it but CA certificates and a non-root mycete system user.

  • The version is pinned: the build installs a specific Go pseudo-version rather than tracking a branch, so a rebuild six months from now produces the same binary.
  • CA certificates matter: the slim Debian base has none, and without them every HTTPS call to the Mastodon API fails with a certificate verification error. This is the classic slim-image trap.
  • It runs unprivileged: the container drops to the mycete user and its entrypoint reads the config from /config/mycete.conf.

Rendering the configuration

The stack never hand-writes config. A config-renderer service runs a Python script that reads .env, substitutes values into templates under config/, and writes the results into runtime/, which is gitignored. Mastodon was no different.

  • The template lives at config/mycete/config.ini.template: it sets twitter=false and mastodon=true, points the bridge at http://synapse:8008 over the internal Docker network, and leaves every credential as a ${MYCETE_...} placeholder.
  • The rendered output lands at runtime/mycete/config.ini: this file contains live secrets and is never committed, because the whole runtime/ directory is gitignored.
  • The Compose service mounts it read-only: runtime/mycete/config.ini is mounted at /config/mycete.conf:ro, and the service depends on the renderer completing successfully and on Synapse being healthy.
  • The command prefixes are configured here: t> to toot, reblog> to boost and +1> to favourite, all of which are set in the [matrix] section alongside the welcome message shown when someone joins.
  • The inbound behaviour is configured too: the [feed2matrix] section enables the complete home stream, Mastodon notifications and my own toots posted from other clients, with a 1000-character limit and sensible caps on image count and size.

Wiring up the Matrix side

  • A dedicated bot user was created: @mastodonbot:matrix.safehomelan.com, with its password held in .env as MYCETE_PASSWORD. mycete logs in as an ordinary user, so no appservice registration was required.
  • A dedicated room was created: #mastodon:matrix.safehomelan.com, added to BOOTSTRAP_DEFAULT_ROOMS so the bootstrap script provisions it as part of the community space alongside announcements, chat, video and support.
  • Encryption was left off for that room: mycete cannot decrypt or send encrypted events, so the room has to remain unencrypted for the bridge to function.
  • Members were invited with the existing helper: scripts/invite_to_room.py mastodon davidfield resolves the room alias to its internal ID using the bootstrap admin account and issues the invites, handling “already in the room” gracefully.

Bringing it up

The whole thing is a docker compose up -d --force-recreate away once .env is populated: the renderer regenerates the config, then mycete starts, logs syncing.., and the timeline begins arriving in the room.

Issues, and How They Were Resolved

Nothing that touches four APIs at once works first time. These are the ones worth recording.

The Go build could not reach GitHub

The first build attempt failed because cloning the source repository directly from GitHub was blocked in my build environment, while the Go module proxy remained perfectly reachable.

  • The fix: build with go install github.com/qbit/mycete@<version> instead of git clone followed by a local build. The module proxy serves the same code over a different path, and it sidesteps both the block and GitHub’s rate limits.
  • The bonus: pinning to an explicit module version made the build reproducible, which a git clone of the default branch never would have been. The comment explaining why lives in the Dockerfile so future-me does not “helpfully” revert it.

The config rendered with the placeholders still in it

The first rendered config came out with literal ${MYCETE_MASTODON_ACCESS_TOKEN} strings in it, and mycete predictably refused to authenticate.

  • The cause: the renderer overlays real environment variables on top of the .env file, but only for a whitelist of known key prefixes. MYCETE_ was not on that list, so Compose-supplied values were being silently dropped.
  • The fix: add MYCETE_ to the prefix allow-list in scripts/render_configs.py, then re-run the renderer. The placeholders substituted correctly and the bridge authenticated on the next start.
  • The lesson: a template renderer that silently passes through unsubstituted placeholders is a foot-gun. safe_substitute is convenient right up until it hides your mistake.

mycete wants a room ID, not a room alias

Setting MYCETE_ROOM_ID=#mastodon:matrix.safehomelan.com does not work. The bridge expects the internal room ID, the one that starts with an exclamation mark.

  • The fix: resolve the alias to its internal ID — either from Element under Room Settings → Advanced → Internal room ID, or straight from the directory API the way scripts/invite_to_room.py does — and put that value in .env instead.
  • The gotcha to remember: room IDs are opaque and not portable. If the room is ever deleted and recreated, this value must be updated, because the alias will happily point at a new ID that the config knows nothing about.

The config file, its owner, and a non-root container

The renderer runs as root inside its own container, so the files it writes into runtime/ are owned by root. The mycete container deliberately runs as an unprivileged user.

  • The symptom to watch for: if you tighten the rendered config to mode 600, the bridge cannot read its own configuration and exits immediately.
  • The resolution: leave the rendered file world-readable and rely on host-level directory permissions and the gitignore to keep it out of harm’s way. It is a pragmatic trade-off, and one worth being conscious of, because that file contains a Mastodon access token in plain text.

Double-posting and the “already seen” check

Because the config deliberately enables both “show my own toots from foreign clients” and posting from Matrix, there is an obvious risk that a toot sent from Matrix comes straight back down the stream and appears twice.

  • What actually happens: the logs show lines like controlroom <id> failed already seen check, which is mycete’s deduplication working exactly as intended — it recognises the status as one it has already handled and drops it.
  • Why it reads like an error but is not: the wording is unfortunate. “Failed already seen check” means “this failed the check because it has already been seen”, not “the check failed”. It is the single most alarming-looking healthy log line in the whole stack.

Everything is one bot user

Every inbound toot arrives as a message from @mastodonbot, with the author’s name in the message body rather than as a distinct Matrix sender.

  • Why: mycete is a bot, not an appservice, so it has no ability to create ghost users.
  • How I made peace with it: the room is a timeline, not a conversation. Treating it as a feed reader with a compose box rather than as a chat room with Fediverse people in it makes the design feel deliberate instead of limited.

Creating the flipboard.social Application, and Where the Keys Go

My account lives on flipboard.social, so the OAuth application has to be registered there. Mastodon makes this straightforward, and it happens entirely in the web UI.

Creating the application

  • Go to Preferences → Development: on flipboard.social that is https://flipboard.social/settings/applications, then choose “New application”.
  • Name it something recognisable: I used a name that identifies the bridge, because this is the label that appears in your authorised applications list forever afterwards.
  • Leave the redirect URI as the default: urn:ietf:wg:oauth:2.0:oob is correct. mycete uses a pre-issued access token rather than an interactive OAuth redirect flow, so no callback URL is needed.
  • Select the minimum scopes that work: read to stream the timeline and notifications, write to post, boost and favourite, and follow if you want the bridge to be able to act on follow relationships. Everything else can be unticked — there is no reason to grant admin scopes to a personal bridge.
  • Submit, then open the application again: Mastodon only shows the credentials once you revisit the saved application.

The three values you need

Opening the saved application reveals three secrets, and they map one-to-one onto the bridge’s configuration.

  • Client key: this is the OAuth client identifier, and it becomes MYCETE_MASTODON_CLIENT_ID.
  • Client secret: the matching client secret, which becomes MYCETE_MASTODON_CLIENT_SECRET.
  • Your access token: this is the important one, listed at the bottom of the application page. It is the pre-authorised token for your own account and becomes MYCETE_MASTODON_ACCESS_TOKEN. In practice this is what actually authenticates the bridge — treat it as a password for your Mastodon account.

Where they go

  • Straight into .env at the repository root: alongside MYCETE_MASTODON_SERVER=https://flipboard.social, MYCETE_USERNAME, MYCETE_PASSWORD and MYCETE_ROOM_ID.
  • Never into the template: config/mycete/config.ini.template is committed to Git and must only ever contain ${...} placeholders. The real values are substituted at render time into runtime/mycete/config.ini, which is gitignored.
  • Note the URL format: MYCETE_MASTODON_SERVER needs the full scheme — https://flipboard.social, not a bare hostname.
  • Re-render after changing them: the config is generated, so editing .env alone changes nothing until the renderer runs. docker compose run --rm config-renderer followed by recreating the mycete service picks up the new credentials.
  • If a token leaks, revoke it at source: deleting the application in Mastodon’s settings immediately invalidates its token. Rotating the credential is a two-minute job, which is a good reason to prefer an application token over anything longer-lived.

Final Thoughts

The interesting part of this project was not the installation — that was a Dockerfile, a config template and a Compose service, an afternoon’s work at most. The interesting part was resisting the pull of the architecturally correct answer.

A real ActivityPub appservice bridge is unambiguously the better piece of engineering. It is also completely wrong for a Tailnet-only homeserver serving one person’s personal Mastodon account, because it demands public ingress I am not willing to provide and operational complexity I would not enjoy maintaining. Choosing a small, slightly stale Go bot over an ambitious appservice felt like a compromise while I was making the decision, and it has felt like the obviously right call every day since.

  • What I would do the same: pin the dependency, render the config from .env, and keep the bridge to one room with an explicit guard prefix. All three of those decisions have already paid for themselves.
  • What I would do differently: I would look at the rendered config immediately after the first render rather than after the first failure. Ten seconds of reading would have saved a confusing round of authentication debugging.
  • What surprised me: the guard prefix is the feature I value most. Being able to hold a scratch conversation in the room and only deliberately push things to the Fediverse is far nicer than a bridge that mirrors everything by default.
  • What I am watching: the lack of maintenance and the lack of encryption. Neither hurts today, but if the Mastodon API shifts under it, the fallback plan is a small custom bot doing the same job with a modern library.
  • The wider point: the best bridge is the one whose deployment model matches yours. Feature matrices are seductive, but “does this need a public IP” turned out to be the only question that actually mattered.

For now, I read my timeline in the same client as everything else, I toot with a two-character prefix, and I have one more reason not to open a browser. That was the whole point.

By admin

Leave a Reply

Your email address will not be published. Required fields are marked *