Self-host
name: install-exponential description: Install and operate a self-hosted Exponential instance (a realtime issue tracker with local coding agents) from the published Docker images. No repo checkout, no build step. when to use: A human or agent wants Exponential running on a server or workstation they control. Follow the steps top to bottom; every command is copy-pasteable. If you are an agent: run the steps, verify each checkpoint, and ask your human only for the decisions marked [decision].
Prerequisites
Section titled “Prerequisites”-
Docker Engine with Docker Compose v2.23.1+ (the compose file uses inline
configs:content). Check:Terminal window docker compose version -
Ports 80/tcp, 443/tcp and 443/udp free on the host (Caddy binds all three: 443/udp is HTTP/3, and 443 only actually serves once you configure a domain).
-
An S3-compatible bucket + access key: the one external dependency, used for attachments and widget screenshots. Any provider works: Hetzner Object Storage, MinIO, Cloudflare R2, AWS S3, … The app uses path-style addressing and streams all attachment traffic server-side, so the endpoint never needs to be reachable by browsers. A LAN MinIO is fine. [decision] which provider; if none exists yet, a local Garage or MinIO container is a fine single-binary answer, run and bootstrapped by you next to (not inside) this stack.
-
Outbound HTTPS to
ghcr.iofor image pulls.
1. Get the two files
Section titled “1. Get the two files”mkdir exponential && cd exponentialcurl -fsSLO https://raw.githubusercontent.com/Niach/exponential/master/selfhost/docker-compose.yamlcurl -fsSL https://raw.githubusercontent.com/Niach/exponential/master/selfhost/.env.example -o .env2. Fill in .env
Section titled “2. Fill in .env”Generate the two secrets (once, keep forever):
sed -i "s/^POSTGRES_PASSWORD=$/POSTGRES_PASSWORD=$(openssl rand -hex 32)/" .envsed -i "s/^BETTER_AUTH_SECRET=$/BETTER_AUTH_SECRET=$(openssl rand -hex 32)/" .env(macOS: sed -i '', or just edit the file.)
Recommended: a vault key for the OAuth tokens and MCP server credentials the app keeps at rest. Unset, it is derived from BETTER_AUTH_SECRET; your own key can be rotated:
sed -i "s|^VAULT_KEY=$|VAULT_KEY=k1:$(openssl rand -base64 32)|" .envTo rotate, set a new VAULT_KEY (another kid, e.g. k2:…) and move the old one into VAULT_KEYS_RETIRED (comma-separated); the daily sweep re-encrypts every value and nobody reconnects. A malformed value stops the server at boot.
Then set the S3 block (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET, S3_REGION) to your provider’s values. Create the bucket up front, or grant the key bucket-create permission and the app creates it at first use.
Leave DOMAIN/APP_URL commented for now. The stack serves plain HTTP on http://localhost out of the box.
3. Start
Section titled “3. Start”docker compose up -dThe web image applies database migrations and its custom trigger SQL at every boot. There are no manual SQL steps, on install or on any update.
Checkpoint (the web container waits for Postgres and can take ~30s on first boot):
docker compose ps # all services runningcurl -fsS http://localhost/api/health # => {"ok":true,"db":true,...}If health fails, read docker compose logs web --tail 50. A proxy or orchestrator probing the web container directly should match the body ("ok":true), not just the 200, and dial 127.0.0.1 rather than localhost: the server listens on IPv4 only, and busybox wget resolves localhost to ::1.
4. First account
Section titled “4. First account”Open http://localhost, register, and create your first team. Verify attachments work (this is the S3 credentials smoke test): open any issue and paste or drag an image into the description. It must render back. If it errors, the S3_* values are wrong (docker compose logs web shows the S3 error).
If you want the admin console (instance-wide users and teams), set INITIAL_ADMIN_EMAILS=you@example.com in .env before your first sign-in and docker compose up -d. There is no other way to become an admin.
5. Go live on a domain (optional)
Section titled “5. Go live on a domain (optional)”-
Point DNS (an
A/AAAArecord) at the host. -
Set both in
.env(mismatched values break sign-in):Terminal window DOMAIN=issues.example.comAPP_URL=https://issues.example.com -
docker compose up -d(recreates web + caddy). Caddy provisions Let’s Encrypt certificates automatically; ports 80/443 must be reachable from the internet for that. -
[decision] Once everyone has an account, close public sign-up: set
AUTH_SIGNUP_ENABLED=falsein.envanddocker compose up -dagain. New teammates then join via invite links.
Native apps (iOS, Android, desktop IDE) connect to a self-hosted instance: enter https://issues.example.com as the instance URL on first launch.
The headless exponential CLI installs against a self-hosted instance with curl -fsSL https://exponential.at/install.sh | EXP_INSTANCE=https://issues.example.com sh (the script is always served from the cloud site; the instance rides EXP_INSTANCE).
6. Optional subsystems
Section titled “6. Optional subsystems”All configured by appending vars to .env (the whole file reaches the web container) and re-running docker compose up -d. Full reference: .env.example at the repo root; deeper walkthroughs: self-host docs.
-
Email (password reset, invites, notification digest, widget reporter magic links):
SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS+EMAIL_FROM, or Amazon SES viaAWS_SES_REGION+ AWS credentials. Without a transport, email features are silently off; everything else works. -
Sign-in providers:
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_LOGIN_ENABLED=true; Sign in with Apple viaAPPLE_CLIENT_ID(an Apple Services ID) +APPLE_PRIVATE_KEY/APPLE_KEY_ID/APPLE_TEAM_ID+APPLE_LOGIN_ENABLED=true; or any OIDC IdP viaOIDC_PROVIDERS(JSON array: Authentik, Keycloak, Zitadel, …). -
GitHub App (only needed for coding, which means repo-backed boards, coding sessions and PRs). Members connect their own GitHub account from Settings → Repositories (or from the Start coding checklist), install the App on the accounts whose repositories they want, and add repositories they can push to. Create the App at GitHub → Settings → Developer settings → GitHub Apps → New GitHub App:
- Callback URL:
https://issues.example.com/api/auth/callback/github. Leave “Request user authorization (OAuth) during installation” unchecked. - Setup URL:
https://issues.example.com/integrations/github, with “Redirect on update” ticked. It is a plain redirect back to the guided page after an install. - Webhook:
https://issues.example.com/api/webhooks/githubwith a secret (GITHUB_WEBHOOK_SECRET), or leave the webhook inactive and setGITHUB_POLLING=truewhen GitHub cannot reach your server (behind NAT). - Permissions: Repository → Contents (read and write), Pull requests (read and write), Metadata (read); subscribe to the Pull request event. For CI status and the CI wake (a session wakes when its PR’s checks finish), also grant Repository → Checks (read-only) and Actions (read-only) and subscribe to the Check run, Check suite and Workflow run events; without them no checks are known, the
prop reportschecks: none, and nothing errors. Add Account → Email addresses (read) only if GitHub should also be a login method (GITHUB_LOGIN_ENABLED=true). - Generate a private key and a client secret, then set
GITHUB_APP_ID,GITHUB_APP_SLUG,GITHUB_APP_PRIVATE_KEY(the PEM, base64-encoded:base64 -w0 app.private-key.pem),GITHUB_APP_CLIENT_IDandGITHUB_APP_CLIENT_SECRET.
Optional:
GITHUB_TOKEN, a scopeless personal access token used only for public-repo reads (PR diffs, the Reviews queue) on repos the App isn’t installed on — it lifts GitHub’s anonymous 60 requests/hour per IP to 5000. - Callback URL:
-
Steer relay (device presence, remote start, Stop/kill/compact, voice calls): set
STEER_RELAY_SECRET(any random string) andSTEER_RELAY_URL=wss://issues.example.com/steerin.env, thenTerminal window docker compose --profile steer up -dcurl -fsS https://issues.example.com/steer/healthzCaddy proxies the relay under
/steeron your app domain (it is not exposed on any other port), so it shares the site’s Let’s Encrypt certificate and the WebSocket is TLS-encrypted — required, because that socket carries bearer steer tickets and voice call audio. Session content never crosses the relay: watching and steering a session goes over eNet, end-to-end encrypted, between the viewer and the device that runs it. Plainws://is cleartext: only use it on a trusted LAN with the no-domain plain-HTTP setup (STEER_RELAY_URL=ws://<host>/steer, health checkhttp://localhost/steer/healthz), never across the internet.Two notes on networking: the relay’s port 4002 is deliberately not published to the host — if you carried a
STEER_RELAY_URL=ws://<host>:4002over from an older setup, change it to the/steerform above or nothing can reach the relay. And the web container’s own relay calls (device lists, remote start, kill) go straight to the relay over the compose network (STEER_RELAY_INTERNAL_URL, defaulted tohttp://steer-relay:4002in the compose file) rather than out through public DNS and back in — home routers often lack hairpin NAT, which would otherwise leave remote start broken while phones and desktops connect fine. -
Response hardening (
SECURITY_HEADERS_ENABLED, default on): the web image stamps CSP, HSTS,nosniff,X-Frame-OptionsandReferrer-Policyon every response, because the bundled Caddy sets none. Self-hosted HSTS ismax-ageonly (noincludeSubDomains, so sibling subdomains of your apex are untouched), and with a plainhttp://APP_URLthe CSP also allowsws:for the LAN steer relay. SetSECURITY_HEADERS_ENABLED=falsewhen a proxy in front already sets its own CSP/HSTS: two CSPs do not merge, browsers enforce the intersection, which typically breaks avatars or the steer socket. -
Push notifications: cloud only for the store mobile apps, see Limitations.
Upgrading
Section titled “Upgrading”docker compose pull && docker compose up -dThe image self-migrates on boot. No other steps. latest tracks upstream master; to move deliberately instead, pin IMAGE_TAG in .env to a release tag (e.g. IMAGE_TAG=0.18, which tracks the latest patch of that minor) and bump it when you choose.
At every upgrade, also raise CLIENT_MIN_VERSION_* in .env to the versions the release notes name (and CLIENT_LATEST_VERSION_* alongside), then docker compose up -d. Those floors are what stops an out-of-date Android, iOS, desktop or CLI client from talking to a server that no longer speaks its dialect — a below-floor client gets HTTP 426 and a blocking update screen instead of mystery failures. Leaving them unset turns the gate off entirely.
The reverse floor matters too: native clients updated from the stores or the desktop self-updater expect a server at least as new as the release they shipped with (since iOS 0.14.23 / Android 0.14.25 / desktop 0.14.30 they attach steering images through /api/sessions/{id}/files, which needs web v0.18.53 or newer). Upgrade the server before, or together with, the clients.
Backup and restore
Section titled “Backup and restore”Session content is end-to-end encrypted on the clients: the database holds ciphertext for message bodies, cards, titles and previews, and only the members’ devices hold the keys. A backup restores what the clients can open again; it never makes that content readable on the server. MCP call audit rows (mcp_calls) are kept 90 days and purged by a daily sweep.
Three named volumes (postgres_data, caddy_data, caddy_config) plus your
external S3 bucket hold all state; .env holds the secrets that make them
readable.
# Database: the only irreplaceable local statedocker compose exec -T postgres pg_dump -U postgres -Fc exponential > exponential-$(date +%F).dump
# Restore into a fresh stack (bring it up first so migrations have run)docker compose exec -T postgres pg_restore -U postgres -d exponential --clean --if-exists < exponential-YYYY-MM-DD.dumpBack up .env alongside the dump. A lost BETTER_AUTH_SECRET invalidates
every session, and lost S3_* credentials orphan every attachment. Attachments
live in your S3 bucket, so back that up with your provider’s tooling.
caddy_data only holds Let’s Encrypt certificates, which re-issue
automatically.
docker compose down -vdeletes all three volumes, including the database. Usedocker compose downto stop the stack.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause / fix |
|---|---|
docker compose up errors on configs |
Compose < 2.23.1. Upgrade Docker Compose. |
| Port 80/443 already allocated | Another proxy owns them. Either stop it, or change the caddy ports: mapping and front this stack with your proxy (keep its read timeouts ≥ 5m and streaming/flush on, since Electric uses long-polls). |
| Sign-in loops or “origin not allowed” | DOMAIN and APP_URL disagree (scheme included). Set both to the same origin and docker compose up -d. |
| Image pastes/attachments fail | Wrong S3_* values, missing bucket, or key without create permission. docker compose logs web shows the S3 error. |
| Nobody can register | AUTH_SIGNUP_ENABLED=false in .env. This stack defaults it to true, so an explicit false is the only way to get here. Set it back to true while onboarding, or configure an OAuth/OIDC provider. |
| No certificates on your domain | Ports 80/443 not reachable from the internet, or DNS not propagated. Check docker compose logs caddy. |
Limitations
Section titled “Limitations”Push notifications are not available for self-hosted mobile apps. The store-distributed iOS and Android apps are compiled against the first-party Firebase/APNs project, so a self-hosted instance cannot push to them. Web and desktop apps are fully featured; the mobile apps work against your instance (build them from source), they just won’t receive push.
Licensing
Section titled “Licensing”Exponential is free to self-host under Apache-2.0: open source, any team size, no restrictions. Optional Enterprise Support (SLA, priority support, deployment help, custom development) is available at exponential.at/contact or support@exponential.at.