Open app →
Documentation

Self-hosting & operations

Run your own Elliptic instance with Docker Compose: the one-command quick start, the compose stack and its environment wiring, secrets and encryption, BYOK on your own keys, releases and upgrades, and air-gapped operation.

Running your own Elliptic

Elliptic is open source and self-hostable, so the whole platform, your projects, tasks, meetings, notes, the activity log, the company brain, and the built-in MCP server your agents connect to, can run entirely on your own infrastructure. The fastest path is Docker Compose: one command brings up the database, the API, and the web app together. This page walks through that quick start, the compose stack and how its services are wired, the secrets and encryption you set before any real deployment, how AI runs on your own keys, and how to upgrade. The canonical hosted instance lives at elliptic.sh; everything here is about running the same software yourself.

Two app services and one database
A Elliptic deployment is three containers: postgres (the only stateful dependency), api (Python / FastAPI, which also serves the MCP server and an in-process realtime relay for co-editing), and web (Next.js). The browser talks only to the web app, which proxies API calls same-origin under /api. Agents reach the workspace over the built-in MCP server on the same API, on your org's own key.

Quick start

You need Docker with the Compose plugin. Clone the repo, copy the example environment file, and bring the stack up. The build compiles both images the first time and is cached after that.

  1. Clone and enter the repo
    Run git clone https://github.com/woosal1337/elliptic.git and then cd elliptic.
  2. Create your env file
    Copy the template with cp .env.example .env. The defaults in it are wired for local evaluation, so the stack will come up with no edits at all.
  3. Bring the stack up
    Run docker compose up --build. Compose builds the api and web images, starts Postgres first, waits for it to pass its health check, then starts the API, then the web app.
  4. Open the app
    Go to http://localhost:3000. Create your account, create your first organization, and you are in. The API is on http://localhost:8000 with a health endpoint at /api/v1/health.
bash
git clone https://github.com/woosal1337/elliptic.git
cd elliptic
cp .env.example .env
docker compose up --build

# then open http://localhost:3000
Migrations run automatically on boot
You do not run database migrations by hand. The API container runs alembic upgrade head on every start before it serves, so a fresh database is brought fully up to schema on first boot, and an existing one is upgraded in place every time you start a newer image. Alembic is idempotent, so re-runs are safe. The API's own health check (it waits up to a 30-second start period before reporting healthy) is what the web service waits on, so by the time the web app is reachable, the database is migrated and the API is live.

The compose stack

docker-compose.yml defines the three services and one named volume. Each service reads a small set of environment variables, most of which are driven by your .env file, with sensible local defaults baked in so the stack runs out of the box for evaluation.

ServiceImageRole
postgrespostgres:17-alpineThe database. Stores all org data on the elliptic_pgdata volume and exposes a pg_isready health check that the API waits on.
apibuilt from apps/apiFastAPI backend, MCP server, and in-process realtime relay. Listens on port 8000, runs migrations on start, and exposes a health check at /api/v1/health.
webbuilt from apps/webNext.js web app, served on port 3000. Waits for the API to be healthy, then talks to it through a same-origin /api proxy.

How the services are wired

The startup order is enforced by health checks: Postgres must pass pg_isready before the API starts, and the API must pass its /api/v1/health check before the web app starts. The API connects to the database, and the web app connects to the API. The key environment variables on each service:

VariableOnWhat it does
DATABASE_URLapiThe async Postgres connection string. In compose it points at the postgres service: postgresql+asyncpg://elliptic:elliptic@postgres:5432/elliptic.
ELLIPTIC_KEKapiThe key-encryption key. A 32-byte urlsafe-base64 key that encrypts every stored secret (integration, SSO, connector, and MCP signing keys, among others). See Secrets and encryption below.
JWT_SECRET_KEYapiSigns the session tokens that keep people logged in. Must be a strong, long random string in production.
ENVapidevelopment (HTTP-friendly, non-secure cookies, for local eval) or production (secure cookies and strict secret enforcement).
CORS_ORIGINSapiThe comma-separated list of browser-facing web origins allowed to call the API. Defaults to http://localhost:3000.
APP_BASE_URLapiThe public URL of your web app, used when the API builds links (for example invite links). Defaults to http://localhost:3000.
BACKEND_ORIGINwebWhere the web app's /api proxy forwards to. Baked into the web image at build time; defaults to the compose-internal http://api:8000.

