---
title: "A Small ActivityPub Server For A Static Blog"
url: https://deallocatedobjects.com/posts/a-small-activitypub-server-for-a-static-blog
author: Jake Marsh
published: 2026-11-06
updated: 2026-11-06
topics: ["This Blog", "Web"]
description: "You can follow this blog from Mastodon. Search for @blog@deallocatedobjects.com in Mastodon, Ivory, Ice Cubes or any other fediverse app, tap Follow, and new posts show up in your home timeline next to everything else you read. The blog itself is a static site."
---

# A Small ActivityPub Server For A Static Blog

By Jake Marsh, November 6, 2026. HTML: https://deallocatedobjects.com/posts/a-small-activitypub-server-for-a-static-blog

You can follow this blog from Mastodon. Search for `@blog@deallocatedobjects.com` in Mastodon, Ivory, Ice Cubes or any other fediverse app, tap Follow, and new posts show up in your home timeline next to everything else you read.

The blog itself is a static site. nginx serves plain files, and nothing runs when you load a page. ActivityPub needs a few things a folder of HTML can't do: it has to answer lookups with JSON built on the fly, accept signed POSTs, remember followers and push new posts out to them. I already had a small Bun service on the server for the newsletter signup form, so the fediverse side lives in that same process, in one file called `newsletter/ap.ts`. It's about 200 lines, and it's a trimmed-down port of the federation code I wrote for abiel.com.

Here's how each piece works, including the details that took me a while to get right.

### Routing Around A Static Site

nginx sends three kinds of requests to the service on `127.0.0.1:3074` and serves everything else from disk:

```nginx title="deploy/nginx.conf"
location = /.well-known/webfinger { proxy_pass http://127.0.0.1:3074; }
location = /.well-known/nodeinfo  { proxy_pass http://127.0.0.1:3074; }
location ^~ /ap/ {
    client_max_body_size 1m;
    proxy_pass http://127.0.0.1:3074;
}
```

(The real blocks also set the usual `Host` and `X-Forwarded-*` headers and a 30 second read timeout.)

The fourth route is the one I like best. When someone pastes a post's URL into Mastodon's search box, their server fetches that URL asking for ActivityStreams JSON instead of HTML. A map on the `Accept` header catches that and rewrites the request into the service:

```nginx title="deploy/nginx-markdown-map.conf"
map $http_accept $deallocated_ap {
    default "";
    "~*application/activity\+json" "1";
    "~*application/ld\+json" "1";
}
```

```nginx title="deploy/nginx.conf"
location ~ ^/posts/[^/.]+$ {
    if ($deallocated_ap) { rewrite ^/posts/(.+)$ /ap/note/$1 last; }
    if ($deallocated_md) { rewrite ^ $uri.md last; }
    try_files $uri.html =404;
}
```

So the same URL gives a browser HTML, an agent asking for `text/markdown` the markdown source, and a fediverse server a Note. Every response carries `Vary: Accept` so caches keep the three apart. You can see it yourself:

```bash
curl -s -H 'Accept: application/activity+json' \
  https://deallocatedobjects.com/posts/scheduled-posts-on-a-static-site
```

### WebFinger

Everything starts with WebFinger. When you type `@blog@deallocatedobjects.com` into Mastodon, your server asks `https://deallocatedobjects.com/.well-known/webfinger?resource=acct:blog@deallocatedobjects.com` where the account lives. The answer is small:

```ts title="newsletter/ap.ts"
if (r !== `acct:${USERNAME}@${HOST}` && r !== actorId) return new Response("not found", { status: 404 });
return new Response(JSON.stringify({
  subject: `acct:${USERNAME}@${HOST}`, aliases: [actorId, `${SITE}/`],
  links: [
    { rel: "self", type: AP_TYPE, href: actorId },
    { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: `${SITE}/` },
  ],
}), { headers: {
  "Content-Type": "application/jrd+json; charset=utf-8",
  "Cache-Control": "public, max-age=86400",
  "Access-Control-Allow-Origin": "*",
} });
```

