Self-Hosting
Sheaf is designed to be self-hosted. If you've ever set up a Minecraft server or a Discord bot, you can handle this.
What you need
- Docker and Docker Compose
- A machine with at least 512 MB of RAM
- A domain name
That's it. Postgres and Redis come with the Compose stack, and the all-in-one image below handles TLS for you.
Quick start (all-in-one)
For a small single-instance self-host, the all-in-one image bundles the backend, the web UI, and a Caddy reverse proxy in one container, alongside Postgres. It generates its own secrets on first start and fetches and renews a Let's Encrypt certificate automatically, so a public HTTPS deploy is close to a one-liner:
git clone https://github.com/sheaf-project/sheaf.git
cd sheaf
cp .env.example .env
# Set AIO_DOMAIN=sheaf.example.com in .env, and point that domain's DNS at this box
docker compose -f docker-compose.aio.yml up -d
Publish ports 80 and 443 and you're done. Leave AIO_DOMAIN unset to serve plain HTTP on port 80 for a LAN-only instance or one sitting behind your own TLS proxy.
No public IP, behind CGNAT, or can't forward ports? The image bundles cloudflared, so setting CF_TUNNEL_TOKEN serves the instance to the internet over an outbound-only connection - nothing to forward, and Cloudflare terminates TLS. You need a domain whose DNS is managed by Cloudflare; a free plan is fine.
The all-in-one is deliberately scoped to small deployments and doesn't scale horizontally. If you outgrow it, move to the split images below - same env vars, same data.
Quick start (split images)
For anything you want to scale, or if you'd rather run your own reverse proxy:
git clone https://github.com/sheaf-project/sheaf.git
cd sheaf
cp .env.example .env
# Edit .env - at minimum, change POSTGRES_PASSWORD and JWT_SECRET_KEY
docker compose up -d
The API is then available at http://localhost:8000 with interactive docs at http://localhost:8000/v1/docs. You build and serve the frontend and terminate TLS yourself.
/health is an always-200 liveness probe; /health/ready checks the database and Redis under a tight timeout, for use as a readiness gate in front of a rolling deploy.
Generating secrets
# JWT secret
python -c "import secrets; print(secrets.token_urlsafe(32))"
# Encryption key (optional - auto-generated on first start if not set)
python -c "import secrets; print(secrets.token_hex(32))"
Important: if you let Sheaf auto-generate the encryption key, it's saved to
data/encryption.keyinside the Docker volume. Back this up. If you lose it, all encrypted data (emails, TOTP secrets) is unrecoverable.
Configuration
The full self-hosting guide in the repo covers:
- Secrets and encryption key management
- Admin access and step-up authentication
- Email (SMTP / AWS SES / SendGrid) with bounce and complaint handling
- Registration modes: open, approval, invite-only, closed
- Account deletion with a configurable grace period
- File storage: filesystem or S3-compatible, with hotlink protection
- Storage quotas and upload limits
- Reverse proxy setup (nginx, Caddy), including the directives the realtime front stream needs
- Rate limiting, per-tier limits, and trusted proxies
- Custom support-page text for your own FAQ or house rules (
CUSTOM_SUPPORT_TEXT_FILE) - Delivering webhooks and ntfy to your own LAN (
WEBHOOK_ALLOWED_PRIVATE_CIDRS) - Backups
Read it at docs/SELFHOSTING.md.
Mobile push notifications
This is the most-asked-about config that doesn't work the way most people first guess, so it gets its own section.
The constraint
Sheaf supports a mobile_push notification channel type that fans out to a recipient's registered iOS / Android devices. For mobile push to actually reach the phone, the server that's sending the push has to hold credentials matching the app installed on that phone. Both Apple and Google enforce this:
- Android (FCM): the Sheaf Android APK has a Firebase project's sender ID embedded at build time. The device's FCM token is bound to that sender ID. Firebase only accepts a send if the server's service-account credentials are for the same project. You can't substitute your own Firebase project's credentials and reach a token issued for a different one - the message is rejected before it ever leaves Google's servers.
- iOS (APNs): the Sheaf iOS app has a bundle ID (
systems.lupine.sheaf) baked in. APNs accepts a send only if the authenticating Apple Developer Team owns that bundle ID. You can't push to someone else's bundle.
So the published Sheaf apps you install from the App Store and Play Store are bound at build time to Lupine Systems' push infrastructure. A self-hosted Sheaf instance can't simply "add its own Firebase credentials" and reach those installed apps. There's no version of the published app that listens to credentials configured at runtime.
What this means for you
You have three options, in roughly increasing order of effort:
Option 1: Use a different channel type (recommended for almost everyone)
Sheaf supports four delivery types that don't require any mobile-app coordination at all:
- Web push - works in mobile Safari and mobile Chrome service workers, no app install required. The recipient's phone or laptop browser handles the push. Apple/Google's free relay infrastructure does the heavy lifting.
- ntfy - the recipient runs the ntfy app (free, open source) and your instance posts to their subscribed topic.
- Pushover - similar; the recipient has a Pushover account and your instance sends with an app token.
- Webhook - your instance HMAC-signs the event and POSTs it to any URL the recipient (or their automation) controls.
For 95% of self-hosters this is the right answer: it works today with no extra setup, no app store accounts, and no ongoing maintenance burden.
Pointing a webhook or ntfy at your own LAN. Sheaf blocks outbound delivery to private, loopback, and link-local addresses by default, because otherwise any account on your instance could use your server to probe your internal network. If you want to drive a local Home Assistant, Node-RED, or self-hosted ntfy, list the specific ranges you'll allow in WEBHOOK_ALLOWED_PRIVATE_CIDRS (e.g. 192.168.1.0/24). Cloud metadata endpoints stay blocked regardless, and a target that would be rejected is reported when you save the channel rather than silently failing on first delivery. Only turn this on if you trust everyone with an account on your instance. It's ignored entirely on the hosted service.
Pulling instead of pushing. If the consumer can hold a connection open, GET /v1/fronts/stream avoids all of this - your automation dials out to Sheaf and receives front changes live, so nothing needs to reach into your network at all.
Running Home Assistant? The Sheaf integration supports all three transports and defaults to polling, but the live stream is the one to pick: it needs no inbound reachability, so you can skip the CIDR allowlist entirely and leave the SSRF protection strict. Only reach for the webhook transport if you have a specific reason to.
Option 2: Fork and republish the mobile apps (advanced)
Nothing about Sheaf is hostage to Lupine's app store accounts. The Android and iOS app source code is open (android, ios, AGPL-3.0). To run your own end-to-end mobile push for your own instance, you would:
- Apple side:
- Enrol in the Apple Developer Program ($99/year). Requires a real legal identity; organisations need a D-U-N-S number.
- Register a new bundle ID for your fork (e.g.
org.example.sheaf). - Create an APNs Authentication Key under that team (Certificates, Identifiers & Profiles → Keys). Download the
.p8file (you only get one chance) and note the Key ID and Team ID. - Set
APNS_TEAM_ID,APNS_KEY_ID,APNS_BUNDLE_ID(your new bundle), and the path to the.p8in your instance's environment. - Build the iOS app against your new bundle ID, sign it with your team's distribution certificate, submit it to the App Store (or distribute via TestFlight / ad-hoc / enterprise channels).
- Android side:
- Create a Firebase project in the Google Cloud Console.
- Add an Android app to that project with a new package name (e.g.
org.example.sheaf). - Download the
google-services.jsonand drop it into the Android repo at build time. - Generate a service-account JSON under that Firebase project with the Firebase Cloud Messaging API role. Mount it in the instance and point
FCM_CREDENTIALS_PATHat it. - Enrol in the Google Play Developer Program ($25 one-time). Generate an upload key, build the AAB, submit it to the Play Console. Google Play App Signing will hold the distribution key; record both the upload and distribution SHA-256 cert fingerprints.
- Optionally: also publish via IzzyOnDroid, F-Droid, or direct APK distribution. You can sign all of these with the same upload key.
- Site-side (sheaf.sh equivalent):
- Stand up a static page at your own domain claiming the
/redeempath via/.well-known/apple-app-site-association(iOS Universal Links) and/.well-known/assetlinks.json(Android App Links). - Set
MOBILE_LINK_BASE_URLon your instance to point at that domain so activation URLs route there.
- Stand up a static page at your own domain claiming the
This is real, ongoing work. App Store / Play Store reviews, key rotation, certificate renewal (APNs keys don't expire, but APNs certificates do; if you go the certificate route instead of key-based auth, that's a yearly chore). For most self-hosters running an instance for themselves and a small group of trusted users, the effort dwarfs the value over just using web push.
Option 3: Future - a shared Sheaf push relay (not yet built)
A "you don't have to fork the apps to get mobile push" path is something we'd like to eventually provide as a paid Lupine-operated service. Self-hosted instances would webhook events to a relay, which would fan out to FCM / APNs using Lupine's published-app credentials. Not currently scheduled; it depends on user demand and on abuse-control infrastructure being in place first.
Architecture (where the push actually flows)
For the Lupine-published apps, the flow looks like this:
┌─────────────────────────────┐
│ Lupine instance │
│ (app.sheaf.sh, etc.) │
│ - holds FCM creds │
│ - holds APNs key │
└────────────┬────────────────┘
│ mobile_push send
┌─────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Google FCM │ │ Apple APNs │
│ (Lupine's │ │ (Lupine's │
│ project) │ │ team / bundle)│
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Sheaf Android │ │ Sheaf iOS app │
│ (Play Store) │ │ (App Store) │
└─────────────────┘ └─────────────────┘
For a self-hosted instance using Option 1 (web push / ntfy / Pushover / webhook), the flow is shorter and the published Sheaf apps are not involved at all:
┌─────────────────────────────┐
│ Your self-hosted instance │
└────────────┬────────────────┘
│
┌────────────────────┼────────────────┬─────────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Mozilla/ │ │ Recipient│ │ Pushover │ │ Custom │
│ Google/ │ │ ntfy.sh │ │ API │ │ webhook │
│ Apple │ │ topic │ │ │ │ URL │
│ web push │ │ │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬───────┘
▼ ▼ ▼ ▼
Browser ntfy app on Pushover app Whatever the
service worker recipient's on recipient's recipient
on any device phone phone wired up
For a self-hosted instance using Option 2 (fork + republish), the flow mirrors the Lupine architecture but with your own Firebase project, Apple Team, and re-published apps in place of Lupine's. From the user's phone all the way back to your instance, every link in the chain is owned by you.
Using cloud services
Any PostgreSQL 16+ instance works - set DATABASE_URL in your .env and point Sheaf at RDS, Cloud SQL, Supabase, Neon, or similar. Same applies to Redis. S3-compatible storage (AWS S3, Backblaze B2, Cloudflare R2, MinIO) works for file uploads.
For the bundled database you don't need DATABASE_URL at all: setting POSTGRES_PASSWORD is enough, and Sheaf derives the connection string from it, so the credential can't drift between the two. Set DATABASE_URL explicitly only for an external database or custom driver options.
Running an instance for others
The AGPL allows you to run Sheaf as a public service. If you modify the code and run it publicly, you must publish your modifications under the same license.
You're allowed to charge for access. Before you do, think about your threat model: you will be handling sensitive identity data belonging to other people. The bigger your instance gets, the higher the chance you'll eventually receive a subpoena for user data. Have mechanisms in place to handle that, and seek legal advice if you're unsure.
Getting help
- GitHub Discussions for general questions
- GitHub Issues for bugs and feature requests
- Discord for live conversation