Self-host

Docker Compose on your own server. No SaaS dependencies, no limits.

Installation

One docker compose file, four services: Postgres, Electric (real-time sync), Garage (S3-compatible attachment storage), and Caddy (reverse proxy). Set SELF_HOSTED=true and every plan limit disappears — billing is disabled entirely.

Just want to use Exponential?Sign up free at app.exponential.at — no install needed.

1. Clone the repo

shell
git clone https://github.com/Niach/exponential
cd exponential

2. Pick your sign-in method

Email & password is on by default (AUTH_PASSWORD_ENABLED=false turns it off). For OIDC (Authentik, Keycloak, Zitadel, …), configure providers as a JSON array:

env
OIDC_PROVIDERS='[{"id":"authentik","name":"Authentik","clientId":"...","clientSecret":"...","discoveryUrl":"https://auth.example.com/application/o/app/.well-known/openid-configuration"}]'

The redirect URI in your IdP is ${BETTER_AUTH_URL}/api/auth/oauth2/callback/<id>. Google sign-in works too:

env
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_LOGIN_ENABLED=true

3. Bring the stack up

Caddyfile is gitignored but the compose file bind-mounts it, so copy the example first — it ships the long-poll timeouts Electric needs. storage:init prints the S3 keys you'll need in the next step.

shell
bun install
cp Caddyfile.example Caddyfile
bun run backend:up
bun run storage:init

4. Configure

Copy the example env file and fill in the database URL, a session secret, and the S3 keys from the previous step.

shell
cp apps/web/.env.example .env
# generate a 32-character session secret
openssl rand -hex 32

5. Migrate & run

shell
bun run migrate
docker exec -i exponential-postgres-1 \
  psql -U postgres -d exponential \
  < apps/web/src/db/out/custom/0001_triggers.sql
bun dev

Open http://localhost:5173 and register the first user — that's your instance.

Projects need a GitHub AppEvery project is backed by a GitHub repository, so creating projects requires a configured GitHub App — the next section walks through creating one.

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.

Going live

Build the web image with the root Dockerfile and run it behind the Caddy from step 3 — the Caddyfile you copied proxies host.docker.internal:5173, so serve the app on port 5173:

shell
docker build -f Dockerfile -t exponential-web:latest .
docker run -d --name exponential-web \
  --network host \
  --env-file .env \
  -e PORT=5173 \
  -e AUTH_SIGNUP_ENABLED=true \
  exponential-web:latest

The image applies pending migrations on boot and listens on PORT; --network host keeps the localhost URLs in your .env working from inside the container.

Sign-up is off in production by defaultThe production image runs with NODE_ENV=production, which disables password registration unless AUTH_SIGNUP_ENABLED=true is set — without it (or an OAuth/OIDC provider) nobody can register on your instance. Drop the flag later to close public sign-up again.

GitHub App

Every project is backed by exactly one GitHub repository, so a GitHub App is a hard prerequisite for creating projects. 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: Contents — Read & write and Pull requests — Read & write (Metadata — Read is added 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).

Server behind NAT?If GitHub can't reach your webhook URL, set 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:

shell
base64 -w0 your-app.private-key.pem   # macOS: base64 -i your-app.private-key.pem
env
GITHUB_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

Restart the app, then connect a GitHub account from workspace 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.

If the App loses repo accessDrop a repo from the installation on GitHub and workspace settings → Repositories flags it with a "no access — re-grant on GitHub" badge and a re-grant link; coding-session token minting fails with a clear message instead of handing out a broken token.

Push notifications

Native push goes through a small companion service, the push-relay, that wraps Firebase Cloud Messaging.

A relay authenticates senders: when its PUSH_RELAY_SECRET is set, it rejects any /send request whose x-relay-secret header doesn't match. The public relay at https://push.exponential.at serves the official cloud and mobile builds and its secret is not published — a self-hosted instance pointing at it gets 401s, so run your own.

Run your own relay

Host the relay with your own Firebase project — you'll also need to build the mobile apps with your own FCM credentials, since the published binaries are wired to the public relay.

shell
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:

env
PUSH_RELAY_URL=https://push.yourapp.com
PUSH_RELAY_SECRET=<shared secret>
Set the secret on both sidesWithout PUSH_RELAY_SECRET the relay accepts unauthenticated /send requests — fine on a private network, not on the open internet. Set the same value on the relay process and the web app; the web app sends it as the x-relay-secret header.
What a relay seesThe FCM device token, the notification title/body, and the data payload (typically an issue ID). Never your database, auth state, or credentials.

Environment variables

Only three are strictly required — the rest have sensible defaults.

DATABASE_URLrequired
Postgres connection string.
BETTER_AUTH_SECRETrequired
32+ character secret for session signing.
BETTER_AUTH_URLrequired
Base URL of your instance (e.g. https://issues.yourcompany.com).
BETTER_AUTH_TRUSTED_ORIGINS
Comma-separated allowed origins.
ELECTRIC_URL
Electric service URL (default: http://localhost:30000).
S3_ENDPOINT
S3-compatible storage URL.
S3_ACCESS_KEY
S3 access key.
S3_SECRET_KEY
S3 secret key.
S3_BUCKET
Attachment bucket name (default: exponential-attachments).
S3_REGION
S3 region (default: garage).
SELF_HOSTED
Set to true to disable billing and unlock all plan limits.
AUTH_PASSWORD_ENABLED
Enable email/password login (default: true).
AUTH_SIGNUP_ENABLED
Allow public password sign-up. Defaults to on in dev but off when NODE_ENV=production — set true or nobody can register on your instance.
INITIAL_ADMIN_EMAILS
Comma-separated emails auto-promoted to instance admin at startup.
OIDC_PROVIDERS
JSON array of OIDC provider configs.
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 and create projects.
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 true to poll for PR merges instead — for servers behind NAT that webhooks can't reach.
PUSH_RELAY_URL
Push notification relay URL.
PUSH_RELAY_SECRET
Shared secret between the web app and the relay (sent as the x-relay-secret header) — must match the relay process's env.
PUBLIC_FEEDBACK_URL
Where the in-app "Send feedback" button sends your users — defaults to the Exponential cloud feedback board (https://app.exponential.at), where issues about Exponential itself belong.

Updating

Exponential rolls forward on master. To update:

1. Pull the latest code

shell
git pull origin master

2. Rebuild the image

shell
docker build -f Dockerfile -t exponential-web:latest .

3. Run migrations

shell
bun run migrate
docker exec -i exponential-postgres-1 \
  psql -U postgres -d exponential \
  < apps/web/src/db/out/custom/0001_triggers.sql

4. Restart

The compose command restarts the backend services (Postgres, Electric, Garage, Caddy); the web app is a separate container, so recreate it from the freshly built image.

shell
docker compose down && docker compose up -d
docker rm -f exponential-web
# then re-run the `docker run` from "Going live"
Migrations firstAlways apply migrations before restarting the containers — the app may fail to start otherwise.