OpenAthleteOpenAthlete

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

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

Caddy 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 /api and /socket.io to the API, so APP_URL is 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-api and ghcr.io/openathleteorg/openathlete-web. OPENATHLETE_VERSION=1 follows every 1.x release; set it to a version such as 1.1.0 to 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 .env

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

FeatureVariables
Transactional email (password reset, invitations)SMTP_* and EMAIL_FROM, or BREVO_API_KEY (see below)
An email to you for each new accountSIGNUP_NOTIFICATION_EMAIL (needs email)
Device syncSTRAVA_*, GARMIN_*, POLAR_*, SUUNTO_*
AI load estimation of planned sessionsENABLE_TRAINING_LOAD_ESTIMATION
BillingSTRIPE_* (not needed for self-hosting)

Email

Any SMTP server works: your mail provider's, a relay such as Postmark or Amazon SES, or your own.

VariableValue
SMTP_HOSTThe server, e.g. smtp.example.org. Setting it turns email on.
SMTP_PORTDefault: 465 when SMTP_SECURE=true, else 587
SMTP_SECUREtrue for implicit TLS (port 465). Otherwise the connection upgrades with STARTTLS when the server offers it.
SMTP_USERUser name, if the server asks for one
SMTP_PASSWORDPassword
EMAIL_FROMThe 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).

ProviderDeveloper programCallback URL to declare
Stravastrava.com/settings/api, open to anyone (limited to a few athletes until Strava reviews the app)Authorization domain: your domain
Polaradmin.polaraccesslink.com, open to anyoneAPP_URL/auth/callback/polar
GarminGarmin Connect Developer Program, business approval requiredAPP_URL/auth/callback/garmin
SuuntoSuunto API Zone, partner approval requiredAPP_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_PEPPER and 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 provider

    Without AI_MODEL_DEFAULT, every feature uses openai/gpt-5.6-luna, so OPENAI_API_KEY is enough. AI_MODEL_EVENT_GENERATION, AI_MODEL_EVENT_MODIFICATION, AI_MODEL_POST_ACTIVITY_FEEDBACK, AI_MODEL_FEEDBACK_EXTRACTION and AI_MODEL_TRIMP_ESTIMATION override 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), or none.

  • Monthly budget: AI_HOSTED_MONTHLY_BUDGET_USD caps what each user may spend per month on the instance keys, in US dollars at the providers' prices, for example 3. 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. Set AI_ALLOW_CUSTOM_ENDPOINTS=false on 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_URL to it, for example https://api.example.com: agents then connect to https://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:

ValueWho can sign up
open (default)Anyone who reaches the instance
inviteOnly people a coach or athlete invited, through the link of their invitation
closedNobody

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 --yes
  • list-users lists the accounts with their id, name, roles and creation date.
  • reset-password prints a link to set a new password, valid 15 minutes, to pass on to the person.
  • delete-user deletes 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 -d

Read 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.yml with the new one: it runs the published images. Keep your .env and your data, which lives in Docker volumes.
  • Remove API_PUBLIC_URL and CORS_ORIGINS from .env: the app now reaches the API under APP_URL/api. If you keep a separate API address, the API port is now published on localhost only: set API_PORT=3000 to publish it on every interface as before.
  • Leave POSTGRES_PASSWORD empty: your database was created with the former default.
  • PostgreSQL (5433) and Redis (6379) are no longer published on the host; use docker compose exec for backups.
  • JWT_SECRET_KEY and HASH_PEPPER are required. Keep HASH_PEPPER=dev-pepper-change-in-production-min-32-chars if 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 migrations

Configure 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 build

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

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

Troubleshooting

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

On this page