TL;DR
- Wildebeest là ActivityPub 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:
| Service | Mục đích | Cost tối thiểu |
|---|---|---|
| Puma/Rails web | Serve HTTP API + UI | 1 VPS 2GB ($10) |
| Sidekiq | Queue worker cho federation | 1 VPS 2GB ($10) |
| PostgreSQL | Account, status, follow, notification | Managed DB ($15-30) |
| Redis | Cache + queue backend | Managed ($10) |
| Elasticsearch (optional) | Full-text search | 2GB VPS ($10) |
| S3-compatible | Media storage | $5-10/tháng |
| Nginx | Reverse proxy + cache | Phần của VPS |
| Monitoring | Sentry/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ống | Wildebeest equivalent |
|---|---|
| Puma web server | Worker wildebeest-web |
| Sidekiq worker | Cloudflare Queues consumer |
| PostgreSQL | D1 |
| Redis | KV (cache + lookup) + Queues (job) |
| Elasticsearch | (chưa có — search là pain point) |
| S3 cho media | R2 |
| Nginx + cert | Workers Routes + Cloudflare TLS |
| Image resize service | Cloudflare Images |
| Mailer | Email Routing send / MailChannels |
| Monitoring | Workers 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:
- Remote actor cached trong
actorstable vớicached_atTTL ~24h. Khi nhận activity từ@alice@mastodon.social, Wildebeest fetch actor profile rồi cache. Tránh refetch mỗi activity. objectskhô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.deliveriestable 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:
- Verify signature TRƯỚC khi parse activity body — nếu fail, return 401 ngay, không tốn CPU parse JSON-LD.
- Cache public key trong KV với TTL 24h — refetch mọi request là DoS chính mình.
Dateheader 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):
| Resource | Usage tháng | Cost |
|---|---|---|
| Worker request | ~50K | Free tier |
| D1 row read | ~200K | Free tier (5M/ngày) |
| D1 row write | ~10K | Free tier (100K/ngày) |
| R2 storage | 200MB media | $0.003 |
| R2 Class A op | ~1K | Free tier (1M) |
| KV read | ~30K | Free tier (100K/ngày) |
| Queue message | ~5K | Free tier |
| Bandwidth | ~2GB | Free tier |
| Total | ~$0.01 |
Effectively free cho personal instance. Cho team 30 user, 1.000 follower remote, ~500 post/ngày:
| Resource | Usage tháng | Cost |
|---|---|---|
| Worker request | ~5M | $0.50 (vượt free) |
| D1 row read | ~50M | $0.45 |
| D1 row write | ~5M | $5 |
| R2 storage | 20GB 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ẽ:
- Mastodon network growth slowdown sau spike 2022 (Twitter migration). Demand giảm.
- GoToSocial (Go-based, single binary, ~50MB RAM) trở thành alternative tốt cho personal — không cần Workers.
- 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.