Skip to content

MarkAtwood/smallhold

Repository files navigation

smallhold

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.


What this is not

  • 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.

Quick start

Docker (fastest)

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.toml

Or 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 -d

Bare metal

cargo build --release
./smallhold init
# Edit config.toml — set your domain
./smallhold admin set-password
./smallhold persona create writer --display-name="Your Name"
./smallhold serve

Put 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.

Connect a client

Open Phanpy, Elk, Ivory, Tusky, or Tuba. Enter your domain. Log in with your admin password and select a persona.


Features

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

Configuration

[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.


Theming

Design tokens

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" }
  }
}

Custom CSS

Drop a CSS file and set branding.custom_css_path. Override any variable:

:root { --link: #e4002b; }

Variables: --text, --muted, --bg, --card, --border, --link.


CLI

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>

Architecture

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.


Verified compatibility

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

Security

  • 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

Multiple personas

smallhold persona create writer --display-name="Professional"
smallhold persona create personal --display-name="Personal"
smallhold persona create bot --display-name="Bot" --bot

Each 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.


License

AGPL-3.0.

About

A single-binary, SQLite-backed Rust ActivityPub server. Mastodon Client API compatible.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages