A single-binary, SQLite-backed Rust server for the fediverse. Speaks the Mastodon Client API well enough that Phanpy, Ivory, Elk, Tusky, and Tuba work against it without modification.
Live instance: social.reviewcommit.com
One binary. One config file. One SQLite database. No Redis. No sidecars. No signup flow.
Designed for a solo operator running a few dozen personas under one domain. Federation volume in the thousands of activities per day, not millions. Cold start under two seconds, steady-state memory under 150 MB.
- Not for public signups. Admin creates personas via CLI; there is no registration page.
- Not a drop-in Mastodon replacement. Missing: polls, push notifications, admin API, reports queue. Clients degrade gracefully on all of these.
- Not horizontally scalable. SQLite on one machine. At 30 personas this is fine.
docker pull fallenpegasus/smallhold:latest
mkdir -p data && sudo chown 999:999 data
docker run --rm -v ./data:/data fallenpegasus/smallhold init --config /data/config.toml
# Edit data/config.toml — set domain, set listen to "0.0.0.0:8080"
docker run --rm -v ./data:/data fallenpegasus/smallhold persona create writer --display-name="Your Name" --config /data/config.toml
docker run -d -p 443:443 -v ./data:/data fallenpegasus/smallhold serve --config /data/config.tomlOr with Compose (includes Caddy for auto-TLS):
git clone https://github.com/MarkAtwood/smallhold && cd smallhold
export SMALLHOLD_DOMAIN=yourdomain.example
mkdir -p data && sudo chown 999:999 data
docker compose run --rm smallhold init --config /data/config.toml
# Edit data/config.toml — set domain, set listen to "0.0.0.0:8080"
docker compose run --rm smallhold persona create writer --display-name="Your Name" --config /data/config.toml
docker compose up -dcargo build --release
./smallhold init
# Edit config.toml — set your domain
./smallhold admin set-password
./smallhold persona create writer --display-name="Your Name"
./smallhold servePut a reverse proxy (Caddy or nginx) in front for TLS. See DEPLOY.md for full instructions including systemd, Caddy config, backup, CDN, and security checklist.
Open Phanpy, Elk, Ivory, Tusky, or Tuba. Enter your domain. Log in with your admin password and select a persona.
Federation:
- Full ActivityPub S2S — follow, post, reply, boost, like, edit, delete, block, move
- HTTP signatures (draft-cavage-11) with authorized fetch
- Delivery worker with exponential backoff and per-domain circuit breaker
- Inbound handling for all 11 activity types
- Verified working with mastodon.social
Mastodon Client API:
- OAuth2 with app registration, authorize page, token exchange
- Timelines: home, public, tag, list
- Posting with markdown, mentions, hashtags, content warnings, media
- Post editing and deletion with federation
- Favourites, boosts, bookmarks, pins
- Notifications with dedup
- Lists with account membership and replies_policy
- Keyword filters (v1 + v2) with timeline filtering
- Hashtag following with home timeline integration
- Scheduled posts
- Full-text search via tantivy
- Follow requests, profile editing, account search
- SSE and WebSocket streaming
- 61/61 API endpoints verified passing
Media:
- Image upload (JPEG, PNG, GIF, WebP)
- MIME sniffing from magic bytes (never trusts Content-Type)
- EXIF stripping via re-encode
- Decompression bomb protection
- Blurhash computation
- Served by reverse proxy with immutable cache headers
Web pages:
- Root page listing personas
- Profile pages with posts, stats, metadata fields, OpenGraph tags
- Individual post pages with ActivityPub alternate links
- RSS and Atom feeds per persona with autodiscovery
- Dark mode via
prefers-color-scheme - W3C Design Tokens theming
- Custom CSS drop-in
Data migration:
smallhold import mastodon— import a Mastodon archive (.tar.gz)- Posts, profile, media, following list
[server]
listen = "127.0.0.1:8080"
domain = "yourdomain.example"
secret_key = "<generated by smallhold init>"
[storage]
database_path = "smallhold.db"
media_dir = "media"
[federation]
authorized_fetch = true
[limits]
max_post_chars = 5000
max_attachments = 4
max_media_mb = 40
[branding]
site_title = "My Instance"
# theme_tokens_path = "tokens.json"
# custom_css_path = "custom.css"See DEPLOY.md for full configuration reference.
Drop a W3C Design Tokens JSON file and set branding.theme_tokens_path. A designer hands over the standard artifact and it just works.
{
"color": {
"primary": { "$value": "#0066cc", "$type": "color" },
"background": { "$value": "#ffffff", "$type": "color" },
"text": { "$value": "#1d1d1f", "$type": "color" }
},
"color-dark": {
"primary": { "$value": "#2997ff", "$type": "color" },
"background": { "$value": "#1d1d1f", "$type": "color" },
"text": { "$value": "#f5f5f7", "$type": "color" }
}
}Drop a CSS file and set branding.custom_css_path. Override any variable:
:root { --link: #e4002b; }Variables: --text, --muted, --bg, --card, --border, --link.
smallhold init # initialize DB and config
smallhold serve # start server
smallhold persona create <user> --display-name="..."
smallhold persona list
smallhold persona update <user> --bio="..." --display-name="..."
smallhold admin set-password
smallhold token mint <user> --scopes=read,write,follow
smallhold domain-block add <domain> --severity=suspend
smallhold queue inspect
smallhold search reindex
smallhold import mastodon <user> <archive.tar.gz>
reverse proxy (Caddy / nginx)
TLS termination, static media serving
|
smallhold binary (axum, tokio)
├── Mastodon Client API (61 endpoints)
├── ActivityPub S2S (inbox, outbox, actors)
├── WebFinger / NodeInfo / host-meta
├── Full-text search (tantivy)
├── SQLite via sqlx (WAL mode, 24 tables)
├── Streaming (SSE + WebSocket)
├── media/ filesystem
└── delivery worker (retry, circuit breaker)
13,000 lines of Rust across 22 source files. IDs are 64-bit time-sortable snowflakes, serialized as strings.
Clients:
| Client | Status |
|---|---|
| Phanpy (web) | Verified on live instance |
| Ivory, Elk, Tusky, Tuba | 61/61 API endpoints pass (same Mastodon Client API) |
Federation:
| Server | Status |
|---|---|
| mastodon.social | Verified — bidirectional follow + post delivery |
| Others (GoToSocial, Akkoma, Misskey) | AP compliance verified (12/12 go-fed, 18/18 FediTest) |
Conformance:
| Suite | Result |
|---|---|
| FediTest (WebFinger RFC 7033) | 18/18 |
| go-fed AP compliance | 12/12 |
| Mastodon Client API | 61/61 |
| Fediverse Pasture edge cases | 93/93 |
- SSRF protection on all outbound HTTP (private IP blocking, HTTPS-only)
- HTML sanitization via ammonia with restrictive allowlist
- HTTP signature verification with Date freshness check
- JSON depth limit, string length caps, LIKE wildcard escaping
- Media: MIME sniffing, EXIF stripping, decompression bomb limits
- OAuth: redirect_uri validation, atomic auth codes, constant-time secret comparison
- Rate limiting on login and token exchange
- Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy
- Cache-Control headers for CDN compatibility
smallhold persona create writer --display-name="Professional"
smallhold persona create personal --display-name="Personal"
smallhold persona create bot --display-name="Bot" --botEach persona is an independent ActivityPub actor with its own keypair, inbox, followers, and timeline. Mastodon clients see them as separate accounts. All personas share one domain, one process, one database.
For real separation between identities, run a second instance on a separate domain.
AGPL-3.0.