# Can I self-host MyFitnessPal Premium?

**YES, BUT** — it's called wger. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $19.99/mo you stop paying ($239.88/yr on the Premium plan).

wger authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/myfitnesspal-premium/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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;
        }
      }
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below wger for this swap. The prompts above install wger only.

- **FitTrackee** — 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 page this mirrors: https://caniselfhostit.com/self-host/myfitnesspal-premium/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
