Self-host

Two files and a docker compose up, pulling the published image, no checkout, no build. No plan limits. Free for everyone, open source under Apache-2.0. Or skip reading entirely: copy the prompt and let your coding agent do the install.

Install by hand View source

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).

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

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).

shell
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 .env

2. Fill in .env

Two generated secrets plus your S3 credentials. The file is short and every entry is commented:

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

3. Up

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

env
DOMAIN=issues.example.com
APP_URL=https://issues.example.com
Set both, identicallyDOMAIN 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.
Sign-up is off in production by defaultThe image runs with 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.

GitHub App (only for coding)Boards work out of the box. Only coding (backing a board with a GitHub repository for coding sessions and PRs) needs a configured GitHub App; section 03 walks through creating one, and you can skip it if you just want issue tracking.

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.

env
# 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-attachments

Verify 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).

Want local S3?Garage is a great single-binary S3 server (it's what Exponential's dev stack uses), MinIO the classic. Run either next to the stack, point 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).

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

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.

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

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:

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>
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.

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:

env
STEER_RELAY_URL=wss://issues.example.com/steer   # what desktops + phones dial
STEER_RELAY_SECRET=<shared secret>               # openssl rand -hex 32
shell
docker 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://:

shell
docker run -d \
  -p 4002:4002 \
  -e STEER_RELAY_SECRET='<shared secret>' \
  ghcr.io/niach/exponential-steer-relay:latest

The 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.

Internet-reachable ⇒ TLS, alwaysThe steering socket carries bearer steer tickets, the live terminal stream of your coding sessions, and remote steering input. Plain 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.
Set the secret on both sidesSTEER_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.
Behind a reverse proxy?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.

Email

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.

env
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.

env
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).

The helpdesk needs a transportA support reporter's only credential is the magic link emailed to them, so support mode on the feedback widget can't work without SMTP or SES. Password reset, address verification, emailed invites, and the notification digest are equally inert. They fail silently rather than erroring.

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_URL for 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 with APP_URL.
APP_URL
The instance origin (e.g. https://issues.yourcompany.com), which becomes BETTER_AUTH_URL and 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 it true so the first account works; flip to false once 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 true to 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 true for 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 be wss:// 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_URL are needed: with either missing the web app reports the subsystem as disabled, and a secretless relay answers 503.
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-secret header). Must match the relay process's env.

Updating

shell
docker compose pull && docker compose up -d

That'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.