Tools: Self-Hosting wger Workout Manager with Docker

Tools: Self-Hosting wger Workout Manager with Docker

What Is wger?

Prerequisites

Docker Compose Configuration

Initial Setup

Configuration

Reverse Proxy

Backup

Troubleshooting

Exercise Database Is Empty

Static Files Not Loading (Broken CSS/JS)

"CSRF Verification Failed" Behind Reverse Proxy

Verdict

Related wger (pronounced "veger") is an open-source fitness tracking application that covers workouts, nutrition logging, and body measurements. It comes with a library of 800+ exercises (synced from wger.de), meal planning with nutritional data, and a REST API for building custom frontends or integrations. Self-hosting means your workout data, body measurements, and dietary information stay private. wger requires several services: the application server, PostgreSQL, Redis, Nginx, and Celery workers for background tasks. This looks complex but the official stack works reliably out of the box. Create a docker-compose.yml file: Create the Nginx configuration file nginx.conf: First startup takes 1-2 minutes as Django runs migrations and Celery begins syncing exercises from wger.de. Important: After creating your admin account, consider setting ALLOW_REGISTRATION=False and ALLOW_GUEST_USERS=False if this is a personal instance. The Celery workers automatically sync the exercise database from wger.de in the background. This includes 800+ exercises with descriptions, muscles targeted, and (if enabled) images and videos. The initial sync can take 10-30 minutes. If placing wger behind an external reverse proxy (Nginx Proxy Manager, Caddy, Traefik), point it to port 80 on the wger Nginx container. Add these environment variables to the web service: For a dedicated reverse proxy setup, see Reverse Proxy Guide. Back up the PostgreSQL database and media volume: For general backup strategies, see Backup Strategy. Symptom: No exercises appear in the exercise list after setup. Fix: The Celery worker syncs exercises asynchronously. Check if it's running: docker logs wger-celery-worker. The initial sync takes 10-30 minutes. If the worker shows errors connecting to wger.de, check outbound internet access. Symptom: The site loads but looks unstyled or broken. Fix: Nginx serves static files from the shared wger-static volume. Ensure the nginx.conf paths match the volume mounts. Run docker exec wger python manage.py collectstatic --noinput to regenerate static files. Symptom: Form submissions fail with a CSRF error when accessed through a reverse proxy. Fix: Set CSRF_TRUSTED_ORIGINS to your external domain (e.g., https://fitness.example.com) and X_FORWARDED_PROTO_HEADER_SET=True in the web service environment. The latest tag on Docker Hub points to 2.5-dev (development). Always pin to 2.4 for stable deployments. wger is the most comprehensive self-hosted fitness tracker available. The exercise database, workout planning, nutritional tracking, and body measurement logging cover everything a serious fitness enthusiast needs. The 6-service Docker stack is heavier than simpler alternatives, but the feature depth justifies it. If you just want basic workout logging without the complexity, Fittrackee is lighter and focused on GPS-tracked activities. wger is the answer when you want a full gym-style workout planner with nutritional tracking. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ services: web: image: wger/server:2.4 container_name: wger -weight: 500;">restart: unless-stopped environment: # Security — CHANGE THESE SECRET_KEY: "generate-a-random-50-char-string-here" # CHANGE THIS SIGNING_KEY: "generate-a-different-random-string" # CHANGE THIS SITE_URL: "http://localhost" # CHANGE to your domain # Database DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password # CHANGE THIS DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_PERFORM_MIGRATIONS: "True" # Cache DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 DJANGO_CACHE_TIMEOUT: "1296000" # Celery USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 # App settings WGER_INSTANCE: https://wger.de ALLOW_REGISTRATION: "True" ALLOW_GUEST_USERS: "True" TIME_ZONE: UTC WGER_USE_GUNICORN: "True" volumes: - wger-static:/home/wger/static - wger-media:/home/wger/media depends_on: db: condition: service_healthy cache: condition: service_healthy healthcheck: test: ["CMD", "-weight: 500;">wget", "-q", "--no-check-certificate", "--spider", "http://localhost:8000"] interval: 30s timeout: 10s retries: 5 networks: - wger nginx: image: nginx:stable container_name: wger-nginx -weight: 500;">restart: unless-stopped ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - wger-static:/wger/static:ro - wger-media:/wger/media:ro depends_on: - web networks: - wger db: image: postgres:15-alpine container_name: wger-db -weight: 500;">restart: unless-stopped environment: POSTGRES_DB: wger POSTGRES_USER: wger POSTGRES_PASSWORD: change-this-db-password # Must match DJANGO_DB_PASSWORD volumes: - wger-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U wger"] interval: 10s timeout: 5s retries: 5 networks: - wger cache: image: redis:7-alpine container_name: wger-cache -weight: 500;">restart: unless-stopped volumes: - wger-redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 networks: - wger celery_worker: image: wger/server:2.4 container_name: wger-celery-worker -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-worker environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 CELERY_WORKER_CONCURRENCY: "2" WGER_INSTANCE: https://wger.de volumes: - wger-media:/home/wger/media depends_on: web: condition: service_healthy networks: - wger celery_beat: image: wger/server:2.4 container_name: wger-celery-beat -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-beat environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 volumes: - wger-beat:/home/wger/beat depends_on: celery_worker: condition: service_healthy networks: - wger volumes: wger-static: wger-media: wger-postgres: wger-redis: wger-beat: networks: wger: driver: bridge services: web: image: wger/server:2.4 container_name: wger -weight: 500;">restart: unless-stopped environment: # Security — CHANGE THESE SECRET_KEY: "generate-a-random-50-char-string-here" # CHANGE THIS SIGNING_KEY: "generate-a-different-random-string" # CHANGE THIS SITE_URL: "http://localhost" # CHANGE to your domain # Database DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password # CHANGE THIS DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_PERFORM_MIGRATIONS: "True" # Cache DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 DJANGO_CACHE_TIMEOUT: "1296000" # Celery USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 # App settings WGER_INSTANCE: https://wger.de ALLOW_REGISTRATION: "True" ALLOW_GUEST_USERS: "True" TIME_ZONE: UTC WGER_USE_GUNICORN: "True" volumes: - wger-static:/home/wger/static - wger-media:/home/wger/media depends_on: db: condition: service_healthy cache: condition: service_healthy healthcheck: test: ["CMD", "-weight: 500;">wget", "-q", "--no-check-certificate", "--spider", "http://localhost:8000"] interval: 30s timeout: 10s retries: 5 networks: - wger nginx: image: nginx:stable container_name: wger-nginx -weight: 500;">restart: unless-stopped ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - wger-static:/wger/static:ro - wger-media:/wger/media:ro depends_on: - web networks: - wger db: image: postgres:15-alpine container_name: wger-db -weight: 500;">restart: unless-stopped environment: POSTGRES_DB: wger POSTGRES_USER: wger POSTGRES_PASSWORD: change-this-db-password # Must match DJANGO_DB_PASSWORD volumes: - wger-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U wger"] interval: 10s timeout: 5s retries: 5 networks: - wger cache: image: redis:7-alpine container_name: wger-cache -weight: 500;">restart: unless-stopped volumes: - wger-redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 networks: - wger celery_worker: image: wger/server:2.4 container_name: wger-celery-worker -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-worker environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 CELERY_WORKER_CONCURRENCY: "2" WGER_INSTANCE: https://wger.de volumes: - wger-media:/home/wger/media depends_on: web: condition: service_healthy networks: - wger celery_beat: image: wger/server:2.4 container_name: wger-celery-beat -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-beat environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 volumes: - wger-beat:/home/wger/beat depends_on: celery_worker: condition: service_healthy networks: - wger volumes: wger-static: wger-media: wger-postgres: wger-redis: wger-beat: networks: wger: driver: bridge services: web: image: wger/server:2.4 container_name: wger -weight: 500;">restart: unless-stopped environment: # Security — CHANGE THESE SECRET_KEY: "generate-a-random-50-char-string-here" # CHANGE THIS SIGNING_KEY: "generate-a-different-random-string" # CHANGE THIS SITE_URL: "http://localhost" # CHANGE to your domain # Database DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password # CHANGE THIS DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_PERFORM_MIGRATIONS: "True" # Cache DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 DJANGO_CACHE_TIMEOUT: "1296000" # Celery USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 # App settings WGER_INSTANCE: https://wger.de ALLOW_REGISTRATION: "True" ALLOW_GUEST_USERS: "True" TIME_ZONE: UTC WGER_USE_GUNICORN: "True" volumes: - wger-static:/home/wger/static - wger-media:/home/wger/media depends_on: db: condition: service_healthy cache: condition: service_healthy healthcheck: test: ["CMD", "-weight: 500;">wget", "-q", "--no-check-certificate", "--spider", "http://localhost:8000"] interval: 30s timeout: 10s retries: 5 networks: - wger nginx: image: nginx:stable container_name: wger-nginx -weight: 500;">restart: unless-stopped ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - wger-static:/wger/static:ro - wger-media:/wger/media:ro depends_on: - web networks: - wger db: image: postgres:15-alpine container_name: wger-db -weight: 500;">restart: unless-stopped environment: POSTGRES_DB: wger POSTGRES_USER: wger POSTGRES_PASSWORD: change-this-db-password # Must match DJANGO_DB_PASSWORD volumes: - wger-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U wger"] interval: 10s timeout: 5s retries: 5 networks: - wger cache: image: redis:7-alpine container_name: wger-cache -weight: 500;">restart: unless-stopped volumes: - wger-redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 networks: - wger celery_worker: image: wger/server:2.4 container_name: wger-celery-worker -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-worker environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" DJANGO_CACHE_BACKEND: django_redis.cache.RedisCache DJANGO_CACHE_LOCATION: redis://cache:6379/1 USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 CELERY_WORKER_CONCURRENCY: "2" WGER_INSTANCE: https://wger.de volumes: - wger-media:/home/wger/media depends_on: web: condition: service_healthy networks: - wger celery_beat: image: wger/server:2.4 container_name: wger-celery-beat -weight: 500;">restart: unless-stopped command: /-weight: 500;">start-beat environment: DJANGO_DB_ENGINE: django.db.backends.postgresql DJANGO_DB_DATABASE: wger DJANGO_DB_USER: wger DJANGO_DB_PASSWORD: change-this-db-password DJANGO_DB_HOST: db DJANGO_DB_PORT: "5432" USE_CELERY: "True" CELERY_BROKER: redis://cache:6379/2 CELERY_BACKEND: redis://cache:6379/2 volumes: - wger-beat:/home/wger/beat depends_on: celery_worker: condition: service_healthy networks: - wger volumes: wger-static: wger-media: wger-postgres: wger-redis: wger-beat: networks: wger: driver: bridge upstream wger { server web:8000; } server { listen 80; server_name _; location /static/ { alias /wger/static/; } location /media/ { alias /wger/media/; } location / { proxy_pass http://wger; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } } upstream wger { server web:8000; } server { listen 80; server_name _; location /static/ { alias /wger/static/; } location /media/ { alias /wger/media/; } location / { proxy_pass http://wger; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } } upstream wger { server web:8000; } server { listen 80; server_name _; location /static/ { alias /wger/static/; } location /media/ { alias /wger/media/; } location / { proxy_pass http://wger; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } } -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d X_FORWARDED_PROTO_HEADER_SET: "True" CSRF_TRUSTED_ORIGINS: "https://fitness.example.com" X_FORWARDED_PROTO_HEADER_SET: "True" CSRF_TRUSTED_ORIGINS: "https://fitness.example.com" X_FORWARDED_PROTO_HEADER_SET: "True" CSRF_TRUSTED_ORIGINS: "https://fitness.example.com" # Database -weight: 500;">docker exec wger-db pg_dump -U wger wger > wger-backup.sql # Media files (exercise images, user uploads) -weight: 500;">docker run --rm -v wger-media:/data -v $(pwd):/backup alpine tar czf /backup/wger-media.tar.gz /data # Database -weight: 500;">docker exec wger-db pg_dump -U wger wger > wger-backup.sql # Media files (exercise images, user uploads) -weight: 500;">docker run --rm -v wger-media:/data -v $(pwd):/backup alpine tar czf /backup/wger-media.tar.gz /data # Database -weight: 500;">docker exec wger-db pg_dump -U wger wger > wger-backup.sql # Media files (exercise images, user uploads) -weight: 500;">docker run --rm -v wger-media:/data -v $(pwd):/backup alpine tar czf /backup/wger-media.tar.gz /data - A Linux server (Ubuntu 22.04+ recommended) - Docker and Docker Compose installed (guide) - 2 GB of RAM minimum (PostgreSQL + Redis + Celery workers) - 10 GB of free disk space (grows with exercise images/videos) - A domain name (optional, for remote access) - Access wger at http://your-server-ip - Register an account (the first account has admin privileges) - Go to Settings to configure your profile, units (metric/imperial), and language - RAM: ~1 GB idle (all services combined), ~1.5 GB under active use - CPU: 2 cores recommended (Celery workers are the main consumer) - Disk: 2 GB base, 10+ GB if syncing exercise images and videos - wger vs Fittrackee: Which Fitness Tracker? - How to Self-Host Fittrackee - Best Self-Hosted Health & Fitness Tools - Docker Compose Basics - Reverse Proxy Guide - Backup Strategy - Best Self-Hosted Analytics Tools - Getting Started with Self-Hosting