Contents

Wger: Tracking Training Without a Subscription

A Django gym log that owns your numbers and asks for nothing monthly

Contents

Fitness apps have a business model problem, and you are the solution to it. The app is free and the data is the product, or the app is £9.99 a month and the data is hostage. Either way, the thing you built over four years — every session, every weight, every rep, the slow honest record of what your body has actually done — lives in someone else’s Postgres, subject to their pivot, their acquisition, and their eventual decision to sunset the export function.

I have been burned twice. A logging app I liked was bought and folded into a wearable ecosystem I had no interest in; the export gave me a CSV with the dates in American format and the exercises as opaque integer IDs. Another simply stopped, with ninety days’ notice and a zip file I never managed to import anywhere.

That data has one useful property: it is longitudinal. A gym log is worth nothing in its first month and quite a lot in its fifth year, because the only question it answers — am I stronger than I was? — needs a long baseline. Which means the exact thing that makes the data valuable is the thing that makes losing it expensive, and the vendors know it.

Wger is the way out. It is a Django application, AGPL, running since 2013, that does workout logging, nutrition tracking and body measurements, self-hosted, with a real API and a genuine export. I have run it for about two years. The interface shows its age. The data is mine.

What it does

Advertisement

Workout manager. Build a routine of days and exercises, then log sessions against it. Weight, reps, sets, RiR, comments. It has a “gym mode” that walks you through a session one exercise at a time on a phone, which is the only part of the interface that matters, because it is the only part you use while holding a barbell.

An exercise database. Community-maintained, multilingual, with muscle groups, equipment and images. It syncs from the public instance at wger.de, so you inherit thousands of exercises without typing any.

Nutrition. Meal plans and a food database backed by Open Food Facts, with barcode scanning in the mobile app. This is the weakest part; more below.

Body metrics. Weight, plus arbitrary custom measurements with charts. Simple and correct.

A REST API with a browsable interface, which means your data is a curl away and a migration path exists by construction.

What it does not do: coach you, guess your one-rep max with any authority, or look like a 2024 product. The interface is Django templates and Bootstrap with an increasing amount of React grafted on. It is functional and visibly built by volunteers.

The stack

Wger ships an all-in-one server image that bundles nginx, gunicorn and Celery. You still bring Postgres and Redis. Compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
services:
  wger:
    image: wger/server:2.2
    container_name: wger
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    environment:
      - TIME_ZONE=Europe/Copenhagen
      - SECRET_KEY_FILE=/run/secrets/wger_secret
      - SIGNING_KEY_FILE=/run/secrets/wger_signing
      - ALLOW_REGISTRATION=False
      - ALLOW_GUEST_USERS=False
      - WGER_USE_RECAPTCHA=False
      - SITE_URL=https://gym.mylab.local
      - CSRF_TRUSTED_ORIGINS=https://gym.mylab.local
      - DJANGO_DB_ENGINE=django.db.backends.postgresql
      - DJANGO_DB_DATABASE=wger
      - DJANGO_DB_USER=wger
      - DJANGO_DB_PASSWORD_FILE=/run/secrets/wger_db
      - 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
      - MEDIA_ROOT=/home/wger/media
      - STATIC_ROOT=/home/wger/static
      # First boot only -- see below
      - SYNC_EXERCISES_ON_STARTUP=True
      - DOWNLOAD_EXERCISE_IMAGES_ON_STARTUP=True
      - SYNC_INGREDIENTS_ON_STARTUP=False
    volumes:
      - /srv/appdata/wger/static:/home/wger/static
      - /srv/appdata/wger/media:/home/wger/media
    secrets:
      - wger_secret
      - wger_signing
      - wger_db
    ports:
      - "127.0.0.1:8000:8000"
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    container_name: wger-db
    environment:
      - POSTGRES_USER=wger
      - POSTGRES_DB=wger
      - POSTGRES_PASSWORD_FILE=/run/secrets/wger_db
    volumes:
      - /srv/appdata/wger/pgdata:/var/lib/postgresql/data
    secrets:
      - wger_db
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U wger"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    container_name: wger-cache
    volumes:
      - /srv/appdata/wger/redis:/data
    restart: unless-stopped

secrets:
  wger_secret:
    file: /srv/secrets/wger_secret.txt
  wger_signing:
    file: /srv/secrets/wger_signing.txt
  wger_db:
    file: /srv/secrets/wger_db.txt

