Fediverse and Bluesky

The federation lab

Trying federation on one machine: this installation and another server, each at a name of its own, speaking to each other over https.

Why it is needed

Servers that federate find each other by name and speak only over https, and a server keeps what it is told another is called. None of that can be tried at http://localhost. The lab gives a development stack what a site on the internet has: names, certificates, and a server it did not write to be understood by.

The names end in .test, which is set aside for this and can never be a real site. Nothing in the lab is reachable from outside your machine, though it can reach out: see Real servers, from the lab.

What is in it

Name What answers Container
https://api.anahita.test This installation's API, as other servers reach it the gateway, nginx
https://anahita.test The web app's host, where a handle such as ana@anahita.test is looked up web-shell, when it is running
https://gts.test GoToSocial (opens in a new tab), a small server that speaks ActivityPub as Mastodon does gotosocial

While the lab is up, activitypub-service is this installation's part in it: make fedi_lab starts it again as the site anahita.test, reached at https://api.anahita.test, trusting the lab's authority and nothing else, and allowed to fetch from the private addresses the lab's servers are at (ACTIVITYPUB_LAB=on). make fedi_lab_down puts it back as it was.

fedi-lab-tls is the front door: an nginx that holds the certificate and is known on the Docker network by all three names, so each server reaches the other by name, as two servers on the internet would. The certificate is signed by an authority made for the lab (scripts/fedi-lab-certs.sh, in fedi-lab/certs/, which Git ignores). GoToSocial is told to trust that authority, and to fetch from the lab's private addresses, which it would otherwise refuse as any careful server does.

Starting it

With the stack running (make up):

make fedi_lab

It is not started by make up, and takes port 443 on your machine while it runs.

Opening it in a browser

Add the names to /etc/hosts:

127.0.0.1 anahita.test api.anahita.test gts.test

and, to be spared the warnings, trust fedi-lab/certs/ca.crt in your browser or keychain. It signs nothing but the lab; remove it when you are done with the lab.

Somebody to talk to

GoToSocial starts with nobody on it. Make somebody:

docker compose --profile fedi-lab exec gotosocial \
  /gotosocial/gotosocial admin account create \
  --username alice --email alice@gts.test --password '<a long password>'

She is then @alice@gts.test, and can sign in at https://gts.test with any Mastodon-compatible client.

Having GoToSocial look somebody up

The real test of whether this installation is understood: ask the other server to find somebody here by their handle. fedi-lab/gts-token.sh signs a GoToSocial account in and prints a token of theirs:

run() { docker run --rm --network anahita-services_anahita-network \
  -v "$PWD/fedi-lab:/lab:ro" "$@"; }

TOKEN=$(run --entrypoint sh curlimages/curl /lab/gts-token.sh alice@gts.test '<her password>')

run curlimages/curl -s --cacert /lab/certs/ca.crt -H "Authorization: Bearer $TOKEN" \
  "https://gts.test/api/v2/search?q=@ana@anahita.test&resolve=true&type=accounts"

GoToSocial then asks anahita.test who that is, is sent on to the API, fetches the person's description and their pictures, and answers with the account as it understood it. Somebody who has not turned federation on is not found. Until the web app has the switch, it is turned on for a person with PUT /activitypub/settings (Federation).

Turning the fediverse on and off for somebody

Nobody is signed in when the lab is driven from a terminal, so the switch in a person's settings has commands that do the same:

docker compose exec activitypub-service /app/app-binary lab-join <username here>
docker compose exec activitypub-service /app/app-binary lab-leave <username here>

Leaving cannot be tried twice against the same GoToSocial. It is told the person is deleted, marks them suspended, and does not take them back when they join again: which is what leaving means, and was found out here. make fedi_lab_reset empties it; make its accounts again afterwards.

Following somebody here from GoToSocial

