Can I self-host Strava?
YES · ONE EVENING— setup effort 2 of 4YES — it's called FitTrackee. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $11.99 a month you stop paying Strava — $143.88 a year on the Subscription plan.
Why people pay for Strava
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Strava's product is not the GPS trace, it is everyone else's. Segments only mean something because thousands of people have ridden them, kudos only arrive because your friends are already there, and the year in review is compelling because it ranks you against a crowd. The subscription buys the analysis on top of that, but the crowd is the thing, and it is the one part no server of your own can hold.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | Records activities and shows a feed. Segment leaderboards, most route planning and the deeper analysis sit behind the subscription. |
| Subscriptionthe plan this page prices against | $11.99/mo | The monthly-billed rate. $79.99 a year on the annual plan, which works out at $6.67 a month. |
| Family | $11.67/mo | Sold annually only, at $139.99 a year for up to four people. The monthly figure here is that annual price divided by twelve, because there is no month-to-month option. |
| Strava + Runna | $12.50/mo | Sold annually only, at $149.99 a year, bundling the Runna training-plan app. The monthly figure is that annual price divided by twelve. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by FitTrackee
One project, named before the prompt, so you know what you are about to install.
A training log on your own server: your .gpx and .fit files, on a map, with the statistics and none of the leaderboard.
The only one here that is a server you log into rather than a desktop app or a companion dashboard. It takes .gpx, .fit, .tcx and .kml files, draws the route on a map, keeps per-sport statistics and records, and puts every workout on one global map. What it cannot give you is the reason most people pay: there are no segments because nobody else is on your server, and the kudos never come. Take it as a training log you own, not as Strava with the bill removed.
The swap
You'd run
FitTrackee
ONE EVENING · ~90 min to running · 2048 MB RAM
Strava Subscription · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same FitTrackee: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
323 lines · 14,920 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install FitTrackee 1.3.4 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and it becomes `UI_URL`, the address FitTrackee
writes into the links it generates.
FitTrackee needs 2048 MB of RAM available and 10 GB free on /srv. The application image
publishes amd64 and arm64, but PostGIS is a mandatory prerequisite and the PostGIS image
publishes amd64 only, in upstream's own words, so this install is amd64 only. Measure all four
first:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dpkg --print-architecture` prints anything but `amd64`, print it and
stop: there is no PostGIS image for that architecture. If `dig +short` prints nothing, print
that and stop: Caddy cannot get a certificate for a name that does not resolve, and failed
attempts count against a rate limit.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/fittrackee /srv/fittrackee/backups
sudo install -d -m 755 -o 1000 -g 1000 /srv/fittrackee/uploads /srv/fittrackee/staticmap_cache
sudo install -d -m 700 /srv/fittrackee/postgres
ls -la /srv/fittrackee
```
Assert: `ls -la` shows `backups` owned by the login user, `uploads` and `staticmap_cache` owned
by uid `1000`, and `postgres` at mode `drwx------` owned by root. The image runs as uid 1000 and
upstream requires both of those directories writable by it, which is why they are chowned to a
number rather than to whoever is logged in. Leave `postgres` to root: the database image chowns
its own data directory on first start.
## 3. Secrets
Two secrets, both generated here: the PostgreSQL password and `APP_SECRET_KEY`, which upstream
documents as the key used in JWT generation. Do not print either, do not repeat them in your
summary, and keep them out of every log line. Hex, not base64: one travels inside a database
connection string.
```bash
umask 077
cat > /srv/fittrackee/.env <<EOF
UI_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 48)
EOF
chmod 600 /srv/fittrackee/.env
umask 022
ls -l /srv/fittrackee/.env
```
Assert: the file exists with mode `-rw-------`. Docker Compose reads it for the `${...}`
substitutions in compose.yml whenever it runs from /srv/fittrackee and passes it to the
application container, so one password reaches both the database and the connection string.
## 4. compose.yml
```bash
cat > /srv/fittrackee/compose.yml <<'EOF'
# FitTrackee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install . https://docs.fittrackee.org/en/installation/installation.html
# variables ...... https://docs.fittrackee.org/en/installation/environments_variables.html
# emails ......... https://docs.fittrackee.org/en/installation/emails.html
#
# Two services. PostGIS 3.4+ is a mandatory prerequisite, so the database image
# is postgis/postgis, which enables the extension in POSTGRES_DB on first start
# and publishes linux/amd64 only, in upstream's own words. PostgreSQL 18 moved
# PGDATA, so the mount is /var/lib/postgresql. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
fittrackee-db:
image: postgis/postgis:18-3.6-alpine@sha256:22e5371710d26bae9b4f3b28f962bcfddecbf8ba8c9e8357ece4ca18858ede28
platform: linux/amd64
container_name: fittrackee-db
restart: unless-stopped
environment:
POSTGRES_DB: fittrackee
POSTGRES_USER: fittrackee
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/fittrackee/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fittrackee -d fittrackee"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
fittrackee:
image: fittrackee/fittrackee:v1.3.4@sha256:87ebf6879eccad561e84b257eb1ec825030030d6b0142fbaef0048c7d8cc29ba
container_name: fittrackee
restart: unless-stopped
env_file: /srv/fittrackee/.env
environment:
FLASK_APP: fittrackee
FLASK_SKIP_DOTENV: "1"
DATABASE_URL: postgresql://fittrackee:${POSTGRES_PASSWORD}@fittrackee-db:5432/fittrackee
UPLOAD_FOLDER: /usr/src/app/uploads
STATICMAP_CACHE_DIR: /usr/src/app/.staticmap_cache
# Console logging, so /usr/src/app/logs never has to exist.
GUNICORN_LOG: "-"
# Empty on purpose: no mail server, so no Redis and no worker.
EMAIL_URL: ""
command: sh docker-entrypoint.sh
volumes:
- /srv/fittrackee/uploads:/usr/src/app/uploads
- /srv/fittrackee/staticmap_cache:/usr/src/app/.staticmap_cache
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8129.
- "127.0.0.1:8129:5000"
depends_on:
fittrackee-db:
condition: service_healthy
EOF
cd /srv/fittrackee && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, two bind mounts, no Redis:
upstream documents Redis and a worker for rate limits, email and data-export archives, and this
install runs neither.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-fittrackee
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# FitTrackee · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.fittrackee.org/en/installation/deployment.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also UI_URL in
# .env, so the two have to stay the same string.
<DOMAIN> {
# The workout list is a JavaScript bundle and the API answers JSON.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: every workout map pulls tiles from
# tile.openstreetmap.org, and one written without testing that breaks
# the maps. Caddy sets no request body limit either, so an upload
# ceiling raised in FitTrackee's Administration page needs nothing here.
# 8129 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8129
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-fittrackee, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it itself. Nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp
is HTTP/3. 8129 stays closed because compose binds it to 127.0.0.1, and 5432 because compose
never publishes it at all. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule mentioning 8129 or 5432.
## 7. Start and verify
FitTrackee runs its migrations on the way up, which takes longer on the first start than on any
later one. Registration also ships open, because upstream's active-users limit is 0 and 0 means
no limit, so this block starts the service and sets that limit to one in the same pass. There is
no environment variable and no CLI command for the limit, and the API that changes it needs an
administrator who does not exist yet, so it goes into the row directly and the container is
restarted to pick it up.
```bash
cd /srv/fittrackee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/check-db
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -c "UPDATE app_config SET max_users = 1;"
docker compose restart fittrackee
sleep 20
curl -sS https://<DOMAIN>/api/config
```
Assert all five, and print what you received for each: the loop ends on `200`; `/api/check-db`
returns `"db available"`; the title line prints `<title>FitTrackee</title>`; psql prints
`UPDATE 1`; the config JSON contains `"version":"1.3.4"`, `"max_users":1` and
`"is_registration_enabled":true`. That last pair is correct, not contradictory: FitTrackee
allows a registration while the account count is under the limit, so exactly one person can
still sign up, and that person is the user. If anything misses, stop, run
`docker compose logs --tail 40 fittrackee` and `docker compose logs --tail 20 fittrackee-db`,
and name the likely earlier step: a database that never reports healthy points at step 2, `502`
means Caddy reaches nothing on 8129, `"db unavailable"` points at the password in step 3.
STOP: tell the user to open https://<DOMAIN>/register, create their account with a username,
their email address and a password of at least 8 characters, and wait. Do not continue until
they confirm. Warn them first that FitTrackee will say the account needs confirming by email,
that this server sends no mail, and that they cannot sign in until the next command runs.
```bash
cd /srv/fittrackee
FTUSER=$(docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -tAc "SELECT username FROM users ORDER BY id LIMIT 1")
echo "account: $FTUSER"
docker compose exec -T fittrackee ftcli users update "$FTUSER" --set-role owner
curl -sS https://<DOMAIN>/api/config
```
Assert all three: `account:` prints the username the user typed, the CLI exits 0, and the config
JSON now reads `"is_registration_enabled":false`. That last one is the security assert in this
block: with one account against a limit of one, FitTrackee closes its own registration form and
an unauthenticated endpoint says so. If it still reads `true`, stop and say so plainly rather
than reporting success. The owner role also activates the account, upstream's documented answer
on an instance with no mail.
The first screen at https://<DOMAIN> shows a `Login` heading over an `Email` box and a
`Password` box, with `Forgot password?` beneath them and no `Register` link.
https://<DOMAIN>/register now answers `Sorry, registration is disabled.`
STOP: tell the user to sign in at https://<DOMAIN> and confirm their dashboard loads, and wait.
Do not continue until they confirm. A running container is not success.
## 8. First backup and restore
Two artifacts: the database holds every workout and everything computed from it, and the config
archive holds the uploaded track files plus the three files that rebuild the service.
```bash
cd /srv/fittrackee
docker compose exec -T fittrackee-db pg_dump -U fittrackee -d fittrackee | gzip > /srv/fittrackee/backups/fittrackee-db-$(date +%F).sql.gz
sudo tar -czf /srv/fittrackee/backups/fittrackee-config-$(date +%F).tar.gz -C /srv/fittrackee compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/fittrackee/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. A backup on the same disk is not a backup, so run
this one from the user's machine, not the server:
```bash
mkdir -p ~/backups/fittrackee
scp vps:/srv/fittrackee/backups/* ~/backups/fittrackee/
```
To restore: `docker compose down`, `sudo rm -rf /srv/fittrackee/postgres`, recreate it as in
step 2, `docker compose up -d fittrackee-db`, wait for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee`, untar
the config archive into /srv/fittrackee, then `docker compose up -d`. Tell the user what matters
at 2am: the tracks are in `uploads`, everything computed from them is in the database, and the
two archives are only useful together.
## 9. Updating later
New versions are listed at https://github.com/SamR1/FitTrackee/releases. Migrations run at
start-up, so upstream asks you to back up before changing the image. Take both artifacts, then
edit the image line in /srv/fittrackee/compose.yml to the new tag and digest:
```bash
cd /srv/fittrackee
docker compose pull
docker compose up -d
docker compose logs --tail 30 fittrackee
```
Watch that log until the migrations settle, then re-run the `/api/config` check from step 7 and
confirm `version` matches the pinned tag.
## 10. What will probably go wrong
The user will register, get told to check their email, find nothing, and conclude the install is
broken. I did. It is not: FitTrackee creates every account inactive and mails a confirmation
link, this server sends no mail on purpose, and the sign-in screen answers an inactive account
with a message that reads exactly like a wrong password. The `ftcli users update` command in
step 7 clears it, and it has to run after the account exists. The same command activates anyone
the user adds later, and that is the whole account system here.
## 11. Out of scope
- Do not configure SMTP, and do not add Redis or the Dramatiq worker. Upstream states a
single-user instance runs with an empty `EMAIL_URL` and needs neither; adding them is two more
services for mail one CLI command already replaces.
- Do not set `WEATHER_API_PROVIDER` or `WEATHER_API_KEY`. Weather on a workout needs an account
with a third-party forecast service, a signup this install exists to avoid.
- Do not set `TILE_SERVER_URL` to a keyed provider. The default OpenStreetMap tile server needs
no account, and its usage policy is the user's decision, not yours.
- Do not enable OAuth 2.0 applications. That connects third-party clients later, and it is not
part of getting the first workout onto this server.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing FitTrackee 1.3.4 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
Read this before step 1. FitTrackee needs PostGIS, and PostGIS publishes no ARM image, which
upstream says in those words. This install therefore runs on an amd64 server only. If your VPS
is an Ampere or Graviton instance, stop here: nothing below will work, and building a PostGIS
image yourself is a different afternoon.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, the word `amd64`, and your
server's IP on the last line.
If you do not: `arm64` means this install stops here, for the reason in the paragraph above. An
empty last line means the A record does not exist yet, so add it, wait a minute and run
`dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB is the
one to take seriously: this runs a Python application server next to a PostgreSQL with the
PostGIS extension loaded, and the OOM killer arriving during your first import looks like a
random failure rather than a decision you made at checkout.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/fittrackee /srv/fittrackee/backups
sudo install -d -m 755 -o 1000 -g 1000 /srv/fittrackee/uploads /srv/fittrackee/staticmap_cache
sudo install -d -m 700 /srv/fittrackee/postgres
ls -la /srv/fittrackee
```
You should see: `backups` owned by you, `uploads` and `staticmap_cache` owned by `1000`, and
`postgres` at mode `drwx------` owned by root.
If you do not: leave those ownerships alone rather than tidying them. The FitTrackee image runs
as uid 1000 and upstream requires both of those directories writable by it, so a directory
owned by you instead produces a permission error the first time a track file is uploaded, which
is hours after the thing that caused it. `postgres` stays root-owned because the database image
chowns its own data directory the first time it starts.
## 3. Secrets
Two secrets: the PostgreSQL password and `APP_SECRET_KEY`, upstream's key for JWT generation.
Both are generated here, on the server, and both go straight into a file only you can read.
Replace `<DOMAIN>` on the first line with your real hostname before you paste.
```bash
umask 077
cat > /srv/fittrackee/.env <<EOF
UI_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 48)
EOF
chmod 600 /srv/fittrackee/.env
umask 022
ls -l /srv/fittrackee/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/fittrackee/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both values, which is harmless before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `POSTGRES_PASSWORD` against an existing
data directory shows up as a connection failure in the FitTrackee log rather than as anything
mentioning passwords.
Do not paste that file, either secret, or any command output containing them into this chat
window. Nothing below needs you to read them out: Docker Compose reads the file itself.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/fittrackee/compose.yml <<'EOF'
# FitTrackee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install . https://docs.fittrackee.org/en/installation/installation.html
# variables ...... https://docs.fittrackee.org/en/installation/environments_variables.html
# emails ......... https://docs.fittrackee.org/en/installation/emails.html
#
# Two services. PostGIS 3.4+ is a mandatory prerequisite, so the database image
# is postgis/postgis, which enables the extension in POSTGRES_DB on first start
# and publishes linux/amd64 only, in upstream's own words. PostgreSQL 18 moved
# PGDATA, so the mount is /var/lib/postgresql. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
fittrackee-db:
image: postgis/postgis:18-3.6-alpine@sha256:22e5371710d26bae9b4f3b28f962bcfddecbf8ba8c9e8357ece4ca18858ede28
platform: linux/amd64
container_name: fittrackee-db
restart: unless-stopped
environment:
POSTGRES_DB: fittrackee
POSTGRES_USER: fittrackee
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/fittrackee/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fittrackee -d fittrackee"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
fittrackee:
image: fittrackee/fittrackee:v1.3.4@sha256:87ebf6879eccad561e84b257eb1ec825030030d6b0142fbaef0048c7d8cc29ba
container_name: fittrackee
restart: unless-stopped
env_file: /srv/fittrackee/.env
environment:
FLASK_APP: fittrackee
FLASK_SKIP_DOTENV: "1"
DATABASE_URL: postgresql://fittrackee:${POSTGRES_PASSWORD}@fittrackee-db:5432/fittrackee
UPLOAD_FOLDER: /usr/src/app/uploads
STATICMAP_CACHE_DIR: /usr/src/app/.staticmap_cache
# Console logging, so /usr/src/app/logs never has to exist.
GUNICORN_LOG: "-"
# Empty on purpose: no mail server, so no Redis and no worker.
EMAIL_URL: ""
command: sh docker-entrypoint.sh
volumes:
- /srv/fittrackee/uploads:/usr/src/app/uploads
- /srv/fittrackee/staticmap_cache:/usr/src/app/.staticmap_cache
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8129.
- "127.0.0.1:8129:5000"
depends_on:
fittrackee-db:
condition: service_healthy
EOF
cd /srv/fittrackee && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/fittrackee/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal,
so run `rm /srv/fittrackee/compose.yml` and paste again in one go. Two things in that file are
worth knowing before you change anything. The database image is `postgis/postgis` rather than
plain `postgres`, because PostGIS 3.4 or later is a mandatory prerequisite and the extension is
created by that image on first start. And there is no Redis and no worker container, because
upstream states that a single-user instance turns email sending off with an empty `EMAIL_URL`
and then needs neither.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-fittrackee
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# FitTrackee · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.fittrackee.org/en/installation/deployment.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also UI_URL in
# .env, so the two have to stay the same string.
<DOMAIN> {
# The workout list is a JavaScript bundle and the API answers JSON.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: every workout map pulls tiles from
# tile.openstreetmap.org, and one written without testing that breaks
# the maps. Caddy sets no request body limit either, so an upload
# ceiling raised in FitTrackee's Administration page needs nothing here.
# 8129 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8129
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-fittrackee /etc/caddy/Caddyfile`,
reload, and paste again. Caddy requests the certificate the first time somebody asks for the
hostname and renews it on its own, so there is nothing to schedule and no path to hardcode.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8129` or `5432`.
If you do not: delete anything for `8129` with `sudo ufw delete allow 8129`. 8129 is bound to
127.0.0.1 by the compose file and 5432 is never published at all, so the database has no host
port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer the
ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.
## 7. Start and verify
FitTrackee runs its migrations on the way up, which takes longer on the first start than on any
later one. Registration also ships open, because upstream's active-users limit is 0 and 0 means
no limit, so this block starts the service and sets that limit to one in the same pass. There is
no environment variable and no CLI command for that limit, and the API that changes it needs an
administrator who does not exist yet, so it goes into the row directly and the container is
restarted to pick it up.
```bash
cd /srv/fittrackee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/check-db
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -c "UPDATE app_config SET max_users = 1;"
docker compose restart fittrackee
sleep 20
curl -sS https://<DOMAIN>/api/config
```
You should see, in order: the loop reaching `200`, then `"db available"`, then
`<title>FitTrackee</title>`, then `UPDATE 1`, then a JSON object containing `"version":"1.3.4"`,
`"max_users":1` and `"is_registration_enabled":true`.
If you do not: that last pair is not a contradiction. FitTrackee allows a registration while the
account count is under the limit, so exactly one person can still sign up, and that person is
you. A `502` where you expected `200` means Caddy is reaching nothing on 8129, so check
`docker compose ps`. If the loop never reaches `200`, run
`docker compose logs --tail 20 fittrackee-db` first, because a database that never reports
healthy is step 2 done wrong, and `docker compose logs --tail 40 fittrackee` second, where the
migrations print as they run. `"db unavailable"` from a database that is healthy points back at
step 3.
Now open https://<DOMAIN>/register in a browser and create your account: a username, your email
address, and a password of at least 8 characters.
You should see: a message telling you to check your email to confirm the account.
If you do not: read that message rather than acting on it. This server sends no mail, so no
confirmation email is coming and you cannot sign in yet. That is expected, and the next block
fixes it. Do not register a second account trying to get past it.
```bash
cd /srv/fittrackee
FTUSER=$(docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -tAc "SELECT username FROM users ORDER BY id LIMIT 1")
echo "account: $FTUSER"
docker compose exec -T fittrackee ftcli users update "$FTUSER" --set-role owner
curl -sS https://<DOMAIN>/api/config
```
You should see: `account:` followed by the username you typed into the browser a minute ago,
CLI reporting the change, then a JSON object in which `"is_registration_enabled"` now reads
`false`.
If you do not: an empty `account:` means the registration did not go through, so go back and do
it before running this again. That `false` is the assert that matters in this whole file, and it
decides whether this install is safe to leave running: with one account against a limit of one,
FitTrackee has closed its own registration form, and an endpoint that needs no credential says
so. If it still reads `true`, stop and work out why before you put anything into this server.
Now sign in at https://<DOMAIN>.
You should see: a `Login` heading over an `Email` box and a `Password` box, with
`Forgot password?` beneath them and no `Register` link, and after signing in, your dashboard.
Opening https://<DOMAIN>/register now answers `Sorry, registration is disabled.`
If you do not: an account that still refuses your password after the CLI ran means the CLI acted
on a different username, so re-read what `account:` printed. A running container is not success;
signing in is.
## 8. First backup and restore
Two artifacts: the database holds every workout and everything computed from it, and the config
archive holds the uploaded track files plus the three files that rebuild the service.
```bash
cd /srv/fittrackee
docker compose exec -T fittrackee-db pg_dump -U fittrackee -d fittrackee | gzip > /srv/fittrackee/backups/fittrackee-db-$(date +%F).sql.gz
sudo tar -czf /srv/fittrackee/backups/fittrackee-config-$(date +%F).tar.gz -C /srv/fittrackee compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/fittrackee/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/fittrackee
scp vps:/srv/fittrackee/backups/* ~/backups/fittrackee/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/fittrackee/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty account:
```bash
cd /srv/fittrackee
docker compose down
sudo rm -rf /srv/fittrackee/postgres
sudo install -d -m 700 /srv/fittrackee/postgres
docker compose up -d fittrackee-db
sleep 40
gunzip -c /srv/fittrackee/backups/fittrackee-db-$(date +%F).sql.gz | docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/api/config
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then a config object still reading
`"is_registration_enabled":false`, which means your account survived a database that was deleted
and rebuilt.
If you do not: `role "fittrackee" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what you are proving:
the tracks live in `uploads` and everything computed from them lives in the database, so the two
archives are only useful together, and a restore that skips one of them is not a restore.
## 9. Updating later
New versions are listed at https://github.com/SamR1/FitTrackee/releases. Migrations run at
start-up, so upstream asks you to back up before changing the image. Take both artifacts, then
edit the `image:` line in /srv/fittrackee/compose.yml to the new tag and its digest.
```bash
cd /srv/fittrackee
docker compose pull
docker compose up -d
docker compose logs --tail 30 fittrackee
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/api/config` check from step 7 and confirm `version` matches the tag you pinned, because a
container that answers on the health endpoint can still have stopped halfway through a
migration.
## 10. What will probably go wrong
You will register, get told to check your email, find nothing, and conclude the install is
broken. I did. It is not: FitTrackee creates every account inactive and mails a confirmation
link, this server sends no mail on purpose, and the sign-in screen answers an inactive account
with a message that reads exactly like a wrong password. The `ftcli users update` command in
step 7 clears it, and it has to run after the account exists rather than before. The same
command activates anyone you add later, and that is the whole account system here.
## 11. Out of scope
- Do not configure SMTP, and do not add Redis or the Dramatiq worker. Upstream states a
single-user instance runs with an empty `EMAIL_URL` and needs neither; adding them is two more
services for mail one CLI command already replaces.
- Do not set `WEATHER_API_PROVIDER` or `WEATHER_API_KEY`. Weather on a workout needs an account
with a third-party forecast service, a signup this install exists to avoid.
- Do not set `TILE_SERVER_URL` to a keyed provider. The default OpenStreetMap tile server needs
no account, and its usage policy is your decision.
- Do not enable OAuth 2.0 applications. That connects third-party clients later, and it is not
part of getting the first workout onto this server.332 lines · 15,867 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install FitTrackee 1.3.4, with the PostgreSQL it stores workouts in, under
~/selfhost/fittrackee, answering at http://localhost:8129.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
FitTrackee answers only at http://localhost:8129, so the phone that recorded the ride cannot
reach it: every workout arrives as a file copied onto this machine and uploaded in a browser.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; uname -m; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. This needs
2048 MB of RAM available and 10 GB free on the home disk. PostGIS is a mandatory prerequisite
and its image publishes linux/amd64 only, in upstream's own words, so an Apple Silicon Mac runs
the database under Docker Desktop's emulation. Stop on a Linux machine where `uname -m` prints
`aarch64`: no emulation layer is there by default. If available RAM is under 2048 MB or free
disk under 10 GB, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/fittrackee/uploads ~/selfhost/fittrackee/staticmap_cache ~/selfhost/fittrackee/backups
if [ "$(uname -s)" = "Linux" ]; then
sudo chown -R 1000:1000 ~/selfhost/fittrackee/uploads ~/selfhost/fittrackee/staticmap_cache
fi
ls -la ~/selfhost/fittrackee
```
Assert: `ls -la` shows all three. The container runs as uid 1000 and upstream requires the first
two writable by it, so on Linux they are chowned to match; on macOS and Windows Docker Desktop
maps ownership itself and the fence is a no-op. The database lives in a volume Docker manages,
so there is no folder for it here.
## 4. Secrets
Two secrets: the PostgreSQL password and `APP_SECRET_KEY`, upstream's key for JWT generation.
Generate both here, print neither, keep both out of your summary and any log line.
```bash
umask 077
cat > ~/selfhost/fittrackee/.env <<EOF
UI_URL=http://localhost:8129
POSTGRES_PASSWORD=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 48)
EOF
chmod 600 ~/selfhost/fittrackee/.env
umask 022
ls -l ~/selfhost/fittrackee/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. Compose reads it for the `${...}` substitutions in compose.yml and
passes it to the container, so one password reaches both the database and the connection string.
On Windows those mode bits are advisory: NTFS does not enforce them, and the user's own account
is the real boundary.
## 5. compose.yml
```bash
cat > ~/selfhost/fittrackee/compose.yml <<'EOF'
# FitTrackee · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install . https://docs.fittrackee.org/en/installation/installation.html
# variables ...... https://docs.fittrackee.org/en/installation/environments_variables.html
# emails ......... https://docs.fittrackee.org/en/installation/emails.html
#
# Two services, every path relative to ~/selfhost/fittrackee/ so one file works
# on macOS, Linux and Windows. PostGIS 3.4+ is a mandatory prerequisite, so the
# database image is postgis/postgis, which publishes linux/amd64 only, in
# upstream's own words. It is a named volume, not a bind mount, because
# PostgreSQL chowns its data directory. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
fittrackee-db:
image: postgis/postgis:18-3.6-alpine@sha256:22e5371710d26bae9b4f3b28f962bcfddecbf8ba8c9e8357ece4ca18858ede28
platform: linux/amd64
container_name: fittrackee-db
restart: unless-stopped
environment:
POSTGRES_DB: fittrackee
POSTGRES_USER: fittrackee
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- fittrackee-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fittrackee -d fittrackee"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
fittrackee:
image: fittrackee/fittrackee:v1.3.4@sha256:87ebf6879eccad561e84b257eb1ec825030030d6b0142fbaef0048c7d8cc29ba
container_name: fittrackee
restart: unless-stopped
env_file: ./.env
environment:
FLASK_APP: fittrackee
FLASK_SKIP_DOTENV: "1"
DATABASE_URL: postgresql://fittrackee:${POSTGRES_PASSWORD}@fittrackee-db:5432/fittrackee
UPLOAD_FOLDER: /usr/src/app/uploads
STATICMAP_CACHE_DIR: /usr/src/app/.staticmap_cache
# Console logging, so /usr/src/app/logs never has to exist.
GUNICORN_LOG: "-"
# Empty on purpose: no mail server, so no Redis and no worker.
EMAIL_URL: ""
command: sh docker-entrypoint.sh
volumes:
- ./uploads:/usr/src/app/uploads
- ./staticmap_cache:/usr/src/app/.staticmap_cache
ports:
# Loopback only: no other device on the wifi can reach 8129.
- "127.0.0.1:8129:5000"
depends_on:
fittrackee-db:
condition: service_healthy
volumes:
fittrackee-pgdata:
EOF
cd ~/selfhost/fittrackee && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, two bind mounts, one named
volume, no Redis.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one; browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work. Nothing is
published beyond loopback, so no port needs closing: 8129 is bound to 127.0.0.1, which is not the
user's phone, not a laptop on the wifi, and not anyone on the internet. Confirm that:
```bash
grep -n '127.0.0.1' ~/selfhost/fittrackee/compose.yml
```
Assert: one line, `- "127.0.0.1:8129:5000"`. PostgreSQL publishes no host port, so 5432 cannot
appear.
## 7. Start and verify
FitTrackee runs its migrations on the way up, slowest on the first start and slower again where
the database is emulated. Registration also ships open, because upstream's active-users limit is
0 and 0 means no limit, so this block starts the service and sets that limit to one in the same
pass, writing it into the row because there is no environment variable and no CLI command for
it.
```bash
cd ~/selfhost/fittrackee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8129/api/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8129/api/check-db
curl -sS http://localhost:8129/ | grep -o '<title>[^<]*</title>'
docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -c "UPDATE app_config SET max_users = 1;"
docker compose restart fittrackee
sleep 20
curl -sS http://localhost:8129/api/config
```
Assert all five, and print what you received for each: the loop ends on `200`; `/api/check-db`
returns `"db available"`; the title line prints `<title>FitTrackee</title>`; psql prints
`UPDATE 1`; the config JSON contains `"version":"1.3.4"`, `"max_users":1` and
`"is_registration_enabled":true`. That last pair is correct: FitTrackee allows a registration
while the account count is under the limit, so exactly one person can still sign up. If anything
misses, stop, run `docker compose logs --tail 40 fittrackee` and
`docker compose logs --tail 20 fittrackee-db`, and name the likely cause: a database that never
reports healthy points at step 4, where an empty `POSTGRES_PASSWORD` leaves PostgreSQL refusing
to start. On `port is already allocated`, find what holds 8129 with
`lsof -nP -iTCP:8129 -sTCP:LISTEN`.
STOP: tell the user to open http://localhost:8129/register, create their account with a
username, their email address and a password of at least 8 characters, and wait. Do not continue
until they confirm. Warn them first that FitTrackee will say the account needs confirming by
email, that this install sends no mail, and that they cannot sign in until the next command
runs.
```bash
cd ~/selfhost/fittrackee
FTUSER=$(docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -tAc "SELECT username FROM users ORDER BY id LIMIT 1")
echo "account: $FTUSER"
docker compose exec -T fittrackee ftcli users update "$FTUSER" --set-role owner
curl -sS http://localhost:8129/api/config
```
Assert all three: `account:` prints the username the user typed, the CLI exits 0, and the config
JSON now reads `"is_registration_enabled":false`. That is the security assert here: with one
account against a limit of one, FitTrackee closes its own registration form. The owner role also
activates the account, upstream's answer on an instance with no mail.
The first screen at http://localhost:8129 shows a `Login` heading over an `Email` box and a
`Password` box, with `Forgot password?` beneath them and no `Register` link.
http://localhost:8129/register now answers `Sorry, registration is disabled.`
STOP: tell the user to sign in at http://localhost:8129 and confirm their dashboard loads, and
wait. Do not continue until they confirm. A running container is not success.
## 8. First backup and restore
Two artifacts: the database holds every workout and everything computed from it, the config
archive the track files and the two files that rebuild the service.
```bash
cd ~/selfhost/fittrackee
docker compose exec -T fittrackee-db pg_dump -U fittrackee -d fittrackee | gzip > ~/selfhost/fittrackee/backups/fittrackee-db-$(date +%F).sql.gz
tar -C ~/selfhost/fittrackee -czf ~/selfhost/fittrackee/backups/fittrackee-config-$(date +%F).tar.gz compose.yml .env uploads
ls -lh ~/selfhost/fittrackee/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.
Both sit on the same disk as the data, which is not a backup, and on a laptop the disk and the
machine fail together. Ask the user for a destination that leaves this computer, a folder their
sync service watches or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive
is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are listed
there. If they have neither, say plainly that this install has no backup.
To restore, in this order. `cd ~/selfhost/fittrackee` and untar the config archive there first,
so compose.yml, .env and the tracks are back before any container starts: PostgreSQL takes
`POSTGRES_PASSWORD` from .env the moment it initialises an empty volume. Then
`docker compose down -v`, the one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d fittrackee-db`, wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee`, then
`docker compose up -d`. Open one workout and check its map draws.
## 9. Updating later
New versions are listed at https://github.com/SamR1/FitTrackee/releases. Migrations run at
start-up, so upstream asks you to back up first. Take both artifacts, then edit the image line in
~/selfhost/fittrackee/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/fittrackee
docker compose pull
docker compose up -d
docker compose logs --tail 30 fittrackee
```
Watch that log until the migrations settle, then re-run step 7's `/api/config` check and confirm
`version` matches the pinned tag.
## 10. What will probably go wrong
On an Apple Silicon Mac the first `docker compose up -d` looks like a hang. It is not: the
PostGIS image is amd64 and Docker Desktop is translating it instruction by instruction while
PostgreSQL initialises a cluster and FitTrackee runs every migration it has ever shipped. I gave
up at four minutes, came back, and found it healthy. Let step 7's loop run all forty attempts
before touching anything, and if it still fails read `docker compose logs --tail 40 fittrackee-db`
rather than restarting. Later starts take seconds.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8129 to 0.0.0.0 so a phone on the wifi can reach it. That puts a login form
with no TLS on every network the user joins.
- Do not configure SMTP, and do not add Redis or the Dramatiq worker.
- Do not set `WEATHER_API_PROVIDER`, `WEATHER_API_KEY` or a keyed `TILE_SERVER_URL`. Each means
a third-party account this install exists to avoid.compose.local.ymlthe services, pinned · local layout60 lines
# FitTrackee · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install . https://docs.fittrackee.org/en/installation/installation.html
# variables ...... https://docs.fittrackee.org/en/installation/environments_variables.html
# emails ......... https://docs.fittrackee.org/en/installation/emails.html
#
# Two services, every path relative to ~/selfhost/fittrackee/ so one file works
# on macOS, Linux and Windows. PostGIS 3.4+ is a mandatory prerequisite, so the
# database image is postgis/postgis, which publishes linux/amd64 only, in
# upstream's own words. It is a named volume, not a bind mount, because
# PostgreSQL chowns its data directory. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
fittrackee-db:
image: postgis/postgis:18-3.6-alpine@sha256:22e5371710d26bae9b4f3b28f962bcfddecbf8ba8c9e8357ece4ca18858ede28
platform: linux/amd64
container_name: fittrackee-db
restart: unless-stopped
environment:
POSTGRES_DB: fittrackee
POSTGRES_USER: fittrackee
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- fittrackee-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fittrackee -d fittrackee"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
fittrackee:
image: fittrackee/fittrackee:v1.3.4@sha256:87ebf6879eccad561e84b257eb1ec825030030d6b0142fbaef0048c7d8cc29ba
container_name: fittrackee
restart: unless-stopped
env_file: ./.env
environment:
FLASK_APP: fittrackee
FLASK_SKIP_DOTENV: "1"
DATABASE_URL: postgresql://fittrackee:${POSTGRES_PASSWORD}@fittrackee-db:5432/fittrackee
UPLOAD_FOLDER: /usr/src/app/uploads
STATICMAP_CACHE_DIR: /usr/src/app/.staticmap_cache
# Console logging, so /usr/src/app/logs never has to exist.
GUNICORN_LOG: "-"
# Empty on purpose: no mail server, so no Redis and no worker.
EMAIL_URL: ""
command: sh docker-entrypoint.sh
volumes:
- ./uploads:/usr/src/app/uploads
- ./staticmap_cache:/usr/src/app/.staticmap_cache
ports:
# Loopback only: no other device on the wifi can reach 8129.
- "127.0.0.1:8129:5000"
depends_on:
fittrackee-db:
condition: service_healthy
volumes:
fittrackee-pgdata:agent-readable mirror: /self-host/strava.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned56 lines
# FitTrackee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install . https://docs.fittrackee.org/en/installation/installation.html
# variables ...... https://docs.fittrackee.org/en/installation/environments_variables.html
# emails ......... https://docs.fittrackee.org/en/installation/emails.html
#
# Two services. PostGIS 3.4+ is a mandatory prerequisite, so the database image
# is postgis/postgis, which enables the extension in POSTGRES_DB on first start
# and publishes linux/amd64 only, in upstream's own words. PostgreSQL 18 moved
# PGDATA, so the mount is /var/lib/postgresql. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
fittrackee-db:
image: postgis/postgis:18-3.6-alpine@sha256:22e5371710d26bae9b4f3b28f962bcfddecbf8ba8c9e8357ece4ca18858ede28
platform: linux/amd64
container_name: fittrackee-db
restart: unless-stopped
environment:
POSTGRES_DB: fittrackee
POSTGRES_USER: fittrackee
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/fittrackee/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fittrackee -d fittrackee"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
fittrackee:
image: fittrackee/fittrackee:v1.3.4@sha256:87ebf6879eccad561e84b257eb1ec825030030d6b0142fbaef0048c7d8cc29ba
container_name: fittrackee
restart: unless-stopped
env_file: /srv/fittrackee/.env
environment:
FLASK_APP: fittrackee
FLASK_SKIP_DOTENV: "1"
DATABASE_URL: postgresql://fittrackee:${POSTGRES_PASSWORD}@fittrackee-db:5432/fittrackee
UPLOAD_FOLDER: /usr/src/app/uploads
STATICMAP_CACHE_DIR: /usr/src/app/.staticmap_cache
# Console logging, so /usr/src/app/logs never has to exist.
GUNICORN_LOG: "-"
# Empty on purpose: no mail server, so no Redis and no worker.
EMAIL_URL: ""
command: sh docker-entrypoint.sh
volumes:
- /srv/fittrackee/uploads:/usr/src/app/uploads
- /srv/fittrackee/staticmap_cache:/usr/src/app/.staticmap_cache
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8129.
- "127.0.0.1:8129:5000"
depends_on:
fittrackee-db:
condition: service_healthyCaddyfilethe hostname and TLS28 lines
# FitTrackee · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.fittrackee.org/en/installation/deployment.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also UI_URL in
# .env, so the two have to stay the same string.
<DOMAIN> {
# The workout list is a JavaScript bundle and the API answers JSON.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: every workout map pulls tiles from
# tile.openstreetmap.org, and one written without testing that breaks
# the maps. Caddy sets no request body limit either, so an upload
# ceiling raised in FitTrackee's Administration page needs nothing here.
# 8129 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8129
}install.shthe same install, no agent177 lines
#!/usr/bin/env bash
# FitTrackee · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=fit.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.fittrackee.org/en/installation/index.html
# https://docs.fittrackee.org/en/installation/installation.html
# https://docs.fittrackee.org/en/installation/environments_variables.html
# https://docs.fittrackee.org/en/installation/emails.html
# https://docs.fittrackee.org/en/api/health_check.html
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# APP_SECRET_KEY. Both go into /srv/fittrackee/.env with mode 600 and neither is
# ever printed.
#
# This install is amd64 only. PostGIS is a mandatory prerequisite and upstream
# states there is no official PostGIS image for ARM platforms.
#
# The script stops after setting the active-users limit to one. Creating the
# first account is a browser step only a human can do, and activating it needs
# that account to exist first; the closing summary says which two commands
# finish the job.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/fittrackee}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. fit.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this server is ${arch}; PostGIS publishes no ARM image, so this install stops here"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Python plus PostGIS wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The image runs as uid 1000, and upstream requires the uploads and static-map
# directories writable by that user. /srv/fittrackee/postgres stays root-owned
# because the database image chowns its own data directory on first start.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 -o 1000 -g 1000 "$APP_DIR/uploads" "$APP_DIR/staticmap_cache"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64: the database password travels inside a connection
# string. Read them later with
# sudo grep -E 'POSTGRES_PASSWORD|APP_SECRET_KEY' /srv/fittrackee/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
UI_URL=https://${DOMAIN_HOST}
POSTGRES_PASSWORD=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 48)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-fittrackee"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8129 nor 5432 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8129 and 5432 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The entrypoint runs `ftcli db upgrade` before gunicorn, so the first start is
# slower than any later one.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/ping"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/ping" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/ping answered ${code:-nothing}. Check: docker compose logs --tail 40 fittrackee"
curl -sS "https://${DOMAIN_HOST}/api/check-db" | grep -q '"db available"' \
|| die "/api/check-db did not report the database available. Check: docker compose logs --tail 20 fittrackee-db"
curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>FitTrackee</title>' \
|| die "the root URL did not serve the FitTrackee page. Check that Caddy is reaching 127.0.0.1:8129"
# --- 7. Close the door before anyone can walk through it ---------------------
#
# Upstream ships max_users at 0, which means no limit, so registration is open.
# Setting it to 1 lets exactly one account be created and closes the form the
# moment it exists. There is no environment variable and no CLI for this
# setting, and the API needs an administrator who does not exist yet.
docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -c "UPDATE app_config SET max_users = 1;" >/dev/null
docker compose restart fittrackee
sleep 20
curl -sS "https://${DOMAIN_HOST}/api/config" | grep -q '"max_users": *1[,}]' \
|| die "the active-users limit did not take. Stop: registration is still open on a public hostname."
# --- 8. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T fittrackee-db pg_dump -U fittrackee -d fittrackee | gzip > "$APP_DIR/backups/fittrackee-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/fittrackee-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/fittrackee-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
FitTrackee is answering at https://${DOMAIN_HOST}
1. Create your account now, in a browser, at
https://${DOMAIN_HOST}/register
It will tell you to check your email. No email is coming: this
install sends no mail on purpose, and every new account starts
inactive. Then run these two lines to activate it and make it the
owner:
cd $APP_DIR
docker compose exec -T fittrackee ftcli users update "\$(docker compose exec -T fittrackee-db psql -U fittrackee -d fittrackee -tAc 'SELECT username FROM users ORDER BY id LIMIT 1')" --set-role owner
2. Then confirm the door is shut:
curl -sS https://${DOMAIN_HOST}/api/config
"is_registration_enabled" must read false. One account against a
limit of one closes the registration form. If it reads true, stop
and find out why before you upload anything.
3. Your secrets are in $APP_DIR/.env, mode 600. Read them with
sudo grep -E 'POSTGRES_PASSWORD|APP_SECRET_KEY' $APP_DIR/.env
Neither was printed here. Your login password is the one you type
into the browser and is not in that file.
4. First backup written to $APP_DIR/backups: a database dump and a
config archive holding compose.yml, .env, uploads and the Caddy
config. They are on the same disk as the data, which is not a
backup. Copy them somewhere else tonight, and copy both: the tracks
are in uploads and everything computed from them is in the database.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Strava.
- Nobody is on it but you. Segments, leaderboards, kudos and the friends already in your feed are what the subscription actually sells, and no server of your own can hold them. FitTrackee has comments and likes, for the people who also have an account on your machine, which on a single-user install is nobody. What you get instead is every ride you have ever recorded, on one map, that nobody can rank you on.
- You bring the files. Nothing here records anything: you record with a watch or a phone app, export .gpx, .fit, .tcx or .kml, and upload it. Upstream's own README points at four Android apps for the recording half and names none for iPhone, and there is no first-party mobile app on either platform. If your watch only syncs to one vendor's cloud, that step is yours to solve before this is useful.
- amd64 only. PostGIS is a mandatory prerequisite and upstream states there is no official PostGIS image for ARM, so a Raspberry Pi or an Ampere VPS is out. On an Apple Silicon Mac the local path works under Docker Desktop's emulation, slowly on the first start.
- No mail, and that is a choice with consequences. Every new account starts inactive waiting for a confirmation email this install does not send, so activating one is a command you run on the server. Password reset by email does not work either. Without the Redis upstream uses for rate limiting, nothing throttles repeated login attempts, so the password on that account is the whole defence.
- Your history is two things, not one. The track files sit in an uploads directory and everything computed from them sits in PostgreSQL, so a backup that takes one and not the other restores nothing. The in-app data export needs the worker this install does not run; the backup step here takes both halves instead.
Where this came from
“On single-user instance, it is possible to disable email sending with an empty EMAIL_URL (in this case, no need to start Dramatiq workers).”
- PostGIS 3.4 or later is a mandatory prerequisite alongside PostgreSQL 14 or later, which is why this install runs the postgis image rather than plain postgres. source
- Upstream states there is no official PostGIS image for ARM platforms yet and that the workaround is to build one locally, which is why this install is amd64 only. source
- Upstream states that a single-user instance can disable email sending with an empty EMAIL_URL and then needs no Dramatiq workers, which is why this install runs two containers and no Redis. source
- The active users limit ships at 0, which means registration is enabled with no restrictions, so a fresh install has an open signup form until the limit is set. source
- A new account is inactive until its confirmation email is followed, and where email sending is not configured a command line activates it instead. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Strava?
Not Strava itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is FitTrackee. A training log on your own server: your .gpx and .fit files, on a map, with the statistics and none of the leaderboard. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Strava?
FitTrackee. A training log on your own server: your .gpx and .fit files, on a map, with the statistics and none of the leaderboard. The only one here that is a server you log into rather than a desktop app or a companion dashboard. It takes .gpx, .fit, .tcx and .kml files, draws the route on a map, keeps per-sport statistics and records, and puts every workout on one global map. What it cannot give you is the reason most people pay: there are no segments because nobody else is on your server, and the kudos never come. Take it as a training log you own, not as Strava with the bill removed. FitTrackee is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Strava?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. FitTrackee itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Strava Subscription, $11.99/mo — $143.88 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the FitTrackee install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run FitTrackee on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same FitTrackee on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer FitTrackee answers only at http://localhost:8129, so the phone that recorded the ride cannot reach it: every workout arrives as a file you copy onto that one machine and upload in a browser. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.