Three services and about 700MB of RAM at rest for a gym diary. That is the honest cost, and it is worth stating up front, because the Django-plus-Postgres-plus-Redis-plus-Celery shape is what a 2013 web application looks like and no amount of enthusiasm makes it a small deployment. The condition: service_healthy gate matters here for the reason it always does, which I dwelt on in Docker Compose Patterns That Age Well: Django gives up on a database that is still initialising and crashes.

Turn the two startup sync flags off after the first successful boot. Leaving them on means every container restart re-runs a network sync you already did, which turns a ten-second restart into a five-minute one and, on a bad day, into a timeout.

Create your user from the command line, since registration is off:

1
2
3
4
5
$ docker exec -it wger python3 manage.py createsuperuser
Username: smarc
Email address: [email protected]
Password:
Superuser created successfully.

The CSRF trap, which will get you

Advertisement

This is the single most common wger failure and the error message is famously unhelpful. You put wger behind a reverse proxy, everything renders beautifully, and then the first form submission dies with Forbidden (403) — CSRF verification failed. Request aborted.

Django checks that the Origin header on a POST matches something it trusts. Behind a proxy terminating TLS, the app sees plain HTTP on port 8000 and concludes the request came from somewhere else. Two things must line up.

In wger, CSRF_TRUSTED_ORIGINS must contain the external origin, scheme included:

1
- CSRF_TRUSTED_ORIGINS=https://gym.mylab.local

And the proxy must tell the app what the outside world looks like. Caddy does this by default; nginx needs it spelled out:

1
2
3
4
5
6
7
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Miss X-Forwarded-Proto and Django builds http:// URLs, the origin check fails, and you spend an hour blaming wger. The general shape of getting a proxy right is in Reverse Proxy Done Right: Automatic HTTPS with Caddy, and I recommend Caddy here for exactly this reason: the defaults are the correct ones.

The ingredient database decision

SYNC_INGREDIENTS_ON_STARTUP pulls the food database from Open Food Facts. Before you enable it, understand what you are asking for: the full set runs to hundreds of thousands of products, the sync takes hours, and it will add multiple gigabytes to your Postgres volume. On a mini PC with a modest SSD, this is a real decision.

You can scope it to your country, which is what I did and what I would recommend:

1
2
3
$ docker exec -it wger python3 manage.py sync-ingredients-from-off --languages de,en
$ docker exec -it wger python3 manage.py sync-exercises
$ docker exec -it wger python3 manage.py download-exercise-images

Even scoped, the data quality is Open Food Facts data quality: crowdsourced, wildly uneven, with three entries for the same yoghurt disagreeing about the protein content by forty per cent. For tracking whether you ate roughly enough protein, it is fine. For anything requiring precision, it is fiction with a barcode.

My honest position after two years: the nutrition module is the part of wger I stopped using. The workout log is the reason to run it, and if you skip the ingredient sync entirely your database stays under 200MB and the whole thing gets noticeably faster.

Gym mode, and the only interface that matters

Everything above is plumbing. The part that decides whether wger survives in your life is a single screen, and it is worth describing properly because the project’s own screenshots undersell it.

A routine in wger is a set of days; each day is a list of exercises; each exercise carries a target set/rep scheme. Once that exists, the phone view — gym mode — takes over. It shows one exercise, the target, and what you did last time, with number fields large enough to hit with a thumb while your hands are chalked. You enter the set, it advances, there is a rest timer. At the end it writes a workout log entry with a date and a session impression.

The “what you did last time” line is the whole product. It is the only number I need in the gym, it is the number every paid app charges for, and it is one SELECT against a table I own. Seeing Last: 82.5kg x 5,5,4 under today’s target is the difference between progressive overload and guessing.

Two things about gym mode are worth knowing before you commit. It is a web view, so it needs the network — a basement gym with no signal means no logging, and the PWA’s offline story is thin. I keep a note on my phone for those sessions and type them in later, which is annoying maybe once a month. And the routine editor is fiddly; building a four-day split the first time took me twenty minutes of fighting a form. Do it once, at a desk, then leave it alone for a year.

The mobile app, wger’s Flutter client, covers the same ground with a nicer capture flow and barcode scanning for the nutrition module. It talks to your instance over the API with a token. It has improved a great deal, and it still trails the web view on routine editing. I use both: app in the gym, browser for anything structural.

What two years of data actually told me

Advertisement

