Wildebeest: self-host Mastodon trên Cloudflare stack — federated trên Workers

Wildebeest = ActivityPub server tương thích Mastodon, chạy entirely trên Workers + D1 + R2 + KV. 1 Worker thay 10 service Mastodon truyền thống. $0-5/tháng vs $50-200 VPS.

· 8 phút đọc

TL;DR

  • WildebeestActivityPub server tương thích Mastodon, chạy hoàn toàn trên Cloudflare stack. 1 Worker thay cho stack truyền thống PostgreSQL + Redis + Sidekiq + Puma + Nginx + Elasticsearch (~10 service).
  • Storage: D1 cho post/follow/account, R2 cho avatar/media, KV cho session + remote actor cache, Queues cho federation delivery, Images (optional) cho resize avatar.
  • Cost cho personal instance (~10-50 follower): $0-5/tháng trên Cloudflare free tier vs $50-200/tháng cho VPS (Hetzner CX21 + managed Postgres + Redis + S3 + monitoring).
  • Federation security là threat model lớn nhất: HTTP Signature verification, blocked instance list (blocklist công khai từ Mastodon admin community), rate limit per remote actor, anti-spam cho mention/DM từ instance lạ.
  • Wildebeest repo status: Cloudflare đã chuyển sang maintenance mode từ giữa 2024, nhưng codebase production-ready và còn nhiều fork active. Đáng dùng như reference architecture cho ai muốn build ActivityPub server hoặc Mastodon-compatible app trên Workers.
  • Đừng chạy Wildebeest cho instance public với > 100 user. Federation queue throughput của Workers + Queues hợp cho personal-to-small (≤ 100 active user). Trên đó nên migrate sang Mastodon truyền thống hoặc GoToSocial.

Vì sao Mastodon truyền thống đắt một cách kỳ lạ

Mastodon spec mở (ActivityPub), code Ruby on Rails, deploy theo 12-factor. Setup minimal cho personal instance:

ServiceMục đíchCost tối thiểu
Puma/Rails webServe HTTP API + UI1 VPS 2GB ($10)
SidekiqQueue worker cho federation1 VPS 2GB ($10)
PostgreSQLAccount, status, follow, notificationManaged DB ($15-30)
RedisCache + queue backendManaged ($10)
Elasticsearch (optional)Full-text search2GB VPS ($10)
S3-compatibleMedia storage$5-10/tháng
NginxReverse proxy + cachePhần của VPS
MonitoringSentry/Grafana$0-10
Total$50-80/tháng

Đó là personal instance. Multi-user instance 100-1000 user nhanh chóng vào $200-500/tháng. Tôi từng host instance cho team 30 người trên Hetzner — sau 4 tháng vận hành (DB migration, Sidekiq queue backup, Elasticsearch reindex, certbot renewal), tôi tính time ops/tháng = ~8 giờ. Đó là 8 giờ tôi không build sản phẩm.

Wildebeest ra đời 2022, release v1.0 đầu 2023 như Cloudflare’s answer: ActivityPub server entirely trên Workers stack, không VPS, không Sidekiq, không Postgres. Sau 2 năm chạy cho social.khavan.dev, viết bài này.

Stack: 1 Worker thay 10 service