With a token for alice and the id GoToSocial gave the person here when it looked them up (the id in the search's answer):

G() { run curlimages/curl -s --cacert /lab/certs/ca.crt -H "Authorization: Bearer $TOKEN" "$@"; }

G -X POST "https://gts.test/api/v1/accounts/<id>/follow"            # "requested": true
G "https://gts.test/api/v1/accounts/relationships?id[]=<id>"        # a few seconds on: "following": true
G -X POST "https://gts.test/api/v1/accounts/<id>/unfollow"

Between the first and the second, GoToSocial sent a signed Follow to the inbox, this installation checked it, made the follow, and sent back a signed Accept, which GoToSocial checked in its turn. If it stays "requested", the reason is in docker compose logs activitypub-service (a message refused) or in activitypub_deliveries (an answer not taken).

Following somebody on GoToSocial from here

Until the web app can show somebody from another server, the lab has a command that does what pressing Follow would. It is refused unless ACTIVITYPUB_LAB is on.

docker compose exec activitypub-service /app/app-binary lab-follow <username here> alice@gts.test

G "https://gts.test/api/v1/follow_requests"                         # she approves her followers: the request is waiting
G -X POST "https://gts.test/api/v1/follow_requests/<id>/authorize"  # she says yes

docker compose exec activitypub-service /app/app-binary lab-unfollow <username here> alice@gts.test

The person here has to be on the fediverse. After her yes, activitypub_follows has the follow as accepted; after the unfollow it is gone, and GoToSocial no longer lists them among her followers.

Posts, both ways

With alice following somebody here, and somebody here following alice:

# From here to there.
docker compose exec activitypub-service /app/app-binary lab-post <username here> "Hello **fediverse** #anahita"
G "https://gts.test/api/v1/timelines/home?limit=5"                 # it is in her timeline
docker compose exec activitypub-service /app/app-binary lab-unpost <the note's number>
                                                                    # and gone from it

# From there to here.
G -X POST "https://gts.test/api/v1/statuses" --data-urlencode "status=Bonjour" -d visibility=public
G -X DELETE "https://gts.test/api/v1/statuses/<id>"

What arrived is a note in the development database whose uri begins https://gts.test/. A note posted from the web app, by somebody who is on the fediverse, goes out the same way while the lab is up: lab-post is only for when nobody is signed in.

Looking somebody on GoToSocial up from here

The other direction, and the test that what this installation signs is accepted: GoToSocial answers for nobody without a signature it can check.

docker compose exec activitypub-service /app/app-binary lookup @alice@gts.test

It prints who was found, and keeps her as a person of another server in the development database.

Asking as another server would

From inside the network, trusting only the lab's authority:

docker run --rm --network anahita-services_anahita-network \
  -v "$PWD/fedi-lab/certs:/certs:ro" curlimages/curl -s --cacert /certs/ca.crt \
  "https://gts.test/.well-known/webfinger?resource=acct:alice@gts.test"

docker run --rm --network anahita-services_anahita-network \
  -v "$PWD/fedi-lab/certs:/certs:ro" curlimages/curl -s --cacert /certs/ca.crt \
  https://api.anahita.test/nodeinfo/2.1

A service of ours that is to speak to the lab has to trust the same authority: mount ./fedi-lab/certs into it and set SSL_CERT_FILE to the ca.crt there, as the gotosocial entry in docker-compose.yml does.

Stopping and emptying it

make fedi_lab_down     # stop it; what GoToSocial holds is kept
make fedi_lab_reset    # stop it and empty GoToSocial

Empty it whenever this installation's identity changes (a fresh database, different keys). A server remembers who it was told somebody is, and will refuse the same name with a different key.

Real servers, from the lab

The lab is not sealed off from the internet, and that is of use: a containers's requests go out as anybody's do.

  • Reading works. lookup @somebody@mastodon.social, or the same from the search page, finds a real person on a real server, with their picture. Mastodon answers for a public profile without being able to check who asks.
  • Writing does not, on purpose. The lab calls itself anahita.test, a name no real server can look up, so nothing it sent could be checked there. activitypub-service therefore sends nothing from the lab to a server whose name does not end in .test, and a follow of somebody on a real server is undone as soon as it is made. Following and posting to real servers is for a site with a real name: see Federation, step 8.

What it is not

  • Not the internet. Mastodon itself, Threads and the rest each behave a little differently. Before a release, federation is tried against real servers through a tunnel to a throwaway name of its own, never one that will later be a real site's.
  • Not for Bluesky. Bluesky needs no lab: reading it and writing to it for a connected account both work from a developer's machine.

Where the code is

What Where
The profile, its two containers and its volume docker-compose.yml, fedi-lab
The front door fedi-lab/nginx.conf
The certificates scripts/fedi-lab-certs.sh → fedi-lab/certs/
A token for a GoToSocial account fedi-lab/gts-token.sh
fedi_lab, fedi_lab_down, fedi_lab_reset Makefile

From anahitasocial/anahita-services, 2026-10-27.

Written by Claude, reviewed by Rastin Mehr · About this