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.
1. Clone the repo
git clone https://github.com/Niach/exponential
cd exponential2. 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:
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:
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_LOGIN_ENABLED=true3. 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.
bun install
cp Caddyfile.example Caddyfile
bun run backend:up
bun run storage:init4. Configure
Copy the example env file and fill in the database URL, a session secret, and the S3 keys from the previous step.
cp apps/web/.env.example .env
# generate a 32-character session secret
openssl rand -hex 325. Migrate & run
bun run migrate
docker exec -i exponential-postgres-1 \
psql -U postgres -d exponential \
< apps/web/src/db/out/custom/0001_triggers.sql
bun devOpen http://localhost:5173 and register the first user — that's your instance.
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:
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:latestThe image applies pending migrations on boot and listens on PORT; --network host keeps the localhost URLs in your .env working from inside the container.
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).
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
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.
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.
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>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.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
trueto 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— settrueor 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
trueto 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-secretheader) — 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
git pull origin master2. Rebuild the image
docker build -f Dockerfile -t exponential-web:latest .3. Run migrations
bun run migrate
docker exec -i exponential-postgres-1 \
psql -U postgres -d exponential \
< apps/web/src/db/out/custom/0001_triggers.sql4. 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.
docker compose down && docker compose up -d
docker rm -f exponential-web
# then re-run the `docker run` from "Going live"