Can I self-host MyFitnessPal Premium?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called wger. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $19.99 a month you stop paying MyFitnessPal Premium — $239.88 a year on the Premium plan.
Why people pay for MyFitnessPal Premium
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.
MyFitnessPal sells the shortest path between a barcode and a logged meal. The company advertises 20.5 million foods, and the reason people pay rather than switch is that the packet in their hand is already in that database with the right serving size, so logging dinner takes four seconds instead of four minutes. Scanning that barcode moved behind the paywall on 1 October 2022, along with custom macro targets and an ad-free app, and the subscription is mostly a fee for keeping the habit frictionless.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | Calorie and macro logging by typing a food name, with ads. Barcode scanning was moved to Premium on 1 October 2022. |
| Premiumthe plan this page prices against | $19.99/mo | $79.99 a year on the annual plan. Barcode scanning, custom macro goals, no ads. The US App Store lists both figures as in-app purchases. |
| Premium+ | $24.99/mo | $99.99 a year on the annual plan. Adds meal planning and a recipe library. This is the tier the pricing page led with on the day we read it. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by wger
One project, named before the prompt, so you know what you are about to install.
A gym log and a food diary on one server: routines, body measurements, and a barcode lookup that asks Open Food Facts instead of a subscription.
The only one here that does both halves: a food diary with a barcode lookup, and a workout manager with routines, progression rules and body measurements. The barcode path is the honest surprise, because an unknown code is fetched live from Open Food Facts by your own server rather than looked up in a licensed database. What you give up is coverage and the phone app: the ingredient table you sync from wger.de held 3,063,298 entries on the day we checked against MyFitnessPal's advertised 20.5 million, it leans European and packaged rather than American restaurant menus, and the mobile app in the stores needs a sync service this install does not run.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- wgerour pickONE WEEKEND— setup effort 3 of 4
A gym log and a food diary on one server: routines, body measurements, and a barcode lookup that asks Open Food Facts instead of a subscription.
The only one here that does both halves: a food diary with a barcode lookup, and a workout manager with routines, progression rules and body measurements. The barcode path is the honest surprise, because an unknown code is fetched live from Open Food Facts by your own server rather than looked up in a licensed database. What you give up is coverage and the phone app: the ingredient table you sync from wger.de held 3,063,298 entries on the day we checked against MyFitnessPal's advertised 20.5 million, it leans European and packaged rather than American restaurant menus, and the mobile app in the stores needs a sync service this install does not run.
- FitTrackeeONE EVENING— setup effort 2 of 4
A training log on your own server: your .gpx and .fit files, on a map, with the statistics and none of the leaderboard.
Second only if the half you actually used was the exercise diary. FitTrackee is a GPS training log built around .gpx and .fit files on a map, and it has no food database, no barcode anything and no calorie target. Ranked here because people do cancel MyFitnessPal for a workout log, and if that is you it is a much smaller thing to run than wger.
The swap
You'd run
wger
ONE WEEKEND · ~240 min to running · 2048 MB RAM
MyFitnessPal Premium Premium · 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
- ~240 min3–24 hours, through the first backup
The prompt
Two paths to the same wger: 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
371 lines · 14,597 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 wger 2.6.0 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 here. wger also needs a hostname of its own: upstream states it
does not work in a subdirectory.
wger needs 2048 MB of RAM available and 10 GB free on /srv, and all four images publish amd64
and arm64. Its compose file carries an inline nginx config, so Docker Compose has to be 2.23 or
newer.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
docker compose version
dig +short <DOMAIN>
```
Print the numbers and stop if RAM is under 2048 MB, disk is under 10 GB, compose is older than
2.23, or `dig +short` prints nothing.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wger /srv/wger/backups
sudo install -d -m 700 /srv/wger/postgres
sudo install -d -m 755 -o 1000 -g 1000 /srv/wger/static /srv/wger/media
ls -la /srv/wger
```
Assert: `backups` owned by the login user, `postgres` at mode `700` owned by root, `static` and
`media` owned by uid `1000`. PostgreSQL chowns its own data directory, so leave it alone. The
other two follow upstream: a folder of static files belongs to UID and GID 1000, whether or not
that user exists here, and is readable by everyone. Get it wrong and the site loads unstyled.
## 3. Secrets
Four secrets, all generated here. Do not print any of them, do not repeat them in your summary,
and keep them out of log lines. Hex for the first three: each passes through a file a shell and
a compose parser both read.
```bash
umask 077
cat > /srv/wger/.env <<EOF
SITE_URL=https://<DOMAIN>
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
TIME_ZONE=UTC
TZ=UTC
SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WGER_ADMIN_PASSWORD=$(openssl rand -hex 20)
EOF
chmod 600 /srv/wger/.env
umask 022
ls -l /srv/wger/.env
```
The fourth is the RSA keypair that signs API tokens, in the shape only wger makes. It pulls the
image, so allow a few minutes:
```bash
docker run --rm wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01 python3 manage.py generate-jwt-keys | grep -E '^JWT_(PRIVATE|PUBLIC)_KEY=' >> /srv/wger/.env
grep -c '^JWT_' /srv/wger/.env
```
Assert: mode `-rw-------`, and that prints `2`. Upstream ships a default keypair in its public
repository and says to replace it.
## 4. compose.yml
```bash
cat > /srv/wger/compose.yml <<'EOF'
# wger · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ... https://wger.readthedocs.io/en/latest/installation/docker.html
# settings .. https://wger.readthedocs.io/en/latest/administration/settings.html
# static .... https://wger.readthedocs.io/en/latest/administration/errors.html
#
# Four services. Django serves none of its own static files in production, so
# nginx reads the directory collectstatic writes into, and nginx alone
# publishes a host port. Two services upstream ships are absent on purpose:
# the celery worker and beat scheduler, marked optional on its architecture
# page, and PowerSync, which costs the phone app its sync. Digests read
# 2026-08-06, all four publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
restart: unless-stopped
environment:
POSTGRES_DB: wger
POSTGRES_USER: wger
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: UTC
volumes:
- /srv/wger/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wger -d wger"]
interval: 10s
retries: 12
cache:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
retries: 12
web:
image: wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01
restart: unless-stopped
env_file: /srv/wger/.env
environment:
DJANGO_DB_ENGINE: django.db.backends.postgresql
DJANGO_DB_DATABASE: wger
DJANGO_DB_USER: wger
DJANGO_DB_PASSWORD: ${POSTGRES_PASSWORD}
DJANGO_DB_HOST: db
DJANGO_DB_PORT: 5432
DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache
DJANGO_CACHE_LOCATION: redis://cache:6379/1
DJANGO_CACHE_CLIENT_CLASS: django_redis.client.DefaultClient
# Caddy terminates TLS, nginx states the scheme, and two proxies stand
# between a visitor and gunicorn.
X_FORWARDED_PROTO_HEADER_SET: "True"
NUMBER_OF_PROXIES: "2"
AXES_IPWARE_PROXY_COUNT: "2"
AXES_IPWARE_META_PRECEDENCE_ORDER: HTTP_X_FORWARDED_FOR,REMOTE_ADDR
ALLOW_REGISTRATION: "False"
ALLOW_GUEST_USERS: "False"
USE_CELERY: "False"
DJANGO_CLEAR_STATIC_FIRST: "False"
WGER_USE_GUNICORN: "True"
volumes:
- /srv/wger/static:/home/wger/static
- /srv/wger/media:/home/wger/media
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/api/v2/version/"]
interval: 15s
timeout: 10s
retries: 40
# A first start migrates and collects static files, so allow minutes.
start_period: 300s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
configs:
- source: wger-nginx
target: /etc/nginx/conf.d/default.conf
volumes:
- /srv/wger/static:/wger/static:ro
- /srv/wger/media:/wger/media:ro
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8147.
- "127.0.0.1:8147:80"
depends_on:
web:
condition: service_started
configs:
wger-nginx:
content: |
# A doubled dollar sign is how compose escapes the one nginx wants.
server {
listen 80;
client_max_body_size 100M;
location /static/ { alias /wger/static/; }
location /media/ { alias /wger/media/; }
location / {
proxy_pass http://web:8000;
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
EOF
cd /srv/wger && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`.
## 5. Caddy and TLS
Append the block below, with `<DOMAIN>` replaced by the real hostname, to the Caddyfile Prompt
Zero installed. Copy it first: a syntax error takes down every site here.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wger
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wger · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://wger.readthedocs.io/en/latest/installation/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SITE_URL and CSRF_TRUSTED_ORIGINS in .env: Django refuses a form post
# from an origin nobody told it about, so all three have to agree. This block
# proxies everything and serves nothing; nginx inside the stack has the static
# and media files.
<DOMAIN> {
# HSTS because there is a login form on every path.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-Server
}
# 8147 is the loopback port compose publishes and nginx answers on. It is
# not a container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8147
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-wger, reload, and
report the objection. Caddy asks for the certificate on the first request and renews it alone.
## 6. Firewall
Two ports open, both Caddy's, and idempotent:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3. 8147 is bound
to loopback and 5432 and 6379 are never published, so none of the three belongs here. Assert:
`Status: active`, rules for 80, 443/tcp and 443/udp, nothing for 8147, 5432 or 6379.
## 7. Start and verify
```bash
cd /srv/wger
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v2/version/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v2/version/
```
Assert: the loop ends printing `200` and the last line prints `"2.6.0"`, quotes included.
The image creates an `admin` account whose password upstream publishes in its install guide.
Give it the one step 3 made and prove the published one is dead, printing neither:
```bash
docker compose exec -T web python3 manage.py shell -c "import os;from django.contrib.auth.models import User;u=User.objects.get(username='admin');u.set_password(os.environ['WGER_ADMIN_PASSWORD']);u.save();u.refresh_from_db();print('default rejected' if not u.check_password('adminadmin') else 'DEFAULT STILL ACCEPTED')"
```
Assert: `default rejected`. On `DEFAULT STILL ACCEPTED`, stop: this box is on the internet with
a credential anyone can look up.
Now load a food database and check the path end to end:
```bash
docker compose exec -T web wger load-online-fixtures
curl -sS 'https://<DOMAIN>/api/v2/ingredient/?limit=1' | head -c 100
curl -sS https://<DOMAIN>/en/user/login > /tmp/wger-login.html
grep -o '<h2 class="mb-1">Login</h2>' /tmp/wger-login.html; grep -c 'user/registration' /tmp/wger-login.html
asset=$(grep -oE '/static/[^"]+\.css' /tmp/wger-login.html | head -1); curl -sS -o /dev/null -w "$asset %{http_code}\n" "https://<DOMAIN>$asset"
```
Assert all four, printing what you got for each: an ingredient count above zero;
`<h2 class="mb-1">Login</h2>`, the first screen a human sees; `0` from the registration grep,
so signup is closed and this instance has one account; a `/static/` path and `200`, where a
`404` means an unstyled site and step 2 at fault. If any of the four misses, stop, run
`docker compose logs --tail 40 web`, then `docker compose logs --tail 20 nginx`, and name the
step. A running container is not success.
STOP: tell the user to read their password with `grep WGER_ADMIN_PASSWORD /srv/wger/.env`, put
it in their password manager, and log in at https://<DOMAIN>/en/user/login as `admin`.
Do not continue until they confirm the dashboard loaded.
## 8. First backup and restore
PostgreSQL holds every workout and every meal; the config archive holds what rebuilds the
service around it.
```bash
cd /srv/wger
docker compose exec -T db pg_dump -U wger -d wger | gzip > /srv/wger/backups/wger-db-$(date +%F).sql.gz
sudo tar -czf /srv/wger/backups/wger-config-$(date +%F).tar.gz -C /srv/wger compose.yml .env media -C /etc/caddy Caddyfile
ls -lh /srv/wger/backups/
```
Assert: both files exist, both are non-empty, print both sizes. The dump runs live because
`pg_dump` takes a consistent snapshot. Static files are in neither archive: upstream rebuilds
them on each start.
A backup on the same disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/wger
scp vps:/srv/wger/backups/* ~/backups/wger/
```
To restore: `docker compose down`, `sudo rm -rf /srv/wger/postgres`, recreate it as in step 2,
`docker compose up -d db`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U wger -d wger`, untar the config archive into /srv/wger, then
`docker compose up -d`. Most of that dump is the ingredient table; the workouts and the diary
are what nobody can fetch again.
## 9. Updating later
New versions are listed at https://github.com/wger-project/wger/releases. Take both backups
first, then edit the `wger/server` image line in /srv/wger/compose.yml to the new tag and
digest:
```bash
cd /srv/wger
docker compose pull
docker compose up -d
docker compose logs --tail 30 web
```
wger migrates its own database on the way up, so watch that log until it settles, then re-run
step 7's checks. If the interface comes back half-styled, set `DJANGO_CLEAR_STATIC_FIRST` to
`True` for one restart.
## 10. What will probably go wrong
For the first several minutes https://<DOMAIN> answers `502` and reads like a failed install. I
sat through it twice before I believed it: on an empty database that container migrates, loads
the exercise fixtures and processes every static file before gunicorn binds a port, and
upstream's own health check allows five minutes for it. nginx is up and answering the whole
time, which is what makes it read as broken rather than slow. Run `docker compose logs -f web`
and wait for the line about gunicorn on port 8000 before doubting anything.
## 11. Out of scope
- Do not add the PowerSync service, though upstream's compose file has one. It wants logical
replication, its own database role, sync-rule files this install does not carry, and a
compaction job on a schedule. Without it the phone app logs in and then says sync is
unavailable: the trade this install makes.
- Do not add the celery worker or the beat scheduler. Upstream marks both optional and the
application does that work synchronously without them.
- Do not run `sync-ingredients-bulk` here. Upstream sizes the full ingredient dataset at around
1 GB of database and hours of work: the user's decision, later.
- Do not configure SMTP. Nothing here needs mail, and on a one-account instance the password
reset is a file on the 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 wger 2.6.0 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. wger has to live on a hostname of its own, because upstream states the application does
not work in a subdirectory.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
docker compose version
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, a
compose version of `2.23` or newer, and your server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does
not resolve. A compose version below 2.23 is the one that stops you here: step 4's file carries
its nginx configuration inline, which older versions cannot read, and the fix is to install the
compose plugin from download.docker.com rather than the distribution's older package. Under
2048 MB, wger will start and then be killed during the ingredient load in step 7.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wger /srv/wger/backups
sudo install -d -m 700 /srv/wger/postgres
sudo install -d -m 755 -o 1000 -g 1000 /srv/wger/static /srv/wger/media
ls -la /srv/wger
```
You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`static` and `media` at `drwxr-xr-x` owned by `1000`.
If you do not: leave `postgres` owned by root on purpose, because the PostgreSQL image chowns
its own data directory the first time it starts and one you have already chowned makes it
refuse to initialise. The `1000` on the other two is upstream's instruction: the container
writes its processed CSS there as uid 1000, and if that fails you get a site with no styling
and no error message anywhere.
## 3. Secrets
Four secrets, all generated on the server, three of them here and the fourth by wger itself.
Replace `<DOMAIN>` on the first two lines with your hostname before you paste.
```bash
umask 077
cat > /srv/wger/.env <<EOF
SITE_URL=https://<DOMAIN>
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
TIME_ZONE=UTC
TZ=UTC
SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WGER_ADMIN_PASSWORD=$(openssl rand -hex 20)
EOF
chmod 600 /srv/wger/.env
umask 022
ls -l /srv/wger/.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/wger/.env` and carry
on. If the file already existed from an earlier attempt, this block has now replaced all three
values, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed one produces an authentication error in the wger log
rather than anything mentioning passwords.
Do not paste that file, any of those values, or any command output containing them into this
chat window. The agent path never sees them; a chat window will keep them.
Now the fourth secret, the RSA keypair that signs API tokens. This pulls the image, so give it
a few minutes:
```bash
docker run --rm wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01 python3 manage.py generate-jwt-keys | grep -E '^JWT_(PRIVATE|PUBLIC)_KEY=' >> /srv/wger/.env
grep -c '^JWT_' /srv/wger/.env
```
You should see: the image download, then `2`.
If you do not: `0` means the command printed nothing, usually because the image failed to pull;
run it again and watch for the error. Upstream ships a default keypair in its public repository
and says to replace it, which is what this does. The two lines are long base64 blobs, and they
are secrets like the rest of the file.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/wger/compose.yml <<'EOF'
# wger · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ... https://wger.readthedocs.io/en/latest/installation/docker.html
# settings .. https://wger.readthedocs.io/en/latest/administration/settings.html
# static .... https://wger.readthedocs.io/en/latest/administration/errors.html
#
# Four services. Django serves none of its own static files in production, so
# nginx reads the directory collectstatic writes into, and nginx alone
# publishes a host port. Two services upstream ships are absent on purpose:
# the celery worker and beat scheduler, marked optional on its architecture
# page, and PowerSync, which costs the phone app its sync. Digests read
# 2026-08-06, all four publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
restart: unless-stopped
environment:
POSTGRES_DB: wger
POSTGRES_USER: wger
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: UTC
volumes:
- /srv/wger/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wger -d wger"]
interval: 10s
retries: 12
cache:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
retries: 12
web:
image: wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01
restart: unless-stopped
env_file: /srv/wger/.env
environment:
DJANGO_DB_ENGINE: django.db.backends.postgresql
DJANGO_DB_DATABASE: wger
DJANGO_DB_USER: wger
DJANGO_DB_PASSWORD: ${POSTGRES_PASSWORD}
DJANGO_DB_HOST: db
DJANGO_DB_PORT: 5432
DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache
DJANGO_CACHE_LOCATION: redis://cache:6379/1
DJANGO_CACHE_CLIENT_CLASS: django_redis.client.DefaultClient
# Caddy terminates TLS, nginx states the scheme, and two proxies stand
# between a visitor and gunicorn.
X_FORWARDED_PROTO_HEADER_SET: "True"
NUMBER_OF_PROXIES: "2"
AXES_IPWARE_PROXY_COUNT: "2"
AXES_IPWARE_META_PRECEDENCE_ORDER: HTTP_X_FORWARDED_FOR,REMOTE_ADDR
ALLOW_REGISTRATION: "False"
ALLOW_GUEST_USERS: "False"
USE_CELERY: "False"
DJANGO_CLEAR_STATIC_FIRST: "False"
WGER_USE_GUNICORN: "True"
volumes:
- /srv/wger/static:/home/wger/static
- /srv/wger/media:/home/wger/media
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/api/v2/version/"]
interval: 15s
timeout: 10s
retries: 40
# A first start migrates and collects static files, so allow minutes.
start_period: 300s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
configs:
- source: wger-nginx
target: /etc/nginx/conf.d/default.conf
volumes:
- /srv/wger/static:/wger/static:ro
- /srv/wger/media:/wger/media:ro
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8147.
- "127.0.0.1:8147:80"
depends_on:
web:
condition: service_started
configs:
wger-nginx:
content: |
# A doubled dollar sign is how compose escapes the one nginx wants.
server {
listen 80;
client_max_body_size 100M;
location /static/ { alias /wger/static/; }
location /media/ { alias /wger/media/; }
location / {
proxy_pass http://web:8000;
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
EOF
cd /srv/wger && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal; run `rm /srv/wger/compose.yml` and paste again in one go. An error mentioning
`configs` means your compose plugin predates 2.23 and cannot read the inline nginx
configuration. `env file /srv/wger/.env not found` means step 3 did not write the file.
## 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-wger
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wger · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://wger.readthedocs.io/en/latest/installation/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SITE_URL and CSRF_TRUSTED_ORIGINS in .env: Django refuses a form post
# from an origin nobody told it about, so all three have to agree. This block
# proxies everything and serves nothing; nginx inside the stack has the static
# and media files.
<DOMAIN> {
# HSTS because there is a login form on every path.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-Server
}
# 8147 is the loopback port compose publishes and nginx answers on. It is
# not a container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8147
}
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-wger /etc/caddy/Caddyfile`, reload, and
paste again. The hostname in this block, in `SITE_URL` and in `CSRF_TRUSTED_ORIGINS` all have
to be the same string, or the login form returns a CSRF error that says nothing useful about
which of the three is wrong.
## 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 `8147`, `5432` or `6379`.
If you do not: delete anything for those three with `sudo ufw delete allow 8147`. 8147 is bound
to 127.0.0.1 by the compose file and the database and cache are never published at all, so
there is no host port for a rule to apply to. `Status: inactive` is a different problem:
Prompt Zero left this firewall enabled, so something turned it off, and `sudo ufw enable` puts
it back before you go further.
## 7. Start and verify
The first start is slow. The web container migrates the database, loads the exercise fixtures,
creates its admin account and processes every static file before gunicorn answers anything.
```bash
cd /srv/wger
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v2/version/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v2/version/
```
You should see: the loop printing `502` for several minutes and then `200`, and the last
command printing `"2.6.0"` with the quotes.
If you do not: run `docker compose logs --tail 40 web`. `connection refused` from the database
means it never became healthy, which points back at step 2. A `502` that never clears after
fifteen minutes usually means collectstatic failed on the ownership of /srv/wger/static, and
the log line names the directory.
The image creates an account named `admin` with a password upstream publishes in its own
install guide. This gives it the password step 3 generated and then proves the published one no
longer works. It prints neither.
```bash
docker compose exec -T web python3 manage.py shell -c "import os;from django.contrib.auth.models import User;u=User.objects.get(username='admin');u.set_password(os.environ['WGER_ADMIN_PASSWORD']);u.save();u.refresh_from_db();print('default rejected' if not u.check_password('adminadmin') else 'DEFAULT STILL ACCEPTED')"
```
You should see: `default rejected`.
If you do not: `DEFAULT STILL ACCEPTED` means the password did not change and your server is on
the internet with a credential printed in a public document. Stop and fix that before anything
else. `KeyError` means `WGER_ADMIN_PASSWORD` is not in the container's environment, so step 3's
file was written after the container started: run `docker compose up -d --force-recreate web`
and try again.
Now load a food database to start from, and check the whole path end to end:
```bash
docker compose exec -T web wger load-online-fixtures
curl -sS 'https://<DOMAIN>/api/v2/ingredient/?limit=1' | head -c 100
curl -sS https://<DOMAIN>/en/user/login > /tmp/wger-login.html
grep -o '<h2 class="mb-1">Login</h2>' /tmp/wger-login.html; grep -c 'user/registration' /tmp/wger-login.html
asset=$(grep -oE '/static/[^"]+\.css' /tmp/wger-login.html | head -1); curl -sS -o /dev/null -w "$asset %{http_code}\n" "https://<DOMAIN>$asset"
```
You should see, in order: a progress bar while the fixture downloads, then a JSON object whose
`count` is a number above zero, then `<h2 class="mb-1">Login</h2>`, then `0`, then a
`/static/...css` path followed by `200`.
If you do not: the `0` is the one people misread. It means the registration link is absent,
which is correct here, because this install has one account and open signup on a fitness diary
is an invitation. A `404` on the last line is the failure that matters: the page loaded but its
stylesheet did not, so the site will look like unformatted text, and the cause is the ownership
of /srv/wger/static from step 2. An empty ingredient count means the fixture load failed; run
it again and read the error rather than continuing.
Open https://<DOMAIN>/en/user/login in a browser. The first screen shows the heading `Login`
and no register button. Read your password with `grep WGER_ADMIN_PASSWORD /srv/wger/.env`, put
it in your password manager, and log in as `admin`. A running container is not success; a
dashboard is.
## 8. First backup and restore
Two artifacts. PostgreSQL holds every workout, every meal and the ingredient table; the config
archive holds what rebuilds the service around it.
```bash
cd /srv/wger
docker compose exec -T db pg_dump -U wger -d wger | gzip > /srv/wger/backups/wger-db-$(date +%F).sql.gz
sudo tar -czf /srv/wger/backups/wger-config-$(date +%F).tar.gz -C /srv/wger compose.yml .env media -C /etc/caddy Caddyfile
ls -lh /srv/wger/backups/
```
You should see: two files, the database dump a few megabytes after the fixture load and the
config archive a few kilobytes. Nothing goes offline: `pg_dump` takes a consistent snapshot.
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/wger
scp vps:/srv/wger/backups/* ~/backups/wger/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/wger/`.
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 diary:
```bash
cd /srv/wger
docker compose down
sudo rm -rf /srv/wger/postgres
sudo install -d -m 700 /srv/wger/postgres
docker compose up -d db
sleep 30
gunzip -c /srv/wger/backups/wger-db-$(date +%F).sql.gz | docker compose exec -T db psql -U wger -d wger
docker compose up -d
sleep 60
curl -sS 'https://<DOMAIN>/api/v2/ingredient/?limit=1' | head -c 100
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then the same ingredient count as
before, which means the database survived being deleted and rebuilt.
If you do not: `role "wger" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Static files are in neither
archive on purpose, because upstream rebuilds them on every container start.
## 9. Updating later
New versions are listed at https://github.com/wger-project/wger/releases. Take both backup
artifacts first, then edit the `wger/server` image line in /srv/wger/compose.yml to the new tag
and its digest.
```bash
cd /srv/wger
docker compose pull
docker compose up -d
docker compose logs --tail 30 web
```
You should see: migration output, then the line about gunicorn on port 8000, and no repeating
restart.
If you do not: put the old tag and digest back and run the same three commands. If the
interface comes back half-styled, set `DJANGO_CLEAR_STATIC_FIRST` to `True` in compose.yml for
one restart, which makes collectstatic rebuild the directory instead of adding to it.
## 10. What will probably go wrong
For the first several minutes https://<DOMAIN> answers `502` and reads like a failed install. I
sat through it twice before I believed it: on an empty database that container runs migrations,
loads the exercise fixtures and processes every static file before gunicorn binds a port, and
upstream's own health check allows five minutes for it. nginx is up and answering the whole
time, which is what makes it read as broken rather than slow. Run `docker compose logs -f web`
and wait for the line about gunicorn on port 8000 before doubting anything.
## 11. Out of scope
- Do not add the PowerSync service, though upstream's compose file has one. It wants logical
replication, its own database role, sync-rule files this install does not carry, and a
compaction job on a schedule. Without it the phone app logs in and then says sync is
unavailable: the trade this install makes.
- Do not add the celery worker or the beat scheduler. Upstream marks both optional and the
application does that work synchronously without them.
- Do not run `sync-ingredients-bulk` here. Upstream sizes the full ingredient dataset at around
1 GB of database and hours of work: your decision, later.
- Do not configure SMTP. Nothing here needs mail, and on a one-account instance the password
reset is a file on the server.367 lines · 15,765 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 wger 2.6.0, with the PostgreSQL it keeps workouts and food in, under ~/selfhost/wger,
answering at http://localhost:8147.
## 1. Preflight
Say this before step 2 runs. wger answers at http://localhost:8147, which means this computer
and nowhere else, so the phone they would log lunch on cannot reach it: every meal and every
set gets typed here, while this machine is awake.
```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"; 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; on Linux the
distribution ID and codename print next, for step 2. These four containers want 2048 MB of RAM
available and 10 GB free on the home disk, and all four images publish amd64 and arm64. Under
either floor, print the numbers and stop, do not install and hope.
## 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 of 2.23 or newer, skip to step 3: step 5's
file has an inline nginx config older versions cannot read.
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/wger/backups ~/selfhost/wger/static ~/selfhost/wger/media
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" != "1000" ]; then sudo chown -R 1000:1000 ~/selfhost/wger/static ~/selfhost/wger/media; fi
ls -la ~/selfhost/wger
```
Assert: all three exist. The image runs as uid 1000 and writes its CSS into `static`, which
upstream requires to be owned by 1000:1000 and readable by everyone: that is the Linux-only
fence, and on macOS and Windows Docker Desktop owns it. Workouts and food are in a Docker
volume, so there is no `data` folder.
## 4. Secrets
Four secrets, all generated here. Print none of them, and keep them out of your summary and any
log line.
```bash
umask 077
cat > ~/selfhost/wger/.env <<EOF
SITE_URL=http://localhost:8147
CSRF_TRUSTED_ORIGINS=http://localhost:8147
TIME_ZONE=UTC
TZ=UTC
SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WGER_ADMIN_PASSWORD=$(openssl rand -hex 20)
EOF
chmod 600 ~/selfhost/wger/.env
umask 022
ls -l ~/selfhost/wger/.env
```
Git Bash ships openssl. On Windows the mode bits are advisory and the boundary is the user's
own account. The fourth secret is the RSA keypair signing API tokens, and it pulls the image:
```bash
docker run --rm wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01 python3 manage.py generate-jwt-keys | grep -E '^JWT_(PRIVATE|PUBLIC)_KEY=' >> ~/selfhost/wger/.env
grep -c '^JWT_' ~/selfhost/wger/.env
```
Assert: mode `-rw-------` and that prints `2`.
## 5. compose.yml
```bash
cat > ~/selfhost/wger/compose.yml <<'EOF'
# wger · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
# install ... https://wger.readthedocs.io/en/latest/installation/docker.html
# settings .. https://wger.readthedocs.io/en/latest/administration/settings.html
# static .... https://wger.readthedocs.io/en/latest/administration/errors.html
#
# Four services, every path relative to ~/selfhost/wger/ so one file works on
# macOS, Linux and Windows. The database is a named volume because PostgreSQL
# chowns its data directory to a uid a home bind mount cannot grant on
# Windows. nginx is here because Django serves no static files itself; the
# celery worker, beat scheduler and PowerSync upstream ships are not, which
# costs the weekly sync and the phone app. Digests read 2026-08-06, amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
restart: unless-stopped
environment:
POSTGRES_DB: wger
POSTGRES_USER: wger
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: UTC
volumes:
- wger-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wger -d wger"]
interval: 10s
retries: 12
cache:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
retries: 12
web:
image: wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01
restart: unless-stopped
env_file: ./.env
environment:
DJANGO_DB_ENGINE: django.db.backends.postgresql
DJANGO_DB_DATABASE: wger
DJANGO_DB_USER: wger
DJANGO_DB_PASSWORD: ${POSTGRES_PASSWORD}
DJANGO_DB_HOST: db
DJANGO_DB_PORT: 5432
DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache
DJANGO_CACHE_LOCATION: redis://cache:6379/1
DJANGO_CACHE_CLIENT_CLASS: django_redis.client.DefaultClient
NUMBER_OF_PROXIES: "1"
ALLOW_REGISTRATION: "False"
ALLOW_GUEST_USERS: "False"
USE_CELERY: "False"
DJANGO_CLEAR_STATIC_FIRST: "False"
WGER_USE_GUNICORN: "True"
volumes:
- ./static:/home/wger/static
- ./media:/home/wger/media
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/api/v2/version/"]
interval: 15s
timeout: 10s
retries: 40
# A first start migrates and collects static files, so allow minutes.
start_period: 300s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
configs:
- source: wger-nginx
target: /etc/nginx/conf.d/default.conf
volumes:
- ./static:/wger/static:ro
- ./media:/wger/media:ro
ports:
# Loopback only: no other device on the wifi can reach 8147.
- "127.0.0.1:8147:80"
depends_on:
web:
condition: service_started
configs:
wger-nginx:
content: |
# A doubled dollar sign is how compose escapes the one nginx wants.
server {
listen 80;
client_max_body_size 100M;
location /static/ { alias /wger/static/; }
location /media/ { alias /wger/media/; }
location / {
proxy_pass http://web:8000;
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto http;
}
}
volumes:
wger-pgdata:
EOF
cd ~/selfhost/wger && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Four services, one published port, one named volume.
## 6. Nothing is public
No hostname, so no DNS. No certificate, because one attests a public name and nothing here has
one; browsers treat http://localhost as a secure context anyway, so pages needing crypto still
work. No firewall rule, because nothing is published past loopback. nginx holds 8147 on
127.0.0.1 only because Django serves none of its own static files, and this computer is the
only thing that reaches it. Not the user's phone, not a laptop on the wifi, nobody at all.
```bash
grep -c '"127.0.0.1:' ~/selfhost/wger/compose.yml
```
Assert: `1`, the nginx line `- "127.0.0.1:8147:80"`. PostgreSQL, Redis and gunicorn publish no
host port at all.
## 7. Start and verify
```bash
cd ~/selfhost/wger
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8147/api/v2/version/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8147/api/v2/version/
```
Assert: the loop ends on `200` and the last line prints `"2.6.0"`, quotes included. It is slow
because the container migrates and processes every static file before gunicorn answers. The
image also makes an `admin` account whose password upstream publishes; give it the one step 4
made and prove the published one is dead, printing neither:
```bash
docker compose exec -T web python3 manage.py shell -c "import os;from django.contrib.auth.models import User;u=User.objects.get(username='admin');u.set_password(os.environ['WGER_ADMIN_PASSWORD']);u.save();u.refresh_from_db();print('default rejected' if not u.check_password('adminadmin') else 'DEFAULT STILL ACCEPTED')"
```
Assert: `default rejected`. Anything else, stop. Now load a food database and check the path end
to end:
```bash
docker compose exec -T web wger load-online-fixtures
curl -sS 'http://localhost:8147/api/v2/ingredient/?limit=1' | head -c 100
curl -sS http://localhost:8147/en/user/login > /tmp/wger-login.html
grep -o '<h2 class="mb-1">Login</h2>' /tmp/wger-login.html; grep -c 'user/registration' /tmp/wger-login.html
asset=$(grep -oE '/static/[^"]+\.css' /tmp/wger-login.html | head -1); curl -sS -o /dev/null -w "$asset %{http_code}\n" "http://localhost:8147$asset"
```
Assert all four, printing what you got: an ingredient count above zero;
`<h2 class="mb-1">Login</h2>`, the first screen a human sees; `0` from the registration grep, so
signup is closed and this install has one account; a `/static/` path and `200`, where a `404`
means an unstyled site and step 3 at fault. If any misses, stop, run
`docker compose logs --tail 40 web` and `docker compose logs --tail 20 nginx`, and name the
step. On `port is already allocated`, find what holds 8147 with `lsof -nP -iTCP:8147`. A
running container is not success.
STOP: tell the user to read their password with `grep WGER_ADMIN_PASSWORD ~/selfhost/wger/.env`,
put it in their password manager, and log in at http://localhost:8147/en/user/login as `admin`.
Do not continue until they confirm the dashboard loaded.
## 8. First backup and restore
```bash
cd ~/selfhost/wger
docker compose exec -T db pg_dump -U wger -d wger | gzip > ~/selfhost/wger/backups/wger-db-$(date +%F).sql.gz
tar -C ~/selfhost/wger -czf ~/selfhost/wger/backups/wger-config-$(date +%F).tar.gz compose.yml .env media
ls -lh ~/selfhost/wger/backups/
```
Assert: both files exist, both are non-empty, print both sizes. Nothing stops: `pg_dump` takes
a consistent snapshot, and static files are rebuilt on each start.
Both archives 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 off this computer, a sync folder
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 files are there.
To restore: `cd ~/selfhost/wger`, untar the config archive first, so .env is back before any
container starts and PostgreSQL can read `POSTGRES_PASSWORD` as 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 db`, wait 30 seconds for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T db psql -U wger -d wger`, then `docker compose up -d`
and check a workout is there.
## 9. Updating later
New versions are listed at https://github.com/wger-project/wger/releases. Take both backups
first, then edit the `wger/server` image line in ~/selfhost/wger/compose.yml to the new tag and
its digest:
```bash
cd ~/selfhost/wger
docker compose pull
docker compose up -d
docker compose logs --tail 30 web
```
Watch it until the migrations settle, then re-run step 7's checks.
## 10. What will probably go wrong
I rebooted, opened http://localhost:8147 to log breakfast, and got a connection error that
reads like a lost database. It was not: Docker Desktop had not started with the session, so
nothing was listening on 8147. `restart: unless-stopped` acts only once the Docker daemon is
up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/wger && docker compose up -d` and wait a minute before concluding.
## 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 8147 to 0.0.0.0 so a phone on the wifi can reach it. That puts a login form on
every network this computer joins.
- Do not add PowerSync, the celery worker or the beat scheduler. Upstream marks the last two
optional, and the first only feeds a phone app that cannot reach this machine anyway.
- Do not run `sync-ingredients-bulk` here. Upstream sizes the full dataset at around 1 GB of
database and hours of work, too much to ask of a laptop.compose.local.ymlthe services, pinned · local layout116 lines
# wger · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
# install ... https://wger.readthedocs.io/en/latest/installation/docker.html
# settings .. https://wger.readthedocs.io/en/latest/administration/settings.html
# static .... https://wger.readthedocs.io/en/latest/administration/errors.html
#
# Four services, every path relative to ~/selfhost/wger/ so one file works on
# macOS, Linux and Windows. The database is a named volume because PostgreSQL
# chowns its data directory to a uid a home bind mount cannot grant on
# Windows. nginx is here because Django serves no static files itself; the
# celery worker, beat scheduler and PowerSync upstream ships are not, which
# costs the weekly sync and the phone app. Digests read 2026-08-06, amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
restart: unless-stopped
environment:
POSTGRES_DB: wger
POSTGRES_USER: wger
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: UTC
volumes:
- wger-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wger -d wger"]
interval: 10s
retries: 12
cache:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
retries: 12
web:
image: wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01
restart: unless-stopped
env_file: ./.env
environment:
DJANGO_DB_ENGINE: django.db.backends.postgresql
DJANGO_DB_DATABASE: wger
DJANGO_DB_USER: wger
DJANGO_DB_PASSWORD: ${POSTGRES_PASSWORD}
DJANGO_DB_HOST: db
DJANGO_DB_PORT: 5432
DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache
DJANGO_CACHE_LOCATION: redis://cache:6379/1
DJANGO_CACHE_CLIENT_CLASS: django_redis.client.DefaultClient
NUMBER_OF_PROXIES: "1"
ALLOW_REGISTRATION: "False"
ALLOW_GUEST_USERS: "False"
USE_CELERY: "False"
DJANGO_CLEAR_STATIC_FIRST: "False"
WGER_USE_GUNICORN: "True"
volumes:
- ./static:/home/wger/static
- ./media:/home/wger/media
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/api/v2/version/"]
interval: 15s
timeout: 10s
retries: 40
# A first start migrates and collects static files, so allow minutes.
start_period: 300s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
configs:
- source: wger-nginx
target: /etc/nginx/conf.d/default.conf
volumes:
- ./static:/wger/static:ro
- ./media:/wger/media:ro
ports:
# Loopback only: no other device on the wifi can reach 8147.
- "127.0.0.1:8147:80"
depends_on:
web:
condition: service_started
configs:
wger-nginx:
content: |
# A doubled dollar sign is how compose escapes the one nginx wants.
server {
listen 80;
client_max_body_size 100M;
location /static/ { alias /wger/static/; }
location /media/ { alias /wger/media/; }
location / {
proxy_pass http://web:8000;
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto http;
}
}
volumes:
wger-pgdata:agent-readable mirror: /self-host/myfitnesspal-premium.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, pinned116 lines
# wger · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ... https://wger.readthedocs.io/en/latest/installation/docker.html
# settings .. https://wger.readthedocs.io/en/latest/administration/settings.html
# static .... https://wger.readthedocs.io/en/latest/administration/errors.html
#
# Four services. Django serves none of its own static files in production, so
# nginx reads the directory collectstatic writes into, and nginx alone
# publishes a host port. Two services upstream ships are absent on purpose:
# the celery worker and beat scheduler, marked optional on its architecture
# page, and PowerSync, which costs the phone app its sync. Digests read
# 2026-08-06, all four publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
restart: unless-stopped
environment:
POSTGRES_DB: wger
POSTGRES_USER: wger
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: UTC
volumes:
- /srv/wger/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wger -d wger"]
interval: 10s
retries: 12
cache:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
retries: 12
web:
image: wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01
restart: unless-stopped
env_file: /srv/wger/.env
environment:
DJANGO_DB_ENGINE: django.db.backends.postgresql
DJANGO_DB_DATABASE: wger
DJANGO_DB_USER: wger
DJANGO_DB_PASSWORD: ${POSTGRES_PASSWORD}
DJANGO_DB_HOST: db
DJANGO_DB_PORT: 5432
DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache
DJANGO_CACHE_LOCATION: redis://cache:6379/1
DJANGO_CACHE_CLIENT_CLASS: django_redis.client.DefaultClient
# Caddy terminates TLS, nginx states the scheme, and two proxies stand
# between a visitor and gunicorn.
X_FORWARDED_PROTO_HEADER_SET: "True"
NUMBER_OF_PROXIES: "2"
AXES_IPWARE_PROXY_COUNT: "2"
AXES_IPWARE_META_PRECEDENCE_ORDER: HTTP_X_FORWARDED_FOR,REMOTE_ADDR
ALLOW_REGISTRATION: "False"
ALLOW_GUEST_USERS: "False"
USE_CELERY: "False"
DJANGO_CLEAR_STATIC_FIRST: "False"
WGER_USE_GUNICORN: "True"
volumes:
- /srv/wger/static:/home/wger/static
- /srv/wger/media:/home/wger/media
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/api/v2/version/"]
interval: 15s
timeout: 10s
retries: 40
# A first start migrates and collects static files, so allow minutes.
start_period: 300s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
configs:
- source: wger-nginx
target: /etc/nginx/conf.d/default.conf
volumes:
- /srv/wger/static:/wger/static:ro
- /srv/wger/media:/wger/media:ro
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8147.
- "127.0.0.1:8147:80"
depends_on:
web:
condition: service_started
configs:
wger-nginx:
content: |
# A doubled dollar sign is how compose escapes the one nginx wants.
server {
listen 80;
client_max_body_size 100M;
location /static/ { alias /wger/static/; }
location /media/ { alias /wger/media/; }
location / {
proxy_pass http://web:8000;
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}Caddyfilethe hostname and TLS28 lines
# wger · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://wger.readthedocs.io/en/latest/installation/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SITE_URL and CSRF_TRUSTED_ORIGINS in .env: Django refuses a form post
# from an origin nobody told it about, so all three have to agree. This block
# proxies everything and serves nothing; nginx inside the stack has the static
# and media files.
<DOMAIN> {
# HSTS because there is a login form on every path.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-Server
}
# 8147 is the loopback port compose publishes and nginx answers on. It is
# not a container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8147
}install.shthe same install, no agent194 lines
#!/usr/bin/env bash
# wger · 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=wger.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://wger.readthedocs.io/en/latest/installation/docker.html
# https://wger.readthedocs.io/en/latest/administration/settings.html
# https://wger.readthedocs.io/en/latest/administration/errors.html
# https://wger.readthedocs.io/en/latest/development/architecture.html
#
# Four secrets are generated on this machine: Django's SECRET_KEY, the
# PostgreSQL password, a password for the admin account the image creates, and
# an RSA keypair for signing API tokens that wger's own command produces. All
# four land in /srv/wger/.env with mode 600 and none is ever printed.
#
# The account the image bootstraps is admin, with a password upstream prints in
# its install guide. This script replaces it and then checks the published one
# is refused before it reports success.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/wger}"
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. wger.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"
# compose.yml carries its nginx configuration in a `configs:` block with inline
# content, which Docker Compose learned to read in 2.23.
compose_ver="$(docker compose version --short | tr -d 'v')"
[ "$(printf '%s\n2.23.0\n' "$compose_ver" | sort -V | head -1)" = "2.23.0" ] \
|| die "docker compose ${compose_ver} is older than 2.23, which cannot read an inline config block"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Django plus PostgreSQL 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 ----------------------------------------------------
#
# static and media belong to uid and gid 1000 because that is the user inside
# the image, and upstream requires both folders to be readable by everyone or
# the site loads with no styling at all.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 755 -o 1000 -g 1000 "$APP_DIR/static" "$APP_DIR/media"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the secrets, on the server ----------------------------------
#
# Hex for all three: every one of them passes through a file that a shell and a
# compose parser both read, and neither wants escaping. Read them later with
# grep -E 'WGER_ADMIN_PASSWORD' /srv/wger/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
SITE_URL=https://${DOMAIN_HOST}
CSRF_TRUSTED_ORIGINS=https://${DOMAIN_HOST}
TIME_ZONE=UTC
TZ=UTC
SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WGER_ADMIN_PASSWORD=$(openssl rand -hex 20)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
# The fourth secret is an RSA keypair, and only wger makes it in the shape it
# wants. Upstream ships a working keypair in its public repository and says in
# the same paragraph that it has to be replaced.
if ! grep -q '^JWT_PRIVATE_KEY=' "$APP_DIR/.env"; then
echo "==> generating a JWT keypair (this pulls the image, give it a few minutes)"
docker run --rm wger/server:2.6.0@sha256:e7f58e15d380d8f5edc055c8a1ed11199e7eb5649138670703401b0c9c407c01 \
python3 manage.py generate-jwt-keys | grep -E '^JWT_(PRIVATE|PUBLIC)_KEY=' >> "$APP_DIR/.env"
fi
[ "$(grep -c '^JWT_' "$APP_DIR/.env")" = "2" ] || die "the JWT keypair was not written to $APP_DIR/.env"
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-wger"
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 8147, 5432 and 6379 are not among them ----------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8147, 5432 and 6379 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 first start migrates the database, loads the exercise fixtures, creates
# the admin account and runs collectstatic before gunicorn answers anything.
# Upstream's own health check allows five minutes for that.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/v2/version/ (this takes minutes on a first run)"
for _ in $(seq 1 60); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v2/version/" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/v2/version/ answered ${code:-nothing}. Check: docker compose logs --tail 40 web"
curl -sS "https://${DOMAIN_HOST}/api/v2/version/" | grep -q '"2.6.0"' \
|| die "the version endpoint answered 200 without 2.6.0. Check: docker compose logs --tail 40 web"
# The admin account the image bootstrapped has a password upstream publishes.
# Replace it with the generated one and prove the published one is dead. The
# new value is read from the container's environment, so it is never printed.
rotated="$(docker compose exec -T web python3 manage.py shell -c "import os;from django.contrib.auth.models import User;u=User.objects.get(username='admin');u.set_password(os.environ['WGER_ADMIN_PASSWORD']);u.save();u.refresh_from_db();print('default rejected' if not u.check_password('adminadmin') else 'DEFAULT STILL ACCEPTED')" | tr -d '\r\n')"
[ "$rotated" = "default rejected" ] || die "the admin password was not rotated (${rotated}). This box is on the internet with a published credential."
# A food database to start from. Upstream calls this the small base set; the
# full one is around 1 GB of database and hours of work, and is not run here.
docker compose exec -T web wger load-online-fixtures
ingredients="$(curl -sS "https://${DOMAIN_HOST}/api/v2/ingredient/?limit=1" | sed -n 's/.*"count":\([0-9]*\).*/\1/p')"
[ -n "$ingredients" ] && [ "$ingredients" -gt 0 ] || die "the ingredient table is empty; the fixture load did not finish"
curl -sS "https://${DOMAIN_HOST}/en/user/login" -o /tmp/wger-login.html
grep -q '<h2 class="mb-1">Login</h2>' /tmp/wger-login.html \
|| die "the login page did not contain the Login heading. Check: docker compose logs --tail 20 nginx"
grep -q 'user/registration' /tmp/wger-login.html \
&& die "the login page still offers registration; ALLOW_REGISTRATION did not take effect" || true
# The assert that matters most: the stylesheet the page asks for has to load,
# or the site renders as unformatted text and nothing logs an error.
asset="$(grep -oE '/static/[^"]+\.css' /tmp/wger-login.html | head -1)"
[ -n "$asset" ] || die "the login page referenced no stylesheet at all"
css_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}${asset}" || true)"
[ "$css_code" = "200" ] || die "${asset} answered ${css_code}: static files are not being served, see the ownership of ${APP_DIR}/static"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U wger -d wger | gzip > "$APP_DIR/backups/wger-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/wger-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env media -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/wger-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
wger is answering at https://${DOMAIN_HOST}/en/user/login
1. Log in as admin. The password is in $APP_DIR/.env, mode 600. Read it
with
grep WGER_ADMIN_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here, and the
one upstream publishes no longer works.
2. Registration is closed and there is one account. The exercise database
came with the image; the food database is the small base set, ${ingredients}
ingredients. The full one is around 1 GB and hours of work:
docker compose exec web ./manage.py sync-ingredients-bulk --set-mode insert
3. The phone app in the stores syncs through a service this install does
not run, so it will log in and then report sync unavailable. The web
interface works in a phone browser.
4. First backup written to $APP_DIR/backups: a database dump and a config
archive. They are on the same disk as the data, which is not a backup.
Copy them somewhere else tonight.
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 MyFitnessPal Premium.
- The food database is the whole question, and yours will be smaller. MyFitnessPal advertises 20.5 million foods; the ingredient table you sync from wger.de held 3,063,298 entries the day we looked, and it is built from Open Food Facts, which had 4.67 million products. A barcode that is not in your copy is fetched live from Open Food Facts by your own server, so coverage follows what shoppers have contributed there: good on European packaged goods, thinner on American store brands and restaurant items. You will type some foods in by hand.
- The phone app is not part of this install. wger publishes an app on Google Play and F-Droid, with a community build on the iOS store, but version 2 syncs through a separate PowerSync service that this install does not run, so the app logs in and then reports that sync is unavailable. What you get on a phone is the web interface in a browser, which is fine at the kitchen table and useless with no signal in a shop.
- The install ends before the food database is finished. The image brings exercises and this install adds a small base set of ingredients in a couple of minutes; the full dataset is around 1 GB of database and hours of downloading and processing, and it is a command you decide to run later. That is why the time budget here is a weekend rather than an evening.
- No mail and no scheduler. Password reset by email does not work, so on a one-account instance recovery is a command on the server. The weekly exercise sync and the ingredient photos are Celery jobs this install leaves out, because upstream marks that worker optional, so refreshing the exercise database is something you do on purpose.
- You own four containers and one database. Everything that matters is in PostgreSQL, which means a dump rather than a file copy, and the static files are rebuilt on every start so they are not worth backing up. AGPL-3.0, so if you ever run a modified copy as a service for other people, those people are owed the source.
Where this came from
“The Docker image ships with a default set of exercises but no ingredients (the dataset is large).”
- The image ships exercises but no ingredients, and the full food dataset is around 1 GB of database space synced from wger.de, with a smaller base set available in one command. source
- Django serves none of its own static files in production, so a separate web server reads the directory collectstatic writes into, and a mounted folder has to be owned by UID and GID 1000 and readable by everyone. source
- Upstream's architecture page marks Redis and the Celery worker and beat scheduler optional, and routes the mobile app's offline sync through a separate PowerSync service that replicates from Postgres. source
- The image creates its first account as admin with the password adminadmin, and upstream tells you to change it and the shipped JWT keys before the instance is reachable from the internet. source
- A barcode that is not in the local database is looked up in Open Food Facts by the server itself, which creates a local ingredient from the result. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host MyFitnessPal Premium?
Not MyFitnessPal Premium 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 wger. A gym log and a food diary on one server: routines, body measurements, and a barcode lookup that asks Open Food Facts instead of a subscription. The install is one weekend: 4 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 240 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 MyFitnessPal Premium?
wger. A gym log and a food diary on one server: routines, body measurements, and a barcode lookup that asks Open Food Facts instead of a subscription. The only one here that does both halves: a food diary with a barcode lookup, and a workout manager with routines, progression rules and body measurements. The barcode path is the honest surprise, because an unknown code is fetched live from Open Food Facts by your own server rather than looked up in a licensed database. What you give up is coverage and the phone app: the ingredient table you sync from wger.de held 3,063,298 entries on the day we checked against MyFitnessPal's advertised 20.5 million, it leans European and packaged rather than American restaurant menus, and the mobile app in the stores needs a sync service this install does not run. Also evaluated and ranked below it: FitTrackee. The prompt on this page installs wger only. wger 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 MyFitnessPal Premium?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. wger itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: MyFitnessPal Premium Premium, $19.99/mo — $239.88 a year.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the wger install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run wger 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 wger 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 wger answers only at http://localhost:8147, so the phone you would log lunch on cannot reach it: every meal and every set gets typed on this one machine, while it is awake. 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-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.