SocialSecretary — Installation Guide

Version 2 — September 2026

Drop a file in a folder. Talk to the Fediverse.

SocialSecretary is a headless ActivityPub sidecar. It handles the Fediverse social handshake — followers, signatures, delivery — so that any publishing tool can participate in the Fediverse without understanding ActivityPub. Your publishing tool writes a JSON file into a directory. SocialSecretary does the rest.

This guide takes you from a fresh Ubuntu server to a live Fediverse presence at @you@yourdomain, able to receive followers and broadcast posts from any tool that can write a file.

It is a manual installation, on purpose. There is no install script. Every step is short, every step says why it exists, and when it is done you will know what is on your server and why. That knowledge is what lets you operate it afterwards, and it is what an install script would have taken from you.

Estimated time: one to two hours for a first installation, most of it reading. Assumed skills: you can connect to a server with SSH, edit a text file there, and copy a command from this page. Nothing else is assumed.


Before You Begin

What SocialSecretary is, and is not

SocialSecretary is a small Node.js process that runs alongside your publishing setup. It does one thing: it handles the social layer of ActivityPub. It does not manage content, host a website, or provide a user interface. It is the administrative clerk, not the editor-in-chief.

The hard part of ActivityPub — HTTP signatures — is about 150 lines of careful code using only Node's built-in modules. The complexity of Mastodon and its relatives comes from everything else they are trying to be at the same time: platform, recommendation engine, moderation system, growth mechanism. Strip that away and what is left is signed JSON and a list of addresses. That is what SocialSecretary is.

What ships as code (you do not edit these):

File Role
index.mjs HTTP server and router
inbound.mjs Receives follows, likes, replies, and everything else the Fediverse sends you
outbound.mjs Broadcasts your posts to your followers
signatures.mjs Signs what you send; verifies what you receive
followers.mjs Your follower list
ledger.mjs The archive of every object you have sent — posts, updates, deletes, likes, boosts, undos
watermark.mjs Remembers what has already been broadcast
log.mjs Writes the activity log — everything sent and received, one JSON line per event
config.mjs Reads your configuration and derives every path from it

What you provide, from the annotated stubs in the stubs/ directory:

File Role
socialsecretary_config.mjs Your settings — the one file you fill in
actor.json Your ActivityPub identity
webfinger.json How the Fediverse finds you from your handle
Caddyfile Routes traffic to the right place
socialsecretary.service Keeps SocialSecretary running
denylist.txt Optional: domains you refuse to hear from

Every stub has its instructions written inside it as comments. Read them. They explain not just what to fill in but why each field exists and what goes wrong if you get it wrong. Someone who reads the stubs understands the whole system before running a single command.

What you will have at the end

This is the layout the rest of this guide builds, and the layout the operator guide assumes. Come back to it whenever a step's purpose is unclear.

/opt/socialsecretary/                 SocialSecretary itself (owned by secretary)
  index.mjs, inbound.mjs, ...         the code
  socialsecretary_config.mjs          your settings
  stubs/                              the templates you copied from
  keys/YOUR_DOMAIN.COM/YOUR_HANDLE/
    private.pem                       signs everything you send — never leaves this server
    public.pem                        published in actor.json so others can verify you
  actors/YOUR_DOMAIN.COM/YOUR_HANDLE/ created automatically on first run
    outbox/                           your publishing tool writes envelopes here
    processing/, failed/              in-flight and failed envelopes (see operator guide)
    mailboxes/                        what the Fediverse sends you, sorted by kind
    archive/                          every object you have ever published
    index/                            lookup tables derived from archive/ and mailboxes/
    followers.json                    who follows you
    last_broadcast.txt                the watermark: what has been broadcast
    retry.json                        deliveries waiting to be retried
    log/                               what Secretary sent and received — the third
                                        contract; see the Activity Log Specification
    denylist.txt                      optional blocklist

/opt/secretary/                       home directory of the secretary user (empty)

/opt/sites/YOUR_DOMAIN.COM/public/    static files served by Caddy
  actor.json                          your identity document
  .well-known/webfinger               discovery document
  avatar.png                          your avatar

/etc/caddy/Caddyfile                  Caddy's routing configuration
/etc/systemd/system/socialsecretary.service
                                      the service definition