The reason to keep a five-year log is that it answers questions no app dashboard is willing to ask. Mine, queried directly against Postgres because I can, produced three findings that changed what I do.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ docker exec -it wger-db psql -U wger -c "
    SELECT date_trunc('month', date) AS month, count(*) AS sessions
    FROM manager_workoutsession
    WHERE date > now() - interval '24 months'
    GROUP BY 1 ORDER BY 1 DESC LIMIT 4;"
        month        | sessions
---------------------+----------
 2024-12-01 00:00:00 |        9
 2024-11-01 00:00:00 |       13
 2024-10-01 00:00:00 |       12
 2024-09-01 00:00:00 |       11

First: my attendance drops by a third every December and every August, reliably, both years. I had a story about being consistent. The table disagreed. Second: the lifts I claim to prioritise are logged least, because the ones I enjoy get done and the ones I need get skipped, which is a pattern invisible at the scale of a week. Third: my actual working weight on the main lift moved by a fraction of what it felt like over the same period, and confronting that number was more useful than any programme.

None of that is a feature. It is the consequence of owning a database and being able to point SQL at it, which is a capability no subscription app will ever sell you, because the answers are unflattering and unflattering products churn.

Troubleshooting

Static files 404 and the site renders as unstyled HTML. The static volume is empty or the collectstatic step failed. Re-run it and check the volume is writable by the container’s user:

1
2
$ docker exec wger python3 manage.py collectstatic --noinput
$ docker exec wger ls /home/wger/static/CACHE | head -3

Everything is slow after the ingredient sync. Postgres is now doing text search over half a million rows with default 128MB shared_buffers. Give it some memory. The settings that actually matter are the handful I went through in Postgres Tuning for Homelabbers: Ten Settings That Matter, and shared_buffers plus work_mem will do most of the work here.

The Android app cannot log in. The app needs the API and it needs the SIGNING_KEY to be stable. If you left SIGNING_KEY unset, wger generates one per boot and tokens die at every restart. Set it from a file, once, permanently. The same discipline applies to SECRET_KEY, and the fact that both are set with _FILE variants above is deliberate.

Emails never send. Wger needs DJANGO_EMAIL_* configured and silently sends nothing without them. Since registration is off and you are one user, this genuinely does not matter, and I have never bothered.

A version upgrade fails on migrations. Wger runs Django migrations on boot. If one fails you get a container that starts, logs an exception, and serves nothing. Read the log, and do not restart in a loop while a migration is half-applied:

1
$ docker logs wger 2>&1 | grep -A5 "Applying"

Back up the database before every image bump. This is one of the few applications where I mean that literally.

Backups and getting out

The API is the export. Everything is reachable with a token, which means a proper archive of your training history is a loop:

1
2
3
4
5
6
7
$ TOKEN=$(cat /srv/secrets/wger_api_token)
$ curl -s "https://gym.mylab.local/api/v2/workoutlog/?format=json&limit=10000" \
    -H "Authorization: Token $TOKEN" \
  | jq '.results[] | {date, exercise_base, weight, reps}' \
  > /srv/backup/wger-log-$(date +%F).json
$ wc -l /srv/backup/wger-log-$(date +%F).json
14022

That file is the thing worth keeping. Four years of sessions, as plain JSON, portable to anything with a text parser. Run it monthly from cron.

Alongside it, the database dump, swept offsite by whatever you already use — mine is the restic arrangement from Borg vs Restic: Painless Encrypted Backups:

1
$ docker exec wger-db pg_dump -U wger -Fc wger > /srv/backup/wger-$(date +%F).dump

The media volume holds exercise images you can always re-download, so it is optional. The pgdata directory should never be backed up by copying it while Postgres is live.

The verdict

Wger is a twelve-year-old volunteer-maintained Django application that runs three containers and 700MB to store a list of how much you lifted. Measured on effort per feature, that is a poor trade, and any of the free phone apps will be prettier and start faster.

Measured on the question that actually matters — will this data exist in five years, in a format I can read, on hardware I control — it is the only answer I have found. The interface is dated and the nutrition module is weak, and neither of those matters at 6am when you want to know what you benched in March.

Run it if you have been lifting for years, you have watched an app die with your history in it, and you have a machine already running Postgres for something else. That describes me exactly, and two years in the container has needed roughly nothing.

Skip it if you have been training for three months. Use a free app, decide whether the habit sticks, and revisit this when the data has become worth defending. Self-hosting has a real time cost, and I made that case at length in Self-Hosting Is Not Free: Accounting for Your Own Time. Spending it on a habit you might abandon in April is the wrong order to do things in.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.