The same-origin /api proxy

The browser never calls the API directly. The web app proxies any request under /api through to the backend at BACKEND_ORIGIN, using a Next.js rewrite. This means the browser only ever talks to one origin (the web app), which keeps cookies first-party and the CORS surface small. Because BACKEND_ORIGIN is a build argument baked into the web image, the default http://api:8000 only needs changing if you run the web app outside this compose file (for example on a separate Next.js host that has to reach the API over a different address).

Changing host ports
The API publishes on host port 8000 and the web app on 3000 by default. If either is taken on your machine, set API_PORT or WEB_PORT in your .env to remap the host side. The Postgres credentials are also overridable with POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB; if you change them, DATABASE_URL follows along because compose builds it from the same values.

Secrets and encryption

The .env.example defaults are deliberately public and for local evaluation only. Before you run Elliptic for real, you generate two fresh secrets: a key-encryption key (ELLIPTIC_KEK) and a JWT signing secret (JWT_SECRET_KEY). Both are critical, and production mode refuses to start without strong, non-default values for them.

ELLIPTIC_KEK, the key-encryption key

ELLIPTIC_KEK is the master key from which Elliptic encrypts every secret it stores at rest: your BYOK provider keys, your integration credentials, your SSO and LDAP configuration, your connector secrets, and the private signing key behind MCP tokens. Nothing sensitive is written to the database in the clear; it is all sealed with this key. It must be a 32-byte key, urlsafe-base64 encoded. Elliptic validates this on startup: if the value does not decode to exactly 32 bytes, the API refuses to boot. Generate one like this:

bash
# 32-byte urlsafe-base64 key-encryption key
ELLIPTIC_KEK=$(python3 -c "import base64,os;print(base64.urlsafe_b64encode(os.urandom(32)).decode())")
Treat the KEK as irreplaceable
If you lose or change ELLIPTIC_KEK, every secret encrypted under it, BYOK keys, integration tokens, SSO secrets, connector credentials, and the MCP signing key, becomes undecryptable. Store it somewhere durable and secret (a secrets manager, not the repo), back it up, and do not rotate it casually. Leaving it empty falls back to a public development key, which is fine for local evaluation and unacceptable for anything real.

JWT_SECRET_KEY, the session signing secret

JWT_SECRET_KEY signs the access and refresh tokens that keep your users logged in. Anyone who knows it can forge a session, so it must be a long, unpredictable random string that is unique to your deployment. Generate one with:

bash
# strong JWT signing secret
JWT_SECRET_KEY=$(openssl rand -hex 32)

What production mode refuses to start without

Setting ENV=production does two things: it switches cookies to secure (so you must serve the web app over HTTPS), and it turns on strict validation of your configuration at boot. In production the API will refuse to start unless all of the following hold:

  • `JWT_SECRET_KEY` is set and strong. It cannot be empty and cannot be one of the known weak development defaults (insecure-dev-secret, or the local-dev-jwt-secret-change-in-production placeholder shipped in the compose file). A weak or default value aborts startup.
  • `ELLIPTIC_KEK` is set. A missing key-encryption key in production aborts startup; the value must also decode to exactly 32 bytes, which is checked in every mode.
  • `CORS_ORIGINS` is not a wildcard. Because the API allows credentialed requests, a * origin is rejected in production. You list your real web origins explicitly, comma-separated.
A minimal production .env
Put your real generated values in, set ENV=production, list your real web origin, and serve the web app behind HTTPS so the secure cookies work.
bash
# in .env
ELLIPTIC_KEK=$(python3 -c "import base64,os;print(base64.urlsafe_b64encode(os.urandom(32)).decode())")
JWT_SECRET_KEY=$(openssl rand -hex 32)
ENV=production            # secure cookies, serve the web app over HTTPS
CORS_ORIGINS=https://app.example.com
APP_BASE_URL=https://app.example.com

MCP token signing