Conventions in this guide

Replace YOUR_DOMAIN.COM with your domain and YOUR_HANDLE with the handle you want, everywhere they appear. Your Fediverse identity will be @YOUR_HANDLE@YOUR_DOMAIN.COM. Choose the handle now; changing it later means starting over, because it is baked into your identity document and every follower's cached copy of it.

Commands are shown with sudo where root is needed and without it where it is not. That is deliberate — see Step 2 for why SocialSecretary itself never runs as root.


Step 1: What You Need

A server

A VPS running Ubuntu 22.04 or later. 1 GB of RAM is sufficient; SocialSecretary idles at around 50 MB. It has been run in production on the smallest droplet DigitalOcean sells.

A domain, with DNS already pointing at the server

An A record (and AAAA if you have IPv6) for YOUR_DOMAIN.COM pointing at the server's address. It must be resolving before Step 7, because Caddy obtains your TLS certificate by proving to Let's Encrypt that it answers for that name. Check from your own machine:

dig +short YOUR_DOMAIN.COM

It should print the server's IP address. If it prints nothing, wait for DNS to propagate before continuing.

Your handle lives at the domain's root: @you@yourdomain.com, not @you@social.yourdomain.com. A subdomain works technically but reads as second-class in a way a root domain does not.

Software on the server

Three packages. Each is explained at the step that uses it.

# Node.js 20 — runs SocialSecretary
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

# Caddy — reverse proxy and automatic HTTPS
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

# openssl — generates your keypair (usually already installed)
sudo apt install -y openssl

Verify:

node --version    # v20 or later
caddy version
openssl version

There is no npm install anywhere in this guide. SocialSecretary has no dependencies beyond Node itself, and the process manager is systemd, which Ubuntu already has. Nothing gets installed from npm.


Step 2: Create the Service User

SocialSecretary runs as its own user, secretary. It is never run as root and never as your login user.

sudo useradd --system --home-dir /opt/secretary --create-home \
  --shell /usr/sbin/nologin secretary

What each option is for:

Why a dedicated user at all: SocialSecretary holds your private key, accepts requests from the entire Fediverse on /inbox, and writes files to disk. If it is ever compromised, the damage is limited to what secretary can touch — which, after the ownership steps below, is /opt/socialsecretary and nothing else. Your login user, your other services, and the rest of the filesystem are out of reach. This is the single cheapest security measure in the whole installation and it costs one command.


Step 3: Download and Install SocialSecretary

cd /tmp
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.sha256
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.asc

Three files: the archive, its checksum, and its signature. Now verify it. This takes a minute and it is the only step in this guide that protects you from installing something other than what was published.

gpg --auto-key-locate clear,wkd --locate-keys rob@socialsecretary.pub
gpg --verify socialsecretary-latest.tar.gz.asc socialsecretary-latest.tar.gz
sha256sum -c socialsecretary-latest.tar.gz.sha256

What each line does, and what it proves:

gpg --locate-keys fetches the release signing key from socialsecretary.pub itself, using Web Key Directory — the same idea as the WebFinger lookup that will let people find you in Step 6: ask the domain, and the domain answers. No keyserver, no copying a key out of a web page. It prints a fingerprint. It must be:

735C CBB0 C5C2 73DD E2C5 0FA1 A84B 1564 762B 3085

If it is anything else, stop. Either the domain has been compromised or you are not talking to it.

gpg --verify checks that the archive was signed by that key. Want: Good signature from "Rob <rob@socialsecretary.pub>". You will also see a warning that the key is not certified with a trusted signature — that is gpg noting that you have not personally vouched for the key, which is true and fine; the fingerprint check above is your vouching. A BAD signature means the archive is not what was published. Delete it.

sha256sum -c confirms the download arrived intact. Want: socialsecretary-latest.tar.gz: OK. The signature already implies this; the checksum is the quick check you can run without gpg at all.

Do not proceed past a failed check. Delete the files and download again; if it fails twice, something is wrong upstream and you should say so.

sudo mkdir -p /opt/socialsecretary
sudo tar -xzf socialsecretary-latest.tar.gz -C /opt/socialsecretary --strip-components=1
sudo chown -R secretary:secretary /opt/socialsecretary
ls /opt/socialsecretary

