A federated blog platform built on ActivityPub.
Other options: Docker Compose Β· Docker Β· Full deploy guide
x-log is an open-source, Bun + TypeScript powered blog platform that federates with the Fediverse using ActivityPub. Readers on Mastodon, Elk, Soapbox, and compatible clients can search, read, like, and follow x-log profiles and posts directly from their clients.
- Runtime: Bun
- Language: TypeScript
- API: Hono
- Validation: Zod + zod-to-openapi
- Database: PostgreSQL + Kysely
- Frontend: Next.js (App Router) + TailwindCSS
- Monorepo: Turborepo
- Queue: Redis + Worker (Bun)
- Deployment: Docker Compose
- Development guide β local setup, monorepo, tests, PRs
- Contributing β short checklist for contributors
- Operator guides under
docs/operators/: - Deploy β production hosting
- Bun >= 1.0.0
- Docker and Docker Compose
- PostgreSQL 16
- Redis 7
Full walkthrough: docs/development.md.
bun install
cp .env.example .env # SESSION_SECRET, DATABASE_URL, REDIS_URL, OIDC placeholders
make setup && make dev # Compose + watch, or: bun run migrate && bun run dev
cd apps/api && bun run init-local-userbun run type-check && bun run test
./scripts/smoke-api.sh # API must be runningmake dev- Start development environment with Bun watch modemake dev-watch- Start development environment with Docker Compose watchmake dev-stop- Stop development environmentmake migrate- Run database migrationsmake logs- View logs from all servicesmake clean- Remove containers, volumes, and imagesmake help- Show all available commands
The repo now includes an Expo mobile app in apps/mobile.
Useful commands:
bun run mobile- Start the Expo development serverbun run mobile:ios- Run the mobile app on iOSbun run mobile:android- Run the mobile app on Android
Environment variables:
EXPO_PUBLIC_API_BASE_URL- Direct backend API URL for native clients, for examplehttp://localhost:8080/apiEXPO_PUBLIC_EAS_PROJECT_ID- EAS project ID used for release builds
When running make dev, the following services will be available:
- API server on http://localhost:8080 (internal, not exposed in production)
- Web server on http://localhost:3000 (public-facing)
- Worker service (background jobs)
- PostgreSQL on localhost:5432
- Redis on localhost:6379
All API requests are proxied through Next.js SSR routes. In production, only the Next.js server is exposed to the internet, and it internally communicates with the backend API server.
Environment Variables:
BACKEND_API_URL- Internal URL to the backend API server (defaults toNEXT_PUBLIC_API_URLorhttp://localhost:8080)NEXT_PUBLIC_API_URL- Fallback for backend API URL (used in development)
API Flow:
- Frontend makes requests to
/api/*(Next.js routes) - Next.js API routes proxy requests to the backend API server
- Backend API server processes requests and returns responses
- Next.js forwards responses back to the frontend
This architecture ensures:
- Backend API is not directly exposed to the internet
- Session cookies are properly forwarded
- CORS issues are avoided
- Single entry point for all API requests
x-log/
βββ apps/
β βββ api/ # Hono API server
β βββ mobile/ # Expo + React Native mobile app
β βββ web/ # Next.js frontend
β βββ worker/ # Background job worker
βββ packages/
β βββ db/ # Kysely database client and schema
β βββ validation/ # Zod schemas
β βββ ap/ # ActivityPub helpers
β βββ snowflake/ # Snowflake ID generator
β βββ config/ # Shared configuration
β βββ ui/ # Shared UI components
β βββ types/ # Shared TypeScript types
βββ infra/
βββ compose/ # Docker Compose configuration
Off by default. Enable the analytics feature flag (admin UI or FEATURE_ANALYTICS=true).
- Beacon: post pages and profiles (
/u/[username]) callPOST /api/analytics/collectwhen enabled - Stores path, post_id, referrer, UA, hashed IP (raw IP only if
ANALYTICS_STORE_RAW_IP=true) - Respects DNT / Sec-GPC when
ANALYTICS_RESPECT_DNT=true(default) - Summary:
GET /api/analytics/summary(auth; admin = all posts, author = own) includes post titles + privacy meta - Dashboard:
/analytics(any signed-in user) and Settings β Analytics (admin) - Retention: worker purges rows older than
ANALYTICS_RETENTION_DAYS(default 90)
# API
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318
OTEL_SERVICE_NAME=x-log-api
# Worker (same collector, different service name)
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318
OTEL_SERVICE_NAME=x-log-workerProcesses dynamically load OTEL only when enabled. Custom spans include federation.deliver, ap.verify_signature, ap.key_fetch, mcp.tool_call, analytics.collect. See deploy/DEPLOY.md for a sample Jaeger collector.
Use your PostHog project (not x-log SaaS telemetry):
# Web client
NEXT_PUBLIC_POSTHOG_ENABLED=true
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Optional server events (API + worker)
POSTHOG_SERVER_ENABLED=true
POSTHOG_KEY=phc_...
POSTHOG_HOST=https://us.i.posthog.comServer events: post_published, follow_received, mcp_tool_called (name only), federation_delivery_failed. Distinct id = primary author or instance:{domain}. No PostHog network calls when disabled; first-party visitor IPs are never forwarded.
x-log is solo-first: one domain, one primary author (site owner).
| Mode | When | Behavior |
|---|---|---|
| solo | β€1 local user (default) | Landing can use primary profile; MCP key acts as primary; registrations closed |
| multi | 2+ local users | Multiple authors, each a separate ActivityPub actor; still one operator/admin |
Stored as instance_settings.primary_user_id (backfilled to oldest admin on migrate).
Used for:
- Profile-as-landing (
use_profile_as_landing) - Instance βfollow remoteβ as the site account
- MCP write tools when
MCP_ACTOR_USERNAMEis unset - Public
/api/public/instanceβprimary_profile - NodeInfo metadata contact account
Set via Settings β Federation β Primary author (admin UI), or
PATCH /api/settings with { "primary_user_id": "<uuid>" }.
| Capability | admin | author |
|---|---|---|
| Publish as self | β | β |
| Instance settings / primary user | β | |
| Invite users (Phase 3+) | β |
Not a social network: no local timeline of strangers; multi-user is for small teams / invite-only blogs.
Under Settings β Federation β Operator tools:
- 24h delivery stats (sent / failed / pending)
- Recent failures with Retry / Retry all
- Domain blocklist (inbox 403 + skip outbound)
Blocked domains apply to both shared and per-user inboxes.
| Driver | Env | Notes |
|---|---|---|
local (default) |
β | Files in uploads/, served at /api/media/β¦ |
s3 |
MEDIA_S3_* |
R2, AWS S3, or MinIO; set MEDIA_S3_PUBLIC_URL for CDN |
See deploy/DEPLOY.md for full env list and migrate script.
| Feature | How |
|---|---|
| Schedule posts | Enable feature flag scheduled_posts. My Posts β clock icon β pick time. Worker publishes when due. |
| Import Markdown | My Posts β Import MD. Paste one or more posts (--- separated). Creates drafts. |
| Notifications | Nav bell β follows & likes. Optional email when SMTP + user email set. |
Flags: Settings β Features, or FEATURE_SCHEDULED_POSTS=true.
Default: invite-only. Open registration is opt-in.
| Mode | How |
|---|---|
| Invite-only (default) | Admin β Settings β Users β Create invite β share /invite/<token> |
| Open registration | Settings β Users β Open public registration, or OPEN_REGISTRATIONS=true β /register |
- Signup always creates author (never admin)
- Cap:
MAX_LOCAL_AUTHORS(default 10 active admin+author accounts) - Soft-deactivate users from Users tab (blocks login)
- Rate limits: register (5/hr/IP), invite accept (10/hr/IP)
- Optional email verification when SMTP is configured
Authors cannot change instance domain / federation / primary author. Post CRUD is scoped to author_id (admins may edit any).
x-log exposes a remote Model Context Protocol server so agents (Cursor, Claude, etc.) can read public content and create/publish posts as a configured local author.
| Key type | Who it acts as | How |
|---|---|---|
Instance MCP_API_KEY |
Primary author (or MCP_ACTOR_USERNAME) |
Env on the API |
Per-user key xlog_mcp_β¦ |
That user only | Profile β MCP API keys |
Per-user keys cannot write as another author.
# Generate a dedicated key (do not reuse SESSION_SECRET in production)
openssl rand -hex 32Set in .env:
MCP_API_KEY=<your-key>
# Optional: username write tools act as (default: primary admin)
MCP_ACTOR_USERNAME=adminRestart the API. GET /health reports mcp.enabled and paths.
| URL | Protocol |
|---|---|
https://{INSTANCE_DOMAIN}/api/mcp |
Streamable HTTP (via Next proxy β API /mcp) β preferred for Cursor/Claude |
https://{INSTANCE_DOMAIN}/mcp |
Streamable HTTP direct to API (if API is public) |
https://{INSTANCE_DOMAIN}/api/mcp/jsonrpc |
Legacy JSON-RPC 2.0 POST |
https://{INSTANCE_DOMAIN}/mcp/jsonrpc |
Legacy JSON-RPC direct |
Auth: Authorization: Bearer <MCP_API_KEY>
{
"mcpServers": {
"x-log": {
"url": "https://YOUR_DOMAIN/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_MCP_API_KEY"
}
}
}
}Read: get_posts, get_post, get_profile, search, get_instance_info
Write (as MCP actor): create_post, update_post, publish_post, delete_post
Write tools use MCP_ACTOR_USERNAME (or the primary admin). The Bearer key is equivalent to that author for posting β protect it.
curl -s https://YOUR_DOMAIN/api/mcp/jsonrpc \
-H "Authorization: Bearer $MCP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'AGPL-3.0 (to be confirmed)
Tagging a commit with mobile-v<version> triggers .github/workflows/mobile-release.yml.
Example:
git tag mobile-v1.0.0
git push origin mobile-v1.0.0Required GitHub secrets:
EXPO_TOKENEXPO_PUBLIC_EAS_PROJECT_ID