Component truyền thốngWildebeest equivalent
Puma web serverWorker wildebeest-web
Sidekiq workerCloudflare Queues consumer
PostgreSQLD1
RedisKV (cache + lookup) + Queues (job)
Elasticsearch(chưa có — search là pain point)
S3 cho mediaR2
Nginx + certWorkers Routes + Cloudflare TLS
Image resize serviceCloudflare Images
MailerEmail Routing send / MailChannels
MonitoringWorkers Analytics Engine + Logpush
// wrangler.jsonc
{
  "name": "wildebeest",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-01",
  "compatibility_flags": ["nodejs_compat"],
  "vars": {
    "DOMAIN": "social.khavan.dev",
    "INSTANCE_TITLE": "khavan social",
    "INSTANCE_DESC": "Personal Mastodon instance"
  },
  "d1_databases": [
    { "binding": "DB", "database_name": "wildebeest", "database_id": "..." }
  ],
  "r2_buckets": [
    { "binding": "MEDIA", "bucket_name": "wildebeest-media" }
  ],
  "kv_namespaces": [
    { "binding": "CACHE", "id": "..." }
  ],
  "queues": {
    "producers": [
      { "binding": "DELIVERY_QUEUE", "queue": "wildebeest-deliveries" }
    ],
    "consumers": [
      { "queue": "wildebeest-deliveries", "max_batch_size": 25 }
    ]
  },
  "secrets": [
    "INSTANCE_PRIVATE_KEY"
  ]
}

Một wrangler deploy lên 1 Worker thay 10 service. Operationally khác biệt hoàn toàn.

D1 schema: ActivityPub object trong SQLite

ActivityPub objects (Person, Note, Follow, Like, Announce) đều có URI duy nhất. D1 schema:

CREATE TABLE actors (
  id              TEXT PRIMARY KEY,           -- full URI: https://...
  preferred_username TEXT NOT NULL,
  type            TEXT NOT NULL,              -- Person|Service|Application
  is_local        INTEGER NOT NULL,           -- 0 nếu remote
  inbox_url       TEXT NOT NULL,
  outbox_url      TEXT,
  public_key      TEXT NOT NULL,
  private_key     TEXT,                       -- chỉ local actor
  display_name    TEXT,
  summary         TEXT,
  avatar_r2_key   TEXT,
  created_at      INTEGER NOT NULL,
  cached_at       INTEGER NOT NULL            -- TTL cho remote actor
);

CREATE TABLE objects (
  id              TEXT PRIMARY KEY,           -- URI
  type            TEXT NOT NULL,              -- Note|Article|...
  attributed_to   TEXT NOT NULL REFERENCES actors(id),
  content         TEXT,
  in_reply_to     TEXT,
  published_at    INTEGER NOT NULL,
  visibility      TEXT NOT NULL,              -- public|unlisted|private|direct
  attachments     TEXT,                       -- JSON array R2 keys
  created_at      INTEGER NOT NULL
);

CREATE INDEX objects_attributed ON objects(attributed_to, published_at DESC);

CREATE TABLE follows (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  follower_id     TEXT NOT NULL REFERENCES actors(id),
  followee_id     TEXT NOT NULL REFERENCES actors(id),
  accepted        INTEGER NOT NULL,
  created_at      INTEGER NOT NULL,
  UNIQUE(follower_id, followee_id)
);

CREATE TABLE deliveries (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  object_id       TEXT NOT NULL,
  to_inbox        TEXT NOT NULL,
  status          TEXT NOT NULL,              -- pending|delivered|failed
  attempts        INTEGER DEFAULT 0,
  last_error      TEXT,
  created_at      INTEGER NOT NULL
);

CREATE INDEX deliveries_pending ON deliveries(status, attempts, created_at);

Vài quyết định design quan trọng:

  1. Remote actor cached trong actors table với cached_at TTL ~24h. Khi nhận activity từ @alice@mastodon.social, Wildebeest fetch actor profile rồi cache. Tránh refetch mỗi activity.
  2. objects không có FTS index trong Wildebeest stock. Search là pain point — Mastodon truyền thống dùng Elasticsearch, D1 chưa có FTS5 với tiếng Việt/CJK tốt.
  3. deliveries table track per-recipient. Một post public với 500 follower có 500 row delivery, mỗi row retry độc lập. Queue consumer pick row, gửi, update status.

HTTP Signatures: federation security cốt lõi

ActivityPub federation dùng HTTP Signatures — mọi inbox POST từ instance khác phải có header Signature ký bằng private key của actor gửi. Verify bằng public key fetch từ actor’s profile.