You should see the .mjs files from the table above, a stubs/ directory, a docs/ directory, and a tools/ directory. Everything under /opt/socialsecretary is now owned by secretary, which is the user that will read and write it.


Step 4: Directories

SocialSecretary creates its own state directories on first run. Two things it will not create, and you must:

# The static files Caddy serves: identity, discovery, avatar
sudo mkdir -p /opt/sites/YOUR_DOMAIN.COM/public/.well-known

# The directory that will hold your keypair
sudo mkdir -p /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo chown -R secretary:secretary /opt/socialsecretary/keys

The keys directory is left for you to create on purpose. Its absence on startup is a setup error SocialSecretary reports clearly — "private key not found at …" — rather than something it papers over by generating a key you did not know about. A key you did not knowingly create is a key you cannot knowingly protect.

/opt/sites/YOUR_DOMAIN.COM/public is owned by root and read by Caddy. That is correct: nothing in it changes at runtime, and SocialSecretary never writes to it. If you later run a website from the same server, this is where its files go too.


Step 5: Generate Your Keypair

cd /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo -u secretary openssl genrsa -out private.pem 2048
sudo -u secretary openssl rsa -in private.pem -pubout -out public.pem
sudo chmod 600 private.pem
sudo chmod 644 public.pem
ls -l

You should see private.pem readable only by secretary and public.pem readable by everyone. Both owned by secretary, because they were created as secretary.

What the keypair is for: every request SocialSecretary sends to another server — a post to a follower, a like, a follow acceptance — is signed with private.pem. Every server that receives it fetches your actor.json, reads public.pem from it, and checks the signature. That is how the Fediverse knows a post claiming to be from you is from you. There is no account, no password, and no central authority; there is a key, and you hold it.

private.pem never leaves this directory. SocialSecretary reads it at broadcast time. Your publishing tool never touches it and never needs to. Back it up somewhere safe: if it is lost, your identity is lost, and every follower would have to follow you again under a new key.

RSA 2048 is the size the Fediverse expects. Larger keys work but buy nothing here; some older implementations reject them.


Step 6: Create Your Identity Files

Two small JSON files tell the Fediverse who you are. Both are static — served by Caddy, never touched by SocialSecretary.

sudo cp /opt/socialsecretary/stubs/actor.json \
   /opt/sites/YOUR_DOMAIN.COM/public/actor.json
sudo cp /opt/socialsecretary/stubs/webfinger.json \
   /opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger

Note the second file is renamed: webfinger, no extension. That is the path the WebFinger standard requires.

Now open each and fill it in. The stubs end with a block of comments explaining every field. Read them, fill in the fields, then delete the comment block — JSON does not permit comments, and a file that still has them will not parse.

actor.json — the fields that must be right:

awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' \
  /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE/public.pem

Paste the entire output — beginning with -----BEGIN PUBLIC KEY-----\n — as the value of publicKeyPem, inside quotes.

webfinger has two placeholders, your handle and your domain. The subject line must read acct:YOUR_HANDLE@YOUR_DOMAIN.COM; this string is compared byte-for-byte against what someone types into a Mastodon search box.

Verify both files are valid JSON before going on:

node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/actor.json','utf8')); console.log('actor.json OK')"
node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger','utf8')); console.log('webfinger OK')"

A parse error here almost always means the comment block was not deleted, or a \n in the key was typed as a real line break.


Step 7: Configure Caddy

Caddy is the reverse proxy: it answers HTTPS on your domain and routes each request to the right place — SocialSecretary for the ActivityPub endpoints, static files for identity, and later your website for everything else. It also obtains and renews your TLS certificate automatically; you will never run a certificate command.

sudo cp /opt/socialsecretary/stubs/Caddyfile /etc/caddy/Caddyfile
sudo nano /etc/caddy/Caddyfile

Replace YOUR_DOMAIN.COM everywhere it appears. The stub's comments explain each block; the summary:

Path Goes to Why
/inbox SocialSecretary The one endpoint the Fediverse sends to. The only writable surface on your domain.
/trigger SocialSecretary Your publishing tool pings this after writing an envelope
/outbox* SocialSecretary Your post history; Mastodon reads it for your profile
/followers* SocialSecretary Your follower list, for follower counts
/objects/* SocialSecretary Individual published objects
/activities/* SocialSecretary Individual activities. Mastodon fetches these to verify a post is authentic; without this route your posts may be silently dropped.
/actor.json static file Your identity
/.well-known/webfinger static file Discovery
/avatar.png static file Added in Step 12
everything else placeholder Replace with your website when you have one

Order matters. Caddy tries handle blocks top to bottom and the last one matches everything, so every specific route must appear above it. If you later add a static file and it comes back as text/plain or as the placeholder text, this is why — it needs its own handle block above the catch-all.

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

validate catches syntax errors before they take your site down. reload applies the file without dropping connections. Now confirm the identity files are reachable from the outside — run this from your own machine, not the server:

curl -sI https://YOUR_DOMAIN.COM/actor.json | grep -i content-type
curl -s "https://YOUR_DOMAIN.COM/.well-known/webfinger?resource=acct:YOUR_HANDLE@YOUR_DOMAIN.COM"

The first should say application/activity+json. The second should print your webfinger file. If either fails, Caddy's log is the place to look: sudo journalctl -u caddy -n 50.


Step 8: Configure SocialSecretary

sudo cp /opt/socialsecretary/stubs/socialsecretary_config.mjs \
   /opt/socialsecretary/socialsecretary_config.mjs
sudo chown secretary:secretary /opt/socialsecretary/socialsecretary_config.mjs
sudo nano /opt/socialsecretary/socialsecretary_config.mjs

This is the one file that holds your settings. It is kept separate from the code so that updating SocialSecretary never touches it. Every field is explained in the file. The ones you must set:

Everything else has a default that is right for a first installation. Leave deliveryAllowlist alone for now; Step 11 uses it.


Step 9: First Run, By Hand

Before handing SocialSecretary to systemd, run it once in the foreground so you can see it start:

cd /opt/socialsecretary
sudo -u secretary node index.mjs

You should see:

[index] SocialSecretary starting
[index] actor: @YOUR_HANDLE@YOUR_DOMAIN.COM
[index] port:  3000
[index] outbox: /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox
[index] watching outbox: ...
[index] HTTP server listening on port 3000

On this first run SocialSecretary creates actors/YOUR_DOMAIN.COM/YOUR_HANDLE/ and everything inside it. Press Ctrl-C to stop it. Then:

ls -la /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/

The directories from the layout in "Before You Begin" are now there, owned by secretary.

If instead you see private key not found, the key is not at the path config.mjs derives from your config: baseDir/keys/domain/actor/private.pem. Compare that path, character for character, with where Step 5 put the file.

Port 3000 is not reachable from the internet — Caddy talks to it on localhost. Nothing in this guide opens it in a firewall, and nothing should.

Optional: denylist.txt. One of the stubs from "Before You Begin" hasn't been used yet. If you already know domains you want to refuse from the start, copy it in now that the actor directory exists to hold it:

sudo cp /opt/socialsecretary/stubs/denylist.txt \
   /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt
sudo chown secretary:secretary \
   /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt

One domain per line. It's read on every request — no restart needed, ever, even later — and an empty or absent file blocks nothing. Most installs skip this at first and add entries later, from experience; see docs/OPERATOR.md §4.8 for how blocking actually behaves once it's in place.


Step 10: Run It as a Service

SocialSecretary must start on boot, restart if it crashes, and run as secretary. On Ubuntu that job belongs to systemd, which is already running every other service on the machine.

sudo cp /opt/socialsecretary/stubs/socialsecretary.service \
   /etc/systemd/system/socialsecretary.service
sudo systemctl daemon-reload
sudo systemctl enable --now socialsecretary
sudo systemctl status socialsecretary

enable makes it start on boot; --now starts it immediately. status should show active (running), and the last lines should be the same startup log you saw in Step 9.

The service file is short enough to read in full, and you should:

[Unit]
Description=SocialSecretary — headless ActivityPub sidecar
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=secretary
Group=secretary
WorkingDirectory=/opt/socialsecretary
ExecStart=/usr/bin/node /opt/socialsecretary/index.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/socialsecretary

[Install]
WantedBy=multi-user.target

The lines that matter: User=secretary runs it as the service user; Restart=on-failure restarts it if it exits with an error, five seconds later; ProtectSystem=strict with ReadWritePaths=/opt/socialsecretary makes the entire filesystem read-only to the process except its own directory, which is the promise of Step 2 enforced by the kernel rather than by file permissions alone.

Why systemd and not pm2. Earlier versions of this guide used pm2, the Node process manager, and a first production install ran on it. It was replaced for three reasons, in order of how much they cost.

First, pm2 keeps its state per user, under $HOME/.pm2. Running SocialSecretary as a dedicated user means every pm2 command must name that user and that directory, and any command that forgets talks to a different pm2 — one that reports nothing running when SocialSecretary is running, or starts a second copy beside the first. Both of those happened. The second copy fought the first for port 3000 and the pair restarted each other twenty-three times. systemd has one daemon and one process list; systemctl start on a running service is a no-op.

Second, pm2 is installed from npm, globally, as root. It would have been the only npm package in a project whose whole design is that there are none.

Third, systemd is already here, already managing Caddy, already writing to the journal you will use for every other service. One process manager for the whole machine is simpler than two.

Reading the log, from now on:

sudo journalctl -u socialsecretary -f          # follow live
sudo journalctl -u socialsecretary -n 100      # last 100 lines
sudo journalctl -u socialsecretary --since today

Restarting, after a code update or a config change:

sudo systemctl restart socialsecretary

Step 11: Verify With ActivityPub.Academy

ActivityPub.Academy is a Mastodon instance built for testing ActivityPub implementations — no real people, no social dynamics, and it shows you the raw activities it sends and receives. Test here before any real instance sees you.

First, restrict delivery to the Academy so that nothing you do while testing can reach anyone else. In socialsecretary_config.mjs:

deliveryAllowlist: ['activitypub.academy'],

then sudo systemctl restart socialsecretary. This is a delivery filter, not a follower filter: followers elsewhere stay in followers.json and start receiving posts the moment you set the list back to [].

Test 1 — Discovery. Create an account on the Academy and search for @YOUR_HANDLE@YOUR_DOMAIN.COM. Your profile should appear, with your display name and (after Step 12) your avatar. If it does not, Step 7's two curl checks are the first thing to re-run.

Test 2 — Follow. Follow yourself from the Academy account. In the journal you should see the Follow arrive, be verified, and be accepted:

sudo journalctl -u socialsecretary -n 20

and the Academy should show the follow as accepted within a few seconds. followers.json now has one entry.

Test 3 — Broadcast. Write a test envelope into the outbox. This is the same thing your publishing tool will do; you are doing it by hand once.

sudo -u secretary tee /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/test-001.json > /dev/null << 'EOF'
{
  "version": "1",
  "type": "create",
  "id": "test-001",
  "url": "https://YOUR_DOMAIN.COM/test-001",
  "published": "2026-09-05T12:00:00Z",
  "content": "<p>Hello from SocialSecretary.</p>"
}
EOF
curl -X POST http://localhost:3000/trigger

Set published to now, in UTC. The journal should show the envelope picked up, accepted into the archive, and delivered — 1/1 delivered. The post appears in your Academy timeline.

The url in this envelope points at a page that does not exist. For a test that is fine: the Academy displays the post from the content you sent. For real posts, url must be a real page — see "Connecting a Publishing Engine" below.

When all three pass, set deliveryAllowlist: [] and restart. You are live.


Step 12: Add Your Avatar

Accounts without avatars read as suspicious or automated to human followers. This is a Fediverse social fact, not a technical requirement, and it is worth five minutes.

Prepare a square image, at least 400×400 pixels, under 1 MB, PNG or JPEG.

sudo cp your-avatar.png /opt/sites/YOUR_DOMAIN.COM/public/avatar.png
curl -sI https://YOUR_DOMAIN.COM/avatar.png | grep -i content-type

It must say image/png (or image/jpeg). If it says text/plain, the Caddyfile catch-all intercepted the request; the stub already has a handle /avatar.png block, so check that it is still above the catch-all.

Add to actor.json, before the publicKey block:

"icon": {
  "type": "Image",
  "mediaType": "image/png",
  "url": "https://YOUR_DOMAIN.COM/avatar.png"
},

Re-run the JSON check from Step 6. No restart is needed — actor.json is a static file. New followers see the avatar immediately; existing ones see it when their instance refreshes its cached copy of your actor document, which takes hours to a few days.


You Are Federated

@YOUR_HANDLE@YOUR_DOMAIN.COM exists in the Fediverse. No platform, no instance account, no database. One Node process, two JSON files, a Caddy config, and a service unit — every one of which you placed yourself and can find again.

From here:


Connecting a Publishing Engine

Drop a JSON envelope in the outbox and SocialSecretary broadcasts it:

/opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/

The smallest useful envelope:

{
  "version": "1",
  "type": "create",
  "id": "my-first-post-20260905120000",
  "url": "https://YOUR_DOMAIN.COM/posts/my-first-post",
  "published": "2026-09-05T12:00:00Z",
  "title": "My First Post",
  "summary": "A short teaser shown before the fold.",
  "content": "<p>The post, as HTML.</p>"
}

Two fields need care. id must be unique and must be reproducible — an update or delete later must use the same id, so derive it from something stable rather than a random value. url must be a real page on your site that serves the post, because other servers fetch it to display your post and to confirm it is yours.

SocialSecretary watches the outbox and picks new envelopes up on its own within a few seconds. To broadcast immediately, ping the trigger endpoint after writing the file:

curl -X POST http://localhost:3000/trigger

Both paths run the same pipeline. The trigger exists because file watching is unreliable on some filesystems; the watcher exists so a tool that cannot make an HTTP request still works.

That is the whole interface. Your publishing tool never imports SocialSecretary's code, never reads its internals, never touches the archive. A file in a directory and an optional ping.

Updates, deletes, followers-only and direct posts, replies, mentions, attachments, likes, boosts, and retracting a like or boost are all envelopes too. The Handoff Envelope Specification defines every field; the example above covers a small fraction of it.

The other direction — replies, likes, boosts, mentions arriving from the Fediverse — lands in mailboxes/, sorted by kind, one JSON file per activity. The Mailbox Specification defines what your tool will find there.


Troubleshooting

systemctl status shows failed or activating (auto-restart). sudo journalctl -u socialsecretary -n 50 shows the error. The three most common on a fresh install: private key not at the derived path (Step 5), a syntax error in socialsecretary_config.mjs (Step 8), or a permissions error because something under /opt/socialsecretary is not owned by secretary (Step 3, chown -R).

This can also happen on an install that has been running fine for months: socialsecretary_config.mjs still contains YOUR_DOMAIN.COM because the stub template got copied over the live file — an installer template and a live, edited config look alike enough to swap by mistake, and it has happened more than once in production. The error names the placeholder value directly, so it is not a guessing game; the fix is restoring your real config, not editing this one back into shape. See docs/OPERATOR.md §4.1 and §4.7, and the project ledger entries §4.7 points to, for why this specific mix-up gets its own paragraph instead of being folded into "syntax error" above — it is not a syntax error, it is valid JavaScript with the wrong values, and node --check will find nothing wrong with it.

Follows are not received. Confirm Caddy routes /inbox to port 3000: sudo caddy validate and look at the handle /inbox block. Confirm the domain resolves and actor.json is served with the right content type (Step 7's checks). Look for signature verification failed in the journal, which means the sender could not be verified — usually a clock more than a few minutes wrong on your server (timedatectl).

Posts are not broadcast. Check last_broadcast.txt in the actor directory: an envelope whose published is older than that timestamp is skipped as already sent. Check failed/ for the envelope and its .error.txt. Try the trigger by hand and watch the journal.

Discovery fails — handle not found. Run the webfinger curl from Step 7 exactly as written. The subject in the file must match the search query byte for byte, including acct:.

Avatar not showing. Step 12's content-type check. If correct and it is still missing, the remote instance has a cached actor document; wait, or search your handle from that instance to prompt a refresh.

Posts show on the Academy but not on a real instance. Real instances fetch url and expect either an HTML page or, when they ask for application/activity+json, an ActivityPub object. If your site does not yet answer at that URL, that is the cause. zdat's site does this; any other publishing engine must be taught to.