Three details matter here. It accepts the actor URL as a resource too, since servers sometimes look an actor up in reverse. The content type is `application/jrd+json`, WebFinger's own type. And the CORS header lets browser-based clients do the lookup directly. The `self` link with type `application/activity+json` is the line Mastodon is looking for: it says where the actor document is.

### The Actor Document

The actor at `/ap/actor` is the account's profile: name, bio, avatar, header image, and the URLs for everything else. A few fields are worth calling out:

```ts title="newsletter/ap.ts"
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { toot: "http://joinmastodon.org/ns#", discoverable: "toot:discoverable", indexable: "toot:indexable", /* ... */ }],
id: actorId, type: "Service", preferredUsername: USERNAME, name: "Deallocated Objects",
inbox: `${SITE}/ap/inbox`, outbox: `${SITE}/ap/outbox`, followers: `${SITE}/ap/followers`,
endpoints: { sharedInbox: `${SITE}/ap/inbox` },
manuallyApprovesFollowers: false, discoverable: true, indexable: true, attributionDomains: [HOST],
publicKey: { id: keyId, owner: actorId, publicKeyPem: keys.pub },
```

`type: "Service"` marks it as an automated account, so Mastodon shows it with a bot badge. That fits: it's a feed of posts, and replies to it go nowhere. `discoverable` and `indexable` are Mastodon extensions, which is why the `toot` namespace has to be in the context for them to mean anything. `attributionDomains` pairs with a `<meta name="fediverse:creator" content="@blog@deallocatedobjects.com">` tag in every page's head, so when someone shares a post link, Mastodon can show the blog's account on the link preview.

The `publicKey` block is how other servers check my signatures. The service generates a 2048-bit RSA key pair the first time it starts and stores both halves in a `settings` table in its SQLite database. The rule I wrote down next to it is to never regenerate them. Followers' servers cache that public key, and a new one would make every future delivery look forged until they refetched it.

### Signing Requests

Mastodon expects every server-to-server request to carry an HTTP Signature in the older draft-cavage format, using `rsa-sha256`. You build a small string out of chosen headers, sign it with your private key, and put the result in a `Signature` header that names the key:

```ts title="newsletter/ap.ts"
const names = ["(request-target)", "host", "date"];
const lines = [`(request-target): ${method.toLowerCase()} ${u.pathname}${u.search}`, `host: ${u.host}`, `date: ${date}`];
const headers: Record<string, string> = { Host: u.host, Date: date, "User-Agent": UA };
if (body != null) {
  const d = digest(body); // "SHA-256=" + base64 of the body's sha256
  Object.assign(headers, { Digest: d, "Content-Type": AP_TYPE });
  names.push("digest", "content-type"); lines.push(`digest: ${d}`, `content-type: ${AP_TYPE}`);
} else headers.Accept = AP_ACCEPT;
const signature = createSign("sha256").update(lines.join("\n")).sign(keys.priv, "base64");
headers.Signature = `keyId="${keyId}",algorithm="rsa-sha256",headers="${names.join(" ")}",signature="${signature}"`;
```

`(request-target)` is a pseudo-header: the lowercase method, a space, and the path with its query string. Signing it ties the signature to this exact request, so it can't be replayed against another URL. For POSTs I also sign a `Digest` header, which is the SHA-256 of the body, so the body can't be swapped out underneath the signature. Mastodon rejects a POST whose signature doesn't cover the digest.

I sign GETs too. Some servers only hand out actor documents to signed requests, so when the service fetches a follower's profile it signs that fetch and asks for `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, which covers both ways servers like to be asked.

The `keyId` is `https://deallocatedobjects.com/ap/actor#main-key`. A receiving server fetches that, finds the actor document, pulls out `publicKeyPem` and checks the signature.

### Checking Incoming Signatures

The inbox does the same thing in reverse. A POST to `/ap/inbox` has to pass four checks before the service does anything with it:

```ts title="newsletter/ap.ts"
if (!p.signature || !["rsa-sha256", "hs2019", undefined].includes(p.algorithm)) return false;
const theirs = (req.headers.get("digest") ?? "").split(",").map((d) => d.trim()).find((d) => d.toLowerCase().startsWith("sha-256="));
if (!theirs || theirs.slice(8) !== digest(raw).slice(8)) return false;
const date = req.headers.get("date");
if (!date || Math.abs(Date.now() - +new Date(date)) > 3600e3) return false;
const lines = (p.headers ?? "date").split(/\s+/).map((h) => (h === "(request-target)" ? `(request-target): ${req.method.toLowerCase()} ${path}` : `${h}: ${req.headers.get(h) ?? ""}`));
return createVerify("sha256").update(lines.join("\n")).verify(pem, p.signature, "base64");
```

The algorithm has to be one I can check (`hs2019` shows up in the wild even when the key is plain RSA). The `Digest` has to match the raw bytes I received, so I hash the body before parsing it as JSON. The `Date` has to be within an hour of now. And the signature has to verify against the sender's public key, rebuilding the signed string from whatever headers they say they signed, in their order.

That public key comes from fetching the sender's actor, and I cache it in an `ap_actors` table. If verification fails with the cached key, the service fetches the actor once more and tries again before giving up with a 401. That second try handles people who moved servers or whose server rotated keys, without fetching a profile on every single request.

Two smaller things keep the inbox calm. Every activity id gets written to an `ap_seen` table (kept for 14 days), and a repeat gets a 202 and nothing else, since servers do retry. And a `Delete` where the actor is deleting itself, from an actor I've never stored, gets a 202 right away. Account deletions go out widely across the fediverse, the actor is already gone so there's no key to fetch, and there's nothing of theirs here to remove.

### Follow, Undo And Accept

Once a request is verified, the blog only cares about three kinds of activity:

```ts title="newsletter/ap.ts"
if (a.type === "Follow" && object === actorId && remote.inbox) {
  db.run(`insert into ap_followers (actor, inbox, shared_inbox, created_at) values (?, ?, ?, ?)
    on conflict (actor) do update set inbox = excluded.inbox, shared_inbox = excluded.shared_inbox`,
    [remote.actor, remote.inbox, remote.shared_inbox, now()]);
  enqueue([remote.inbox], { "@context": "https://www.w3.org/ns/activitystreams",
    id: `${actorId}#accepts/${crypto.randomUUID()}`, type: "Accept", actor: actorId,
    object: { ...(a.id ? { id: a.id } : {}), type: "Follow", actor: remote.actor, object: actorId } });
} else if (a.type === "Undo" && a.object?.type === "Follow") {
  db.run("delete from ap_followers where actor = ?", [actor]);
} else if (a.type === "Delete" && actor === object) {
  db.run("delete from ap_followers where actor = ?", [actor]); db.run("delete from ap_actors where actor = ?", [actor]);
}
return new Response(null, { status: 202 });
```

A Follow gets stored with both the follower's personal inbox and their server's shared inbox, and then the blog sends back an Accept. The Accept wraps the original Follow, including its `id`. Mastodon matches the Accept to the pending follow request by that id, and until it arrives the follow button stays on "Requested". Since `manuallyApprovesFollowers` is false, the Accept goes out right away.

Unfollowing arrives as an Undo wrapping the old Follow, and it removes the row. Everything else (replies, likes, boosts) gets a 202 and is ignored. A blog that answers replies would need moderation, and this account is a feed.

### Delivering New Posts

Here's where the static site and the service meet. The site build writes `feed.json`, and the service reads that same file from the site's folder on disk. Every five minutes it looks for feed items newer than the moment federation was turned on, and hands each new one to `publish`:

```ts title="newsletter/server.ts"
const apTick = async () => {
  try { for (const item of (await readFeed()).items) if (item.date_published > apSince! && ap.publish(item)) console.log(`ap: published ${item.url}`); }
  catch (e) { console.error(`ap tick: ${(e as Error).message}`); }
};
void apTick(); setInterval(apTick, 5 * 60e3);
```

`publish` records the slug in an `ap_published` table first, so a post goes out once even across restarts. Then it builds a `Create` activity around a Note and queues one copy per inbox, using each follower's shared inbox when they have one. That's what keeps it cheap: a hundred followers on one Mastodon server is one delivery, and that server fans the post out to all of them.

Nothing on my Mac has to be awake for this. A scheduled post goes live when a timer on the server swaps in a pre-built copy of the site, which brings a new `feed.json` with it, and within five minutes the next tick sees the new item.

### Sending Notes Instead Of Articles

ActivityStreams has an `Article` type that sounds like the right fit for a blog post. In Mastodon 4.3 and later, an Article shows up in the timeline as only its title and a link. A Note shows its full HTML content, so I send a Note that reads like a small card: the title in bold, a summary paragraph, then the link.

```ts title="newsletter/ap.ts"
id: item.url, type: "Note", attributedTo: actorId, url: item.url, published: item.date_published,
to: [AS_PUBLIC], cc: [`${SITE}/ap/followers`],
content: `<p><strong>${esc(item.title)}</strong></p><p>${esc(item.summary ?? "")}</p><p><a href="${esc(link)}">${esc(shown)}</a></p>`,
tag: (item.tags ?? []).map((t) => ({ type: "Hashtag", name: `#${t.replace(/[^A-Za-z0-9]/g, "")}`, href: `${SITE}/topics/...` })),
```

The Note's `id` is the post's own URL. That's the same URL the nginx rewrite answers with this Note, so whether someone finds a post in their timeline or by pasting the link into search, their server ends up with the same single object. The post's topics become hashtags that link to the matching topic pages, and the post's link-preview card rides along as an image attachment.

For posts older than the feed, the build also writes `ap-posts.json` with every listed post, and `/ap/note/<slug>` falls back to it. That's how a link to a 2012 post still resolves in Mastodon search. The outbox at `/ap/outbox` returns the 20 newest posts as Creates, so a new follower who opens the profile sees recent posts right away.

### A Queue That Retries

Deliveries go through an `ap_deliveries` table. The payload is stored unsigned and gets signed at the moment it's sent, which matters because the `Date` header is part of the signature: a retry signed hours ago would fail that one-hour freshness check on the other end.

```ts title="newsletter/ap.ts"
const BACKOFF = [1, 8, 40, 120, 360, 720, 1440, 2880]; // minutes
// ...
if (res.status === 501 || (res.status >= 400 && res.status < 500 && ![401, 408, 429].includes(res.status))) {
  db.run("update ap_deliveries set status = 'failed', attempts = attempts + 1, last_error = ? where id = ?", [err, d.id]); return;
}
```

The queue drains eight deliveries at a time, and it runs both right after something is queued and once a minute on a timer. A 2xx marks a delivery done. Most 4xx responses mean the other server will never take it (the account is gone, or the server doesn't want it), so those fail right away. The exceptions are 401, 408 and 429, which are often temporary: a key the other side hasn't refreshed yet, a timeout, or rate limiting. Those, along with 5xx errors and network failures, wait for the next step of the backoff. Eight retries spread from one minute to two days add up to just under four days before a delivery is given up on, which is plenty of room for a server to come back from maintenance.

### Small Extras

NodeInfo at `/.well-known/nodeinfo` points to a short document that says this is a single-user server running software called `deallocatedobjects` with closed registrations. Fediverse crawlers and some instance software read it to learn what they're talking to. The followers collection at `/ap/followers` reports a total and no list, and the following collection reports zero.

If you're building this for your own static site, start from the inbox and the signing code: once those work, the rest is a handful of JSON documents. And to watch it run, follow `@blog@deallocatedobjects.com` and keep an eye on your timeline when the next post goes out.
