#docker#opennutritracker#self-hosting#open-food-facts#compose

Self-hosting OpenNutriTracker companion services with Docker

OpenNutriTracker is local-first, but the OFF mirror and backup target benefit from a tiny home server.

What this is and isn’t

OpenNutriTracker is a Flutter app that runs locally on your phone. There is no “OpenNutriTracker server” you can host. So this isn’t a “host the app” guide — it’s a guide to running the two companion services that make ONT meaningfully better when you’re on a self-hosted homelab:

  1. A local Open Food Facts mirror. Faster lookups, offline-capable, no rate limits, and no telemetry to OFF’s API.
  2. A WebDAV target for backups. ONT exports a JSON snapshot; we want it landing somewhere we control.

Total RAM: about 600 MiB. Total disk: about 12 GiB after first sync. Runs fine on a Pi 5.

Architecture

[Phone, OpenNutriTracker] ──╮
                            ├──> [Caddy reverse proxy, TLS]
[Phone, file manager]   ────╯       │
                                    ├──> [OFF mirror (read-only)]
                                    └──> [WebDAV (Apache mod_dav)]

Caddy is the only thing exposed to the LAN. The OFF mirror and WebDAV are container-local. Caddy terminates TLS using a Tailscale-issued cert (or your CA of choice).

docker-compose.yml

services:
  off-mirror:
    image: openfoodfacts/openfoodfacts-server:nightly
    restart: unless-stopped
    environment:
      - OFF_MIRROR_MODE=read-only
    volumes:
      - off-data:/var/data/off
      - off-cache:/var/cache/off
    expose:
      - "8080"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/api/v2/product/0"]
      interval: 60s
      timeout: 10s
      retries: 3

  webdav:
    image: bytemark/webdav:latest
    restart: unless-stopped
    environment:
      - AUTH_TYPE=Basic
      - USERNAME=ont-backup
      - PASSWORD_FILE=/run/secrets/webdav_password
    volumes:
      - webdav-data:/var/lib/dav
    secrets:
      - webdav_password
    expose:
      - "80"

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - off-mirror
      - webdav

secrets:
  webdav_password:
    file: ./secrets/webdav_password.txt

volumes:
  off-data:
  off-cache:
  webdav-data:
  caddy-data:
  caddy-config:

Caddyfile

ont.lan.example.org {
    encode gzip
    
    @off path /off/* /api/*
    handle @off {
        reverse_proxy off-mirror:8080
    }
    
    @webdav path /backup/*
    handle @webdav {
        reverse_proxy webdav:80
    }
    
    handle {
        respond "OK" 200
    }
    
    log {
        output file /var/log/caddy/access.log {
            roll_size 10MB
            roll_keep 5
        }
    }
}

The @off path serves the OFF mirror’s API behind a stable URL on your LAN. ONT can be pointed at this URL via the in-app “custom OFF endpoint” setting (added in ONT 1.6+).

OFF mirror first sync

The first sync downloads roughly 8 GiB of OFF JSON-Lines. Allow a couple of hours over a residential connection. Subsequent syncs are incremental and run nightly via the container’s built-in cron.

docker compose up -d off-mirror
docker compose logs -f off-mirror
# wait for "initial-sync-complete"

If the sync stalls, kill it and restart. The download resumes.

Backup workflow

In OpenNutriTracker, set the export schedule to weekly. Point it at:

https://ont.lan.example.org/backup/ont-backup.zip

ONT will PUT the zip on every export. Filenames are timestamped on the server side via a webdav post-receive hook (sample script below). We keep the last 12 weeks.

# /scripts/webdav-rotate.sh
cd /var/lib/dav/data/backup
TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)
mv ont-backup.zip "ont-backup-${TIMESTAMP}.zip" 2>/dev/null || true
ls -t ont-backup-*.zip | tail -n +13 | xargs -r rm

Run this from cron every 5 minutes. It’s idempotent.

Restore drill

Quarterly. We’ve all written backup scripts that don’t restore. Don’t be that person.

# pull most recent backup
curl -u ont-backup -o /tmp/ont-restore.zip \
  https://ont.lan.example.org/backup/ont-backup-2026-01-12T03-15-00Z.zip

# verify integrity
unzip -t /tmp/ont-restore.zip

# import on a fresh ONT install (Settings → Import)

Confirm the diary days, custom recipes, and weight history all came back. We have hit two ONT minor-version bumps where the schema changed; both required a re-import on the new version, both worked.

Watch-outs

  • ONT’s “custom OFF endpoint” setting is buried in Developer Settings and requires enabling Developer Mode (tap the build version 7 times, in true Android tradition).
  • The OFF mirror image’s nightly sync runs at 03:00 UTC by default. If your ISP has off-peak limits, adjust.
  • Caddy’s automatic HTTPS will fail if your LAN-only domain doesn’t resolve publicly. Use Tailscale’s tailscale cert or a private CA. See TLS for self-hosted nutrition apps.

What this gets you

Once it’s running, OpenNutriTracker on the phone:

  • Looks up barcodes against your local OFF mirror in <100 ms (vs 400–800 ms against OFF’s public API).
  • Works in airplane mode for any product the mirror has indexed.
  • Backs up every export to your homelab automatically.
  • Phones home to nothing.

References

  • OpenNutriTracker: github.com/simonoppowa/OpenNutriTracker
  • Open Food Facts mirror docker: github.com/openfoodfacts/openfoodfacts-server
  • bytemark/webdav image: github.com/bytemark/docker-webdav
  • Caddy: caddyserver.com