Extract parse/serialize into postgres::jsonb, used by user, remote_actor, and postgres-federation. Validate profile fields in update_profile use case (max 4, name≤64, value≤256).
Thoughts
A self-hosted microblogging server with full ActivityPub federation. Write short posts, follow people on Mastodon and other Fediverse servers, and receive their posts in your feed. Built in Rust with a Next.js frontend.
Features
- Short-form posts (thoughts) with replies, boosts, and likes
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as
Noteobjects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync - Remote actor discovery — search by
@user@instancehandle, view full remote profiles (bio, banner, profile fields, posts, followers, following tabs), follow from within the UI - Worker-backed remote caches — remote posts and follower/following lists are fetched by the NATS worker and cached locally; profiles populate on first visit and refresh in the background
- Content negotiation at
GET /users/{username}— serves ActivityPub actor JSON or REST profile based onAcceptheader - Federation moderation — per-instance domain blocking, per-user actor blocking with
Blockactivity delivery, delivery filter excludes blocked actors and blocked-domain inboxes - Async event fan-out via NATS JetStream — notifications and AP delivery run in a separate worker process; pull consumer with 1-hour TTL caching
- JWT authentication (Bearer token) with API key support for third-party clients
- OpenAPI documentation at
/docs(Swagger UI) and/scalar(Scalar) - Full-text search over thoughts and users via PostgreSQL trigram indexes
- Top friends — pin up to 5 users as highlighted contacts
- Home feed, public feed, and per-user thought timelines
- Rate limiting and registration control
Federation
Thoughts implements the ActivityPub protocol, making it compatible with Mastodon, Misskey, Pleroma, and other Fediverse software.
Fediverse endpoints
| Endpoint | Description |
|---|---|
GET /.well-known/webfinger |
WebFinger discovery (?resource=acct:user@host) |
GET /.well-known/nodeinfo |
NodeInfo pointer |
GET /nodeinfo/2.0 |
NodeInfo 2.0 — software metadata |
GET /users/{username} |
Actor profile (content-negotiated: JSON-LD or REST) |
GET /users/{username}/outbox |
Paginated outbox of Note activities |
POST /users/{username}/inbox |
Per-actor inbox |
POST /inbox |
Shared inbox for bulk delivery |
Federation flow
- A remote user follows
@you@yourinstance.com→ Mastodon sends aFollowactivity to/users/you/inbox - Thoughts accepts and delivers an
Acceptback to the remote actor's inbox - When you post, Thoughts fans out a
Create(Note)activity to all remote followers via the NATS worker - Remote posts from people you follow are fetched, cached, and shown in your home feed
Without NATS
Federation still works without NATS — activities are processed in-process synchronously. The worker is required for async fan-out delivery to remote servers at scale. See Environment Variables.
Instance moderation
- Domain blocks — block an entire instance; no activities are delivered to or accepted from blocked domains
- Actor blocks — block individual remote actors; a
Blockactivity is delivered and they are filtered from all feeds
Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
domain — pure types and port trait definitions, no external deps
application — use cases and event processing services (business logic)
api-types — shared REST API request/response DTOs
presentation — Axum HTTP router, OpenAPI spec, composition root for the API process
bootstrap — binary: thoughts (API server)
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
adapters/
auth — JWT issuance and validation, Argon2 password hashing
storage — object storage adapter (local filesystem + S3/MinIO) implementing the MediaStore port
postgres — PostgreSQL repositories for all domain entities
postgres-search — PostgreSQL trigram full-text search
postgres-federation — PostgreSQL-backed federation repository
k-ap (external) — generic AP protocol layer (ActivityPubService, actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, HTTP signatures)
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
nats — NATS transport implementing Transport + MessageSource ports
event-payload — shared event serialization DTOs
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
The domain and application crates have zero concrete adapter dependencies. All I/O goes through &dyn Port traits, keeping business logic fully testable with in-memory fakes.
Media Storage
Users can upload avatar and banner images via PUT /users/me/avatar and PUT /users/me/banner (multipart/form-data). Uploaded images are served at GET /media/*path (public, no auth required). Set STORAGE_BACKEND to configure the backend.
Prerequisites
- Rust stable (1.80+)
- PostgreSQL 15+
- NATS with JetStream (optional — see Without NATS)
Environment Variables
Copy .env.example to .env and fill in your values.
Required
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
JWT_SECRET |
Secret used to sign JWT tokens — use a long random string in production |
BASE_URL |
Public URL of the API server — used for ActivityPub actor URLs and canonical links |
Optional
| Variable | Default | Description |
|---|---|---|
HOST |
0.0.0.0 |
Interface to bind |
PORT |
3000 |
Port to listen on |
NATS_URL |
— | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
CORS_ORIGINS |
* |
Comma-separated allowed origins for CORS, e.g. https://app.example.com |
RATE_LIMIT |
disabled | Max requests per minute per IP |
ALLOW_REGISTRATION |
true |
Set to false to close sign-ups |
RUST_ENV |
development |
Set to production to disable ActivityPub debug logging |
RUST_LOG |
info |
Log level filter (error, warn, info, debug, trace) |
STORAGE_BACKEND |
local |
Storage backend: local or s3 |
STORAGE_PATH |
— | Local filesystem path for media (required when STORAGE_BACKEND=local) |
STORAGE_PREFIX |
— | Optional key prefix for all stored objects |
S3_ENDPOINT |
— | S3/MinIO endpoint URL (required when STORAGE_BACKEND=s3) |
S3_ACCESS_KEY_ID |
— | S3 access key (required when STORAGE_BACKEND=s3) |
S3_SECRET_ACCESS_KEY |
— | S3 secret key (required when STORAGE_BACKEND=s3) |
S3_BUCKET |
— | S3 bucket name (required when STORAGE_BACKEND=s3) |
S3_REGION |
us-east-1 |
S3 region |
UPLOAD_MAX_BYTES |
5242880 |
Max upload size in bytes (default 5 MiB) |
UPLOAD_ALLOWED_TYPES |
image/jpeg,image/png,image/gif,image/webp,image/avif |
Comma-separated allowed MIME types |
Run
# API server (runs migrations automatically on startup)
cargo run -p bootstrap
# Event worker — federation fan-out and notifications (separate terminal)
cargo run -p worker
Both processes share the same PostgreSQL database. The worker is optional but required for ActivityPub delivery to remote servers.
Test
# Unit tests — no database required
cargo test -p application
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
cargo test --workspace
The application crate contains unit tests for all event services and use cases backed by in-memory fakes from domain's test-helpers feature. These are the fastest feedback loop for business logic.
API
All REST endpoints are under the root path. Authentication uses Authorization: Bearer <token> obtained from POST /auth/login.
Interactive API documentation is available at runtime:
- Swagger UI —
http://localhost:8000/docs - Scalar —
http://localhost:8000/scalar
Frontend
The Next.js frontend lives in thoughts-frontend/. It requires two environment variables:
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests
cd thoughts-frontend
bun install
bun run dev # http://localhost:3000
Docker
The backend image contains both thoughts (API server) and thoughts-worker (event processor). Run them as separate containers:
docker build -t thoughts .
# API server
docker run -p 8000:8000 \
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
-e JWT_SECRET=change-me \
-e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \
-e STORAGE_BACKEND=local \
-e STORAGE_PATH=/data/media \
-v media_vol:/data/media \
thoughts
# Event worker (same image, different entrypoint)
docker run \
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
-e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \
--entrypoint ./thoughts-worker \
thoughts
# Frontend
docker build -t thoughts-frontend \
--build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \
--build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \
thoughts-frontend/
docker run -p 3000:3000 thoughts-frontend
Local development stack
compose.yml spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
docker compose up
Services:
| Service | Port | Description |
|---|---|---|
postgres |
5432 | PostgreSQL 16 |
nats |
4222 / 8222 | NATS with JetStream; 8222 is the monitoring endpoint |
api |
8000 | Thoughts API server |
worker |
— | Event worker (no exposed port) |
frontend |
3000 | Next.js frontend |
Contributing
Contributions are welcome. A few guidelines:
- Run tests before opening a PR. At minimum:
cargo test -p application(no database needed). For adapter changes:cargo test --workspacewith a live database. - Keep the hexagonal boundary.
domainandapplicationmust not import any adapter crate. Use&dyn Porttraits for all I/O. - No ORM. The project uses raw
sqlx. Keep it that way. - ActivityPub changes — test against a live Mastodon instance if possible, or use the AP debug logs (
RUST_ENV=development). - Small, focused PRs are easier to review than large ones.
For significant changes, open an issue first to discuss the approach.
License
MIT License. See LICENSE.