// src/federation/verify.ts
async function verifySignature(
  request: Request,
  env: Env
): Promise<{ actor: string } | null> {
  const sigHeader = request.headers.get("Signature");
  if (!sigHeader) return null;

  // Parse signature header
  const params = parseSignatureHeader(sigHeader);
  const keyId = params.keyId;          // URI tới public key
  const algorithm = params.algorithm;   // rsa-sha256
  const headers = params.headers;       // signed headers list
  const signature = params.signature;   // base64

  // Fetch actor's public key (qua cache KV)
  const cacheKey = `actor:${keyId}`;
  let publicKeyPem = await env.CACHE.get(cacheKey);
  if (!publicKeyPem) {
    const actor = await fetchActor(keyId);
    publicKeyPem = actor.publicKey.publicKeyPem;
    await env.CACHE.put(cacheKey, publicKeyPem, { expirationTtl: 86400 });
  }

  // Reconstruct signing string
  const url = new URL(request.url);
  const signingString = headers
    .split(" ")
    .map((h) => {
      if (h === "(request-target)") {
        return `(request-target): ${request.method.toLowerCase()} ${url.pathname}`;
      }
      return `${h}: ${request.headers.get(h)}`;
    })
    .join("\n");

  // Verify với WebCrypto
  const publicKey = await crypto.subtle.importKey(
    "spki",
    pemToArrayBuffer(publicKeyPem),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["verify"]
  );

  const valid = await crypto.subtle.verify(
    "RSASSA-PKCS1-v1_5",
    publicKey,
    base64ToArrayBuffer(signature),
    new TextEncoder().encode(signingString)
  );

  if (!valid) return null;
  return { actor: keyId.replace(/#.*$/, "") };
}

Quan trọng:

  1. Verify signature TRƯỚC khi parse activity body — nếu fail, return 401 ngay, không tốn CPU parse JSON-LD.
  2. Cache public key trong KV với TTL 24h — refetch mọi request là DoS chính mình.
  3. Date header check: spec yêu cầu clock skew < 30s. Nếu instance gửi có clock lệch, reject. Tránh replay attack.

Wildebeest stock code đã handle 3 điểm này — đó là một trong những phần phức tạp nhất implement đúng. Trước Wildebeest, các project ActivityPub trên serverless thường fail ở đây.

Blocked instance: defense against bad federation

Federation cũng có dark side — instance spam, instance harassment-prone, instance bị compromise. Mastodon admin community maintain public blocklist cho instance toxic. Wildebeest cho phép import blocklist:

CREATE TABLE blocked_instances (
  domain          TEXT PRIMARY KEY,
  severity        TEXT NOT NULL,              -- silence|suspend
  reason          TEXT,
  created_at      INTEGER NOT NULL
);
// src/middleware/blocklist.ts
async function checkBlocked(env: Env, actorUri: string): Promise<boolean> {
  const domain = new URL(actorUri).hostname;
  const result = await env.DB.prepare(
    "SELECT severity FROM blocked_instances WHERE domain = ? OR ? LIKE '%.' || domain"
  )
    .bind(domain, domain)
    .first();
  return result?.severity === "suspend";
}

Subdomain match '%.' || domain quan trọng — block evil.tld thì cũng block node1.evil.tld. Operate silence (cho activity vào nhưng không hiện public timeline) hoặc suspend (drop hoàn toàn).

Import blocklist mỗi tuần qua Scheduled Worker:

export default {
  async scheduled(event, env, ctx) {
    const res = await fetch("https://raw.githubusercontent.com/.../tier0.json");
    const list = await res.json();
    const stmts = list.map((entry) =>
      env.DB.prepare(
        "INSERT OR REPLACE INTO blocked_instances (domain, severity, reason, created_at) VALUES (?, ?, ?, ?)"
      ).bind(entry.domain, entry.severity, entry.reason, Date.now())
    );
    await env.DB.batch(stmts);
  },
};

Rate limit per remote actor: anti-DDoS qua federation

Một instance malicious có thể flood inbox của bạn với 10K Follow request/giây. Without rate limit, D1 write quota cháy trong vài phút. Defense: rate limit per remote actor URI trong KV với sliding window.

async function checkRateLimit(
  env: Env,
  actorUri: string
): Promise<boolean> {
  const key = `ratelimit:inbox:${actorUri}`;
  const count = parseInt((await env.CACHE.get(key)) ?? "0");

  if (count >= 60) {
    return false; // 60 request/phút per actor
  }

  await env.CACHE.put(key, (count + 1).toString(), {
    expirationTtl: 60,
  });
  return true;
}

Limit 60/phút là conservative cho normal actor. Nếu cần dynamic limit (instance lớn như mastodon.social legitimately gửi nhiều hơn), maintain whitelist.

Federation delivery qua Cloudflare Queues

Khi user local post, Wildebeest fan-out tới mọi follower’s inbox. Follower có thể ở 50 instance khác nhau. Network sync impossible (timeout 30s), nên dùng Queue:

// src/outbox.ts
async function deliverNote(env: Env, note: Note) {
  // Lấy danh sách inbox unique từ follower
  const followers = await env.DB.prepare(
    `SELECT DISTINCT a.inbox_url
     FROM follows f
     JOIN actors a ON f.follower_id = a.id
     WHERE f.followee_id = ? AND a.is_local = 0 AND f.accepted = 1`
  )
    .bind(note.attributedTo)
    .all<{ inbox_url: string }>();

  // Enqueue delivery job per inbox
  const messages = followers.results.map((row) => ({
    body: {
      objectId: note.id,
      toInbox: row.inbox_url,
    },
  }));

  // Batch send tới queue
  for (let i = 0; i < messages.length; i += 100) {
    await env.DELIVERY_QUEUE.sendBatch(messages.slice(i, i + 100));
  }
}

Queue consumer:

export default {
  async queue(batch: MessageBatch, env: Env) {
    for (const msg of batch.messages) {
      const { objectId, toInbox } = msg.body;
      try {
        const note = await loadObject(env, objectId);
        await postToInbox(env, toInbox, note);
        msg.ack();
      } catch (err) {
        if (msg.attempts < 5) {
          msg.retry({ delaySeconds: Math.pow(2, msg.attempts) * 60 });
        } else {
          await markDeliveryFailed(env, objectId, toInbox, err.message);
          msg.ack();
        }
      }
    }
  },
};

Exponential backoff: 60s, 120s, 240s, 480s, 960s. Sau 5 lần, mark failed và bỏ. Tránh queue stuck mãi vì instance dead.

Throughput Cloudflare Queues: ~5.000 message/giây mặc định. Cho instance personal-to-small (≤ 100 user), throughput dư dả. Cho instance 10K user post 100 message/giây, mỗi message fan-out 500 follower = 50K message/giây → vượt quota, cần request raise.

Cost thực tế: $0-5/tháng cho personal instance

Cho social.khavan.dev (1 user, 50 follower remote, ~5 post/ngày):

ResourceUsage thángCost
Worker request~50KFree tier
D1 row read~200KFree tier (5M/ngày)
D1 row write~10KFree tier (100K/ngày)
R2 storage200MB media$0.003
R2 Class A op~1KFree tier (1M)
KV read~30KFree tier (100K/ngày)
Queue message~5KFree tier
Bandwidth~2GBFree tier
Total~$0.01

Effectively free cho personal instance. Cho team 30 user, 1.000 follower remote, ~500 post/ngày:

ResourceUsage thángCost
Worker request~5M$0.50 (vượt free)
D1 row read~50M$0.45
D1 row write~5M$5
R2 storage20GB media$0.30
Queue message~5M$2
Total~$8-10

So với Mastodon truyền thống team 30 user trên Hetzner: VPS CX31 ($16) + managed Postgres ($30) + S3 ($5) + monitoring ($5) = $56/tháng + ~8h ops/tháng. Wildebeest team 30 user: $10 + ~1h ops/tháng (chỉ là D1 migration thi thoảng).

Maintenance mode: Wildebeest còn nên dùng?

Cloudflare đã giảm investment vào Wildebeest từ giữa 2024 — repo về maintenance mode, không nhận feature mới, chỉ security fix. Tại sao? Có lẽ:

  1. Mastodon network growth slowdown sau spike 2022 (Twitter migration). Demand giảm.
  2. GoToSocial (Go-based, single binary, ~50MB RAM) trở thành alternative tốt cho personal — không cần Workers.
  3. ActivityPub spec stability: ít breaking change → bảo trì code cần thiết ít.

Nên dùng Wildebeest 2026 không?

  • Personal instance: YES, nó stable, code battle-tested, cost gần zero. Tự lo upgrade Cloudflare runtime (compat date) là việc nhẹ.
  • Reference architecture cho ActivityPub trên Workers: YES, code clean, học HTTP Signatures + federation pattern.
  • Production multi-tenant (100+ user): chỉ khi team bạn comfortable maintain fork. Wait time fix bug từ upstream sẽ chậm.
  • Public open registration: NO. Spam abuse handling thiếu. GoToSocial hoặc Mastodon chính thống tốt hơn.

Cạm bẫy thường gặp

1. WebFinger endpoint phải serve /.well-known/webfinger với CORS đúng. Mastodon client query qua đây để discover. Setup Worker route đúng cho path.

2. RSA key cho actor sinh một lần và lưu D1. Nếu lost private key, mọi follow remote phải re-establish.

3. D1 free tier 100K row write/ngày. Một post viral 5K boost = 5K delivery row write. Vượt sang paid sớm.

4. instance.actor cần tồn tại trước khi accept first follow. Instance setup script phải tạo system actor đầu tiên.

5. Media R2 phải có CORS header cho cross-instance. Mastodon client load avatar từ r2-domain.dev — fail nếu thiếu Access-Control-Allow-Origin: *.

6. Clock skew trên Cloudflare edge < 1s. Nhưng instance lạ có thể có clock lệch 60s+. Reject by spec sẽ break federation. Wildebeest implement tolerance 1h cho real-world compat.

Vận hành — checklist sau 2 năm

  • Instance actor + private key tạo và lưu D1 trước khi go-live
  • WebFinger endpoint serve đúng và CORS open
  • HTTP Signature verify cho mọi inbox POST, reject < 401 nếu fail
  • Public key của remote actor cache KV 24h
  • Rate limit per remote actor URI: 60 req/phút mặc định
  • Blocklist import từ public list mỗi tuần qua Scheduled Worker
  • Queue retry exponential backoff, max 5 attempts
  • Failed delivery sau 5 attempts vào table audit, không spam queue
  • D1 backup mỗi tuần qua wrangler d1 export
  • R2 media CORS allow *, content-type chính xác
  • Cloudflare Images cho avatar resize (optional) thay vì serve raw
  • Compat date update mỗi quý, test trên staging trước
  • Monitor queue lag — nếu > 5 phút, alert
  • DR plan: D1 restore + R2 sync trong 2h
  • Document fork patch của bạn trong commit message rõ ràng

Bottom line

Wildebeest là one of the cleanest implementation của ActivityPub server tôi từng đọc. Federation pattern, HTTP Signatures, queue-based delivery — toàn bộ map elegant vào Workers stack. Personal instance gần như free, team 30 user ~$10/tháng + 1h ops/tháng vs $56/tháng + 8h ops cho Mastodon truyền thống.

Trade-off là maintenance mode từ Cloudflare. Bạn phải comfortable maintain fork riêng nếu cần feature mới. Cho mục đích personal-to-small (≤ 100 user) + ai muốn học federation architecture, Wildebeest 2026 vẫn là lựa chọn xuất sắc. Public open instance hoặc scale ngàn user thì stick với Mastodon chính thống.

Tham chiếu