The built-in MCP server, the surface your agents authenticate against, mints short-lived access tokens signed with RS256 (asymmetric signing) rather than the symmetric secret used for user sessions. On first use the instance generates an RSA signing key pair, encrypts the private key with your ELLIPTIC_KEK, and stores it. The matching public key is published as a JWKS document, so any client (and the API itself) can verify a token's signature without ever seeing the private key. Each MCP access token is bound to a specific audience (a single org, or an org-agnostic audience for multi-org tokens), is short-lived, and org membership is re-verified live on every call. This is all automatic; the only thing you provide is the KEK that protects the signing key at rest.

AI runs on your own keys (BYOK)

Self-hosting changes nothing about how AI is powered: it is always bring-your-own-key (BYOK), per organization. There is no shared, pooled model account anywhere in Elliptic, not on the hosted instance and not in your own deployment. Every AI feature, meeting summaries, asking the meeting, the company brain, and AI agents acting as first-class members over the MCP server, runs on a provider key (OpenAI or Anthropic, with OpenAI-compatible endpoints also supported) that an owner or admin stores at the organization level. That key is encrypted at rest under your ELLIPTIC_KEK and decrypted only at call time.

Because there is no fallback to any shared key, an organization with no provider key configured simply has its AI surfaces inert until a key is added. The API returns a clear "No AI provider key configured for this organization" rather than quietly billing some central account. The cost lands on your provider bill and your prompts and data stay under your control. The same holds for agents: an AI member operating tasks, boards, and meetings over the company-brain MCP does so on its org's key, exactly like a person's actions do.

Set the org key in Settings, not in env
BYOK keys are not environment variables. They are added per organization, in the workspace under Settings, and managed there (you can hold several, mark a default, and rotate with no downtime). The ELLIPTIC_KEK you set in .env is what encrypts those keys, it is not itself a model key.

Releases and upgrades

Tagged releases publish prebuilt container images to the GitHub Container Registry (GHCR), so you do not have to build from source for a real deployment. The two images are:

  • ghcr.io/woosal1337/elliptic-api, the backend, MCP server, and realtime relay.
  • ghcr.io/woosal1337/elliptic-web, the Next.js web app.

To upgrade, pull the newer image tags and recreate the api and web services. Because the API runs its Alembic migrations automatically on start, restarting onto a newer api image upgrades the database schema in place, there is no separate migration step. Postgres data persists across upgrades on the elliptic_pgdata named volume, so recreating containers does not lose anything.

  1. Pin the new version
    Point your compose file (or deployment manifests) at the GHCR images for the release tag you want, instead of building locally.
  2. Pull and recreate
    Run docker compose pull then docker compose up -d. The API starts on the new image, applies any pending migrations, and reports healthy before the web app comes back.
  3. Confirm it is live
    Check the API health endpoint at /api/v1/health and load the web app. Your data on the Postgres volume carries over untouched.
Flip to production when you go live
The single most important switch when moving from evaluation to a real deployment is ENV=production together with fresh ELLIPTIC_KEK and JWT_SECRET_KEY and a non-wildcard CORS_ORIGINS. Production mode is what enforces secure cookies and rejects weak or default secrets, so making the switch is what turns an evaluation stack into a hardened one.

Beyond Compose

Docker Compose is the fastest way to run Elliptic, but it is not the only one. For Kubernetes (a Helm chart or raw manifests), Docker Swarm, and a complete configuration reference, see apps/api/SELF-HOSTING.md and the apps/api/deploy/ directory in the repository (deploy/helm, deploy/k8s, and deploy/swarm). The same two images and the same environment contract described above apply across all of them.

Air-gapped operation and offline licensing

Elliptic can run with zero outbound network egress. In air-gapped mode, the instance disables the features that would reach the public internet: web search in the company brain is turned off, and outbound link unfurls and embeds (the previews Elliptic would otherwise fetch when you paste a URL) are suppressed. This lets you run the platform fully inside a closed network. Air-gapped deployments also use offline licenses, signed license keys that an instance can verify locally without any phone-home.

Both air-gapped mode and offline licensing are configured by an instance administrator on the Instance administration page, not through the Compose environment. Its General tab holds the air-gapped toggle, the Users tab manages instance-wide users, and the License tab is where you paste and activate an offline license key.