Self-Hosting
Deploy your own OpenAthlete instance
OpenAthlete is designed to be self-hosted, giving you full control over your data and privacy.
Quick start
Requirements: a Linux server (or any machine) with Docker and its Compose plugin. 1 GB of RAM is enough: the whole stack uses about 250 MB at rest.
curl -fsSL https://raw.githubusercontent.com/openathleteorg/openathlete/main/scripts/install.sh | shThe script creates an openathlete folder with docker-compose.yml and a .env holding new random secrets, downloads the images and starts OpenAthlete on http://localhost. Open it and create your account: the first account is a regular one, there is no default admin.
To serve your own domain over HTTPS, point its DNS to the server, then:
curl -fsSL https://raw.githubusercontent.com/openathleteorg/openathlete/main/scripts/install.sh | sh -s -- --domain openathlete.example.orgCaddy then obtains and renews the certificate on its own; ports 80 and 443 must be reachable. Behind your own reverse proxy instead, use --url https://openathlete.example.org and forward to the port of the web container (WEB_PORT).
Keep a copy of .env. Without HASH_PEPPER, no existing password can be
checked.
How it fits together
- One address. The web container serves the app and forwards
/apiand/socket.ioto the API, soAPP_URLis the only address to configure: email links, allowed origins and device callback URLs follow it. - Images. The Compose file runs the published multi-architecture images (amd64, arm64):
ghcr.io/openathleteorg/openathlete-apiandghcr.io/openathleteorg/openathlete-web.OPENATHLETE_VERSION=1follows every 1.x release; set it to a version such as1.1.0to upgrade only when you choose. - Data. PostgreSQL and Redis are only reachable from the other containers. Database migrations run when the API starts.
Without the script
mkdir openathlete && cd openathlete
curl -fsSLO https://raw.githubusercontent.com/openathleteorg/openathlete/main/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/openathleteorg/openathlete/main/.env.example -o .envIn .env, set JWT_SECRET_KEY, HASH_PEPPER and POSTGRES_PASSWORD (each openssl rand -hex 32) and APP_URL, then run docker compose up -d.
To build the images from a clone of the repository instead: docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build. Building takes a few minutes and much more memory than running the images.
Optional features
Leave a variable empty to disable the feature:
| Feature | Variables |
|---|---|
| Transactional email (password reset, invitations) | SMTP_* and EMAIL_FROM, or BREVO_API_KEY (see below) |
| An email to you for each new account | SIGNUP_NOTIFICATION_EMAIL (needs email) |
| Device sync | STRAVA_*, GARMIN_*, POLAR_*, SUUNTO_* |
| AI load estimation of planned sessions | ENABLE_TRAINING_LOAD_ESTIMATION |
| Billing | STRIPE_* (not needed for self-hosting) |
Any SMTP server works: your mail provider's, a relay such as Postmark or Amazon SES, or your own.
| Variable | Value |
|---|---|
SMTP_HOST | The server, e.g. smtp.example.org. Setting it turns email on. |
SMTP_PORT | Default: 465 when SMTP_SECURE=true, else 587 |
SMTP_SECURE | true for implicit TLS (port 465). Otherwise the connection upgrades with STARTTLS when the server offers it. |
SMTP_USER | User name, if the server asks for one |
SMTP_PASSWORD | Password |
EMAIL_FROM | The sender address, which the server must accept, e.g. openathlete@example.org |
Brevo works too, through its API: set BREVO_API_KEY instead (it wins when both are set) and EMAIL_FROM or BREVO_FROM_EMAIL.
Without Stripe, the instance sells nothing and limits nobody: every account can coach any number of athletes. With Stripe, free accounts coach up to 5 athletes and the Supporter subscription (STRIPE_PRICE_SUPPORTER_MONTHLY, STRIPE_PRICE_SUPPORTER_YEARLY) removes the limit and includes AI on the instance keys.
The app only offers what is set up: without Firebase there is no "Continue with Google" button, and without device sync settings Settings > Connectors points to file import instead of offering connectors that would fail. GET /api/instance lists what the instance offers.
Every API variable is documented in apps/api/.env.example.
Device sync
Each provider needs an app registered in its developer program. Users can always import FIT and GPX files instead (Settings > Connectors).
| Provider | Developer program | Callback URL to declare |
|---|---|---|
| Strava | strava.com/settings/api, open to anyone (limited to a few athletes until Strava reviews the app) | Authorization domain: your domain |
| Polar | admin.polaraccesslink.com, open to anyone | APP_URL/auth/callback/polar |
| Garmin | Garmin Connect Developer Program, business approval required | APP_URL/auth/callback/garmin |
| Suunto | Suunto API Zone, partner approval required | APP_URL/auth/callback/suunto |
Set the client ID and secret in .env (STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET...); Suunto also needs SUUNTO_SUBSCRIPTION_KEY. A provider appears in the app once all its settings are present. Callback URLs default to APP_URL/auth/callback/<provider> and webhooks to APP_URL/api/provider/<provider>/webhook; the *_REDIRECT_URI and POLAR_WEBHOOK_URL variables override them.
Activities from connected devices, including historical imports, are fetched by background jobs that run inside the API container (ENABLE_ACTIVITY_IMPORT and ENABLE_ACTIVITY_PROCESSING, both on by default). To run them in a separate worker instead, start a second API container with these two variables set to true and SKIP_MIGRATIONS=true, and set them to false on the API.
AI features
AI features (workout generation and editing, post-activity questions, feedback analysis, load estimation) run on any of 200+ providers: OpenAI, Anthropic, Google, Mistral, OpenRouter, Groq, DeepSeek, OVHcloud, Scaleway and more.
-
Each user can bring their own key: in Settings > AI, they add an API key and pick the model of each feature. Keys are encrypted with a key derived from
HASH_PEPPERand never sent back to the browser. -
Instance keys (optional) are used by users without a key of their own. Set the provider's usual variable (
OPENAI_API_KEY,ANTHROPIC_API_KEY,GOOGLE_GENERATIVE_AI_API_KEY,MISTRAL_API_KEY,OPENROUTER_API_KEY...) and the model:ANTHROPIC_API_KEY=sk-ant-... AI_MODEL_DEFAULT=anthropic/claude-sonnet-4-5 # every feature on one providerWithout
AI_MODEL_DEFAULT, every feature usesopenai/gpt-5.6-luna, soOPENAI_API_KEYis enough.AI_MODEL_EVENT_GENERATION,AI_MODEL_EVENT_MODIFICATION,AI_MODEL_POST_ACTIVITY_FEEDBACK,AI_MODEL_FEEDBACK_EXTRACTIONandAI_MODEL_TRIMP_ESTIMATIONoverride one feature. A feature whose model has no key configured is simply unavailable on the instance keys. -
Who may use the instance keys:
AI_HOSTED_ACCESS=everyone(default without Stripe),subscribers(users whose plan includes AI), ornone. -
Monthly budget:
AI_HOSTED_MONTHLY_BUDGET_USDcaps what each user may spend per month on the instance keys, in US dollars at the providers' prices, for example3. Calls are charged at their model's price (input and output tokens apart); a model the API does not know is charged like a flagship model. Once the budget is used, features run only on the user's own key until the next month. Unset, usage is not limited. Users see the share they used in Settings > AI. -
Local models: users can add any OpenAI-compatible endpoint, such as Ollama (
http://ollama:11434/v1), vLLM or LM Studio. This is on by default without Stripe. SetAI_ALLOW_CUSTOM_ENDPOINTS=falseon an instance open to the public: the server calls these URLs on users' behalf.
For background features (post-activity questions, feedback analysis, load estimation), an athlete without AI uses their coach's key or plan.
AI agents (MCP)
Users can connect AI agents (Claude, ChatGPT, Cursor...) to their account
from Settings › AI agents (guide). Agents
reach the MCP server at APP_URL/api/mcp and sign in through OAuth on the
same address: nothing to configure, and no data goes to the OpenAthlete
project.
- The web container forwards
/.well-known/oauth-*to the API, where agents look for the OAuth discovery documents. A proxy in front must forward these paths too, as it does for the rest of the domain. - With the API on its own address, set
API_PUBLIC_URLto it, for examplehttps://api.example.com: agents then connect tohttps://api.example.com/mcp. - Agents register themselves (OAuth Dynamic Client Registration, or Client ID Metadata Documents, which the API fetches from public https addresses only). Clients nobody approved are deleted after a month.
HTTPS and reverse proxies
The https profile runs Caddy with automatic certificates (see the quick start). With your own proxy (Traefik, nginx, Coolify...), forward the whole domain to the web container and keep WEB_PORT on localhost, for example WEB_PORT=127.0.0.1:8080. The API trusts X-Forwarded-For only from proxies on private networks, which covers a proxy on the same host or Docker network. If another public proxy sits in front (for example Cloudflare), set TRUST_PROXY to the number of proxy hops so rate limiting sees real client IPs.
Sign-up
SIGNUP_MODE decides who can create an account, with a password or with Google:
| Value | Who can sign up |
|---|---|
open (default) | Anyone who reaches the instance |
invite | Only people a coach or athlete invited, through the link of their invitation |
closed | Nobody |
The first account of the instance is always allowed: it is yours. The install script sets invite when it sets up a public domain. Existing accounts keep signing in whatever the mode.
Administration
Commands run inside the API container:
docker compose exec api openathlete list-users
docker compose exec api openathlete reset-password ana@example.com
docker compose exec api openathlete delete-user ana@example.com --yeslist-userslists the accounts with their id, name, roles and creation date.reset-passwordprints a link to set a new password, valid 15 minutes, to pass on to the person.delete-userdeletes the account and all its data, as the user would from Settings; it asks for--yes.
Without email (SMTP_HOST and BREVO_API_KEY empty), "Forgot your password?" still works: the reset link is written to the API logs instead of being sent, and the app tells the user to ask you for it. Find it with docker compose logs api | grep "reset link".
Updating
Run the install script again, or:
docker compose pull
docker compose up -dRead the release notes first: they list new variables and anything to change.
Upgrading a setup from before version 1.1 (built from a clone with docker compose up -d --build):
- Replace
docker-compose.ymlwith the new one: it runs the published images. Keep your.envand your data, which lives in Docker volumes. - Remove
API_PUBLIC_URLandCORS_ORIGINSfrom.env: the app now reaches the API underAPP_URL/api. If you keep a separate API address, the API port is now published on localhost only: setAPI_PORT=3000to publish it on every interface as before. - Leave
POSTGRES_PASSWORDempty: your database was created with the former default. - PostgreSQL (5433) and Redis (6379) are no longer published on the host; use
docker compose execfor backups. JWT_SECRET_KEYandHASH_PEPPERare required. KeepHASH_PEPPER=dev-pepper-change-in-production-min-32-charsif your instance used that former default: changing the pepper invalidates every password.
Manual deployment
API (NestJS)
pnpm install
pnpm database run db:generate
pnpm shared build
pnpm api build
pnpm database run db:deploy # apply migrationsConfigure the API with the variables from apps/api/.env.example (at least DATABASE_URL, JWT_SECRET_KEY, HASH_PEPPER, ENV and NODE_ENV), then start it with pnpm api start:prod.
Web app (React + Vite)
VITE_API_BASE_URL=https://api.your-domain.com pnpm web buildServe apps/web/dist with any static web server, falling back to index.html for client-side routes:
server {
listen 80;
server_name your-domain.com;
root /path/to/apps/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}Backups
Back up the database regularly, and keep .env somewhere safe:
docker compose exec -T postgres pg_dump -U openathlete -Fc openathlete > openathlete-$(date +%F).dumpTo restore a dump into a fresh instance:
docker compose up -d postgres
docker compose exec -T postgres pg_restore -U openathlete -d openathlete --clean --if-exists < openathlete-2026-10-05.dump
docker compose up -dTroubleshooting
The API container keeps restarting. Run docker compose logs api: the API lists every invalid or missing variable at startup.
CORS or WebSocket errors in the browser. APP_URL must be the exact address users open, including the scheme and port. With a separate API address, CORS_ORIGINS must list it too.
Links in emails point to the wrong host. Set APP_URL to the public URL of the web app.
The web app calls the wrong API URL. Remove API_PUBLIC_URL from .env (the app then uses /api), then recreate the web container: docker compose up -d --force-recreate web.
Device connection fails with a redirect error. The callback URL declared at the provider must match APP_URL/auth/callback/<provider> exactly.
Next steps
- Architecture Overview - Understand the system
- API Reference - Integrate with your instance
- Contributing - Help improve OpenAthlete