Installation
One docker compose file pulling published images: the web app (ghcr.io/niach/exponential-web, amd64 + arm64), Postgres, Electric (real-time sync), and Caddy (reverse proxy + automatic HTTPS), plus the optional steer relay behind a compose profile. You bring an S3-compatible bucket for attachments. The stack runs in self-hosted mode by default (nothing to set), so every plan limit disappears (seats, storage, widgets) and billing is disabled entirely. It's free for any team size, open source under Apache-2.0 (see Licensing).
The step-by-step below also lives as INSTALL.md in the repo, written so a coding agent can follow it end-to-end. The "Copy prompt for your agent" button above hands it to whatever agent you use.
1. Get the two files
Prerequisites: Docker Engine with Compose ≥ 2.23.1 (docker compose version), free ports 80/443, and an S3 bucket + key (next section).
mkdir exponential && cd exponential
curl -fsSLO https://raw.githubusercontent.com/Niach/exponential/master/selfhost/docker-compose.yaml
curl -fsSL https://raw.githubusercontent.com/Niach/exponential/master/selfhost/.env.example -o .env2. Fill in .env
Two generated secrets plus your S3 credentials. The file is short and every entry is commented:
sed -i "s/^POSTGRES_PASSWORD=$/POSTGRES_PASSWORD=$(openssl rand -hex 32)/" .env
sed -i "s/^BETTER_AUTH_SECRET=$/BETTER_AUTH_SECRET=$(openssl rand -hex 32)/" .env
# then set S3_ENDPOINT / S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET / S3_REGION3. Up
docker compose up -d
curl -fsS http://localhost/api/health # => {"ok":true,"db":true,...}Open http://localhost and register the first user. That's your instance. Migrations and the custom trigger SQL apply themselves at every boot; there are no manual SQL steps, on install or on any update.
4. Go live on a domain
Point DNS at the host, then set both values in .env and docker compose up -d again. Caddy provisions Let's Encrypt certificates automatically:
DOMAIN=issues.example.com
APP_URL=https://issues.example.comDOMAIN is what Caddy serves; APP_URL is the origin the app runs auth against. If they disagree (scheme included), sign-in breaks with origin errors.NODE_ENV=production, which disables password registration unless AUTH_SIGNUP_ENABLED=true is set. The shipped compose defaults it to true so your first account works; set it to false in .env once your accounts exist, and new teammates join via invite links.Connect the apps
All native clients (iOS, Android, macOS, Windows, Linux) work with self-hosted instances: on first launch, enter your instance URL instead of app.exponential.at and sign in.
Prefer running from a checkout (dev server, hacking on the code)? That's the Development section of the README. The compose file above is only for running released images.
S3 storage
Attachments and widget screenshots live in an S3-compatible bucket, the one external dependency you bring. Any provider works: Hetzner Object Storage, MinIO, Cloudflare R2, AWS S3, Garage, … The app talks to it with path-style addressing (forcePathStyle) and streams all attachment traffic server-side, so the endpoint never needs to be reachable by browsers. A LAN-only MinIO is fine. The bucket is created automatically at first use when the key has create permission; otherwise create it up front.
# examples; copy one provider's block into .env
S3_ENDPOINT=https://nbg1.your-objectstorage.com # Hetzner
S3_REGION=nbg1
# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com # Cloudflare R2
# S3_REGION=auto
# S3_ENDPOINT=http://minio.lan:9000 # MinIO on your network
# S3_REGION=us-east-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_BUCKET=exponential-attachmentsVerify with the app itself: paste an image into any issue description. If it renders back, the credentials are right (docker compose logs web shows the S3 error otherwise).
S3_ENDPOINT at it, and your attachments never leave the machine.GitHub App
Coding runs against a GitHub repository, so a GitHub App is a prerequisite for coding sessions and PRs (repo-less boards need none). The server uses it to mint short-lived per-repo installation tokens. No personal access tokens, no stored user OAuth tokens.
1. Create the App
Go to github.com/settings/apps/new (or your org's equivalent). Set the homepage URL to your instance and the Setup URL to ${BETTER_AUTH_URL}/api/integrations/github/setup with "Redirect on update" ticked. GitHub redirects there after each install or repo-access change. Set the OAuth Callback URL to ${BETTER_AUTH_URL}/api/integrations/github/callback. That's where the lightweight connect flow lands back.
2. Permissions & events
Repository permissions: set Contents and Pull requests to Read & write (Metadata gets Read automatically). Subscribe to the Pull request webhook event. Installation and repo-selection events are delivered to GitHub Apps automatically once the webhook is active, so they never appear in the subscribe list. The webhook URL is ${BETTER_AUTH_URL}/api/webhooks/github with a secret of your choosing (goes into GITHUB_WEBHOOK_SECRET).
GITHUB_POLLING=true instead. The server polls for PR merges rather than waiting for webhooks.3. Wire the env vars
Generate a private key on the App page, then base64-encode it into a single line:
base64 -w0 your-app.private-key.pem # macOS: base64 -i your-app.private-key.pemGITHUB_APP_ID=123456 # the App's numeric ID
GITHUB_APP_SLUG=your-app-slug # from the App's URL; builds the install link
GITHUB_APP_PRIVATE_KEY=<base64 PEM>
GITHUB_WEBHOOK_SECRET=<webhook secret>
GITHUB_APP_CLIENT_ID=<oauth client id> # optional; enables the lightweight connect flow
GITHUB_APP_CLIENT_SECRET=<oauth client secret>GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET are the App's own OAuth credentials. The client ID is on the App page, and you generate the secret there too. They power the lightweight connect flow: the user token they mint is transient, used once to enumerate installations and then discarded, never stored. Leave them unset and connecting a repository falls back to the install-page round-trip.
4. Connect an account
Apply the env changes (docker compose up -d), then connect a GitHub account from Team settings → Repositories. With the OAuth credentials above configured, this opens a lightweight GitHub authorization: one consent screen, and if you manage several installations you pick which to connect from an in-app account picker. Without them it falls back to the install-page round-trip, which is also how you install the App on a new account or grant it access to more repositories.
Push notifications
The honest version first: mobile push does not work for self-hosted instances with the store apps. The iOS and Android apps from the App Store / Play Store are compiled against Exponential's first-party Firebase project, and only the cloud's push relay can reach their device tokens. A self-hosted relay cannot, by design.
Self-hosted users still get web notifications, the email digest, and the desktop app's notifications. Only mobile push is cloud-only.
Why a self-hosted relay can't serve the store apps
Native push goes through a small companion service, the push-relay, that wraps Firebase Cloud Messaging. FCM only accepts sends from the Firebase project an app was built against, and a relay always authenticates its senders: it refuses to start without a PUSH_RELAY_SECRET and rejects any /send whose x-relay-secret doesn't match. The public relay at https://push.exponential.at serves the official cloud and mobile builds; its secret is not published, so a self-hosted instance pointing at it just collects 401s.
The escape hatch: build the apps yourself
If you build the mobile apps from source against your own Firebase project, your own relay serves them. Build the relay from a checkout too, since its image isn't published, precisely because this path is the exception:
docker build -f Dockerfile.push-relay -t push-relay:latest .
docker run -d \
-p 4001:4001 \
-e FIREBASE_SERVICE_ACCOUNT_JSON='<single-line JSON>' \
-e PUSH_RELAY_SECRET='<shared secret>' \
push-relay:latest
# verify
curl https://push.yourapp.com/healthz # => {"ok":true}Then point the web app at it with the same secret:
PUSH_RELAY_URL=https://push.yourapp.com
PUSH_RELAY_SECRET=<shared secret>Steer relay
Starting a coding session on your desktop from the phone or the web app (and then watching and steering it live) goes through the steer-relay, a second companion service. It is a dumb pipe with auth: every connection dials out to it, so the desktop never needs an inbound port, and the relay itself holds device presence and session rooms in memory only. Leave it unconfigured and remote start plus live watch/steer are simply off. Local coding sessions on the desktop are unaffected.
Run the relay
The self-host compose ships it behind an opt-in profile, pulling the published image (ghcr.io/niach/exponential-steer-relay). Caddy proxies it under /steer on your app domain — it is not exposed on any other port — so it shares the site's TLS certificate and the WebSocket is wss:// end to end. Set the two vars in .env (they configure the relay container and the web app at once), then bring the profile up:
STEER_RELAY_URL=wss://issues.example.com/steer # what desktops + phones dial
STEER_RELAY_SECRET=<shared secret> # openssl rand -hex 32docker compose --profile steer up -d
# verify
curl https://issues.example.com/steer/healthz # => {"ok":true,...}To host it on its own box instead, run the same image there — and unless that box only serves a trusted LAN, front it with a TLS-terminating proxy (with TRUST_PROXY=true, see below) so clients dial wss://:
docker run -d \
-p 4002:4002 \
-e STEER_RELAY_SECRET='<shared secret>' \
ghcr.io/niach/exponential-steer-relay:latestThe relay is reached over WebSocket, so give STEER_RELAY_URL a ws:// or wss:// URL (an http(s):// one works too, since the server converts it); the web app derives the HTTP origin from it for its own server-to-server calls. A LAN address is fine: every client dials out, so the relay never has to be reachable from the internet.
ws:// is cleartext — anyone on the path can read the stream or inject input into a running agent. Only use ws:// on a trusted LAN; anything reachable from the internet (phone steering away from home) must be wss:// behind TLS, which the compose stack's /steer route gives you for free.STEER_RELAY_SECRET is the shared HS256 key: the web app signs the short-lived tickets clients present, the relay verifies them. Without it the relay answers 503 on everything but /healthz, and the web app treats the subsystem as off unless both STEER_RELAY_URL and STEER_RELAY_SECRET are set.TRUST_PROXY=true on the relay process whenever a proxy fronts it. The relay rate-limits WebSocket upgrades per client IP, and without this flag every connection keys to one shared bucket instead of the real address from X-Forwarded-For. Leave it off when the relay is exposed directly. Forwarded headers from unknown peers are forgeable.One sender handles all outgoing mail: password reset and address verification, invite emails, the notification digest, the helpdesk reporter magic links, and the contact form. With no transport configured every send is a logged no-op: nothing throws, the UI hides the affordances that depend on it ( "Forgot password?", the email-notification prefs), and in-app notifications keep working.
SMTP
The straightforward option for a self-hosted instance: any relay you already run. SMTP_PORT defaults to 587 (STARTTLS); set SMTP_SECURE=true for implicit TLS on port 465. SMTP_USER and SMTP_PASS are optional for unauthenticated relays.
SMTP_HOST=smtp.yourcompany.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SECURE=false
EMAIL_FROM="Exponential <noreply@yourcompany.com>"Amazon SES
Setting AWS_SES_REGION switches the sender to the SESv2 API; credentials come from the standard AWS chain. EMAIL_FROM must be a verified identity in that region, and the account has to be out of the SES sandbox to mail arbitrary recipients.
AWS_SES_REGION=eu-central-1
AWS_ACCESS_KEY_ID=<IAM user allowed ses:SendEmail>
AWS_SECRET_ACCESS_KEY=
EMAIL_FROM="Exponential <noreply@yourcompany.com>"SES wins when both transports are configured. EMAIL_REPLY_TO sets a monitored default Reply-To on every message (individual sends may override it).
Environment variables
With the shipped compose file, the in-network plumbing (DATABASE_URL, ELECTRIC_URL) is wired for you. What's left in .env is two secrets, the S3 block, your domain, and whichever optional subsystems above you turn on. Everything in .env reaches the web container, so optional vars are simply appended. The list below documents what the app reads, for the shipped compose and custom setups alike; the exhaustive commented reference is .env.example at the repo root.
- POSTGRES_PASSWORDrequired
- Postgres password (compose wires it into
DATABASE_URLfor you). - BETTER_AUTH_SECRETrequired
- 32+ character secret for session signing.
- S3_ENDPOINTrequired
- S3-compatible storage URL. Any provider works, see S3 storage.
- S3_ACCESS_KEYrequired
- S3 access key.
- S3_SECRET_KEYrequired
- S3 secret key.
- S3_BUCKET
- Attachment bucket name (default:
exponential-attachments). - S3_REGION
- S3 region label your provider expects.
- DOMAIN
- Hostname Caddy serves with automatic HTTPS (default:
:80, plain HTTP on localhost). Always set together withAPP_URL. - APP_URL
- The instance origin (e.g.
https://issues.yourcompany.com), which becomesBETTER_AUTH_URLand the trusted origin. - IMAGE_TAG
- Image tag for web + steer relay (default:
latest, which tracks upstream master). Pin a release tag to move deliberately. - AUTH_PASSWORD_ENABLED
- Enable email/password login (default:
true). - AUTH_SIGNUP_ENABLED
- Allow public password sign-up. The image runs
NODE_ENV=production, where this defaults off. The shipped compose sets ittrueso the first account works; flip tofalseonce onboarded. - INITIAL_ADMIN_EMAILS
- Comma-separated emails auto-promoted to instance admin at startup.
- OIDC_PROVIDERS
- JSON array of OIDC provider configs (Authentik, Keycloak, Zitadel, …). The redirect URI per provider is
${APP_URL}/api/auth/oauth2/callback/<id>. - GOOGLE_CLIENT_ID
- Google OAuth client ID.
- GOOGLE_CLIENT_SECRET
- Google OAuth client secret.
- GOOGLE_LOGIN_ENABLED
- Show Google sign-in button (default:
false). - GITHUB_APP_ID
- GitHub App numeric ID, required to connect repositories (coding sessions and PRs).
- GITHUB_APP_SLUG
- GitHub App URL slug (builds the install link).
- GITHUB_APP_PRIVATE_KEY
- GitHub App PEM private key, base64-encoded.
- GITHUB_WEBHOOK_SECRET
- GitHub App webhook secret (PR-merge detection via webhook).
- GITHUB_APP_CLIENT_ID
- GitHub App OAuth client ID (optional). Enables the lightweight connect flow (a single GitHub consent screen); unset falls back to the install-page round-trip.
- GITHUB_APP_CLIENT_SECRET
- GitHub App OAuth client secret (generate it on the App page). The user token it mints is transient, used once to enumerate installations, never stored.
- GITHUB_POLLING
- Set to
trueto poll for PR merges instead, for servers behind NAT that webhooks can't reach. - SMTP_HOST
- SMTP server for all outgoing mail (self-host transport). Unset together with
AWS_SES_REGION⇒ every send is a logged no-op. - SMTP_PORT
- SMTP port (default:
587). - SMTP_USER
- SMTP username (optional, for unauthenticated relays).
- SMTP_PASS
- SMTP password.
- SMTP_SECURE
- Set to
truefor implicit TLS (port 465). - AWS_SES_REGION
- Amazon SES region. Setting it switches the sender to SES, which wins over SMTP when both are configured.
- AWS_ACCESS_KEY_ID
- AWS credentials for SES (IAM user allowed
ses:SendEmail). - AWS_SECRET_ACCESS_KEY
- AWS secret key for SES.
- EMAIL_FROM
- Sender address, e.g.
Exponential <noreply@yourcompany.com>. Must be a verified identity on SES. - EMAIL_REPLY_TO
- Monitored default Reply-To on every outbound email.
- STEER_RELAY_URL
- Steer relay WebSocket URL (e.g.
wss://issues.example.com/steer— Caddy serves the compose profile's relay under/steer) for remote start and live watch/steer. Must bewss://whenever it crosses untrusted networks. Unset ⇒ the subsystem is off; local coding is unaffected. - STEER_RELAY_SECRET
- Shared HS256 secret the web app signs steer tickets with. The compose profile hands the same value to the relay container. Both this and
STEER_RELAY_URLare needed: with either missing the web app reports the subsystem as disabled, and a secretless relay answers503. - PUSH_RELAY_URL
- Push relay URL. Only meaningful with self-built mobile apps, see Push notifications.
- PUSH_RELAY_SECRET
- Shared secret between the web app and the push relay (sent as the
x-relay-secretheader). Must match the relay process's env.
Updating
docker compose pull && docker compose up -dThat's the whole procedure: the web image applies pending migrations and its custom trigger SQL at every boot, so there are no separate migration steps.
By default (IMAGE_TAG unset ⇒ latest) this tracks upstream master. To move deliberately instead, pin a release in .env (e.g. IMAGE_TAG=0.18.13 from the release tags) and bump it when you choose; the same tag applies to the steer relay image.
Licensing
Exponential is fully open source under the Apache License 2.0. Self-hosting is free for everyone: any company size, in production, forever. No seat caps, no commercial license, no phone home.
Enterprise Support (optional)
If you want more than the community around the repo, add Enterprise Support: SLA, priority support, deployment help, and custom development, on one annual invoice. It's an add-on, never a requirement. Contact sales or email support@exponential.at. Need SSO, SLA, or DPA? That conversation starts there too.