Skip to content

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

  • 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.io for image pulls.

Terminal window
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

Generate the two secrets (once, keep forever):

Terminal window
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

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

Terminal window
sed -i "s|^VAULT_KEY=$|VAULT_KEY=k1:$(openssl rand -base64 32)|" .env

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

Terminal window
docker compose up -d

The 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):

Terminal window
docker compose ps # all services running
curl -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.

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.

  1. Point DNS (an A/AAAA record) at the host.

  2. Set both in .env (mismatched values break sign-in):

    Terminal window
    DOMAIN=issues.example.com
    APP_URL=https://issues.example.com
  3. 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.

  4. [decision] Once everyone has an account, close public sign-up: set AUTH_SIGNUP_ENABLED=false in .env and docker compose up -d again. 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).

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 via AWS_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 via APPLE_CLIENT_ID (an Apple Services ID) + APPLE_PRIVATE_KEY/APPLE_KEY_ID/APPLE_TEAM_ID + APPLE_LOGIN_ENABLED=true; or any OIDC IdP via OIDC_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:

    1. Callback URL: https://issues.example.com/api/auth/callback/github. Leave “Request user authorization (OAuth) during installation” unchecked.
    2. 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.
    3. Webhook: https://issues.example.com/api/webhooks/github with a secret (GITHUB_WEBHOOK_SECRET), or leave the webhook inactive and set GITHUB_POLLING=true when GitHub cannot reach your server (behind NAT).
    4. 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 pr op reports checks: none, and nothing errors. Add Account → Email addresses (read) only if GitHub should also be a login method (GITHUB_LOGIN_ENABLED=true).
    5. 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_ID and GITHUB_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.

  • Steer relay (device presence, remote start, Stop/kill/compact, voice calls): set STEER_RELAY_SECRET (any random string) and STEER_RELAY_URL=wss://issues.example.com/steer in .env, then

    Terminal window
    docker compose --profile steer up -d
    curl -fsS https://issues.example.com/steer/healthz

    Caddy proxies the relay under /steer on 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. Plain ws:// is cleartext: only use it on a trusted LAN with the no-domain plain-HTTP setup (STEER_RELAY_URL=ws://<host>/steer, health check http://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>:4002 over from an older setup, change it to the /steer form 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 to http://steer-relay:4002 in 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-Options and Referrer-Policy on every response, because the bundled Caddy sets none. Self-hosted HSTS is max-age only (no includeSubDomains, so sibling subdomains of your apex are untouched), and with a plain http:// APP_URL the CSP also allows ws: for the LAN steer relay. Set SECURITY_HEADERS_ENABLED=false when 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.

Terminal window
docker compose pull && docker compose up -d

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

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.

Terminal window
# Database: the only irreplaceable local state
docker 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.dump

Back 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 -v deletes all three volumes, including the database. Use docker compose down to stop the stack.

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.

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.

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.