# Docker Deployment Runs LIM as two containers — the Flask app and Postgres 16 — with uploads and backups on a named Docker volume. Intended for a Proxmox VM or LXC. Companion to [`BACKUP_AND_MIGRATION.md`](BACKUP_AND_MIGRATION.md), which covers getting your existing data out of `E:\LIM`. --- ## What the stack looks like | Container | Image | Purpose | |---|---|---| | `lim-app` | built from `Dockerfile` | Flask + gunicorn on port 8000 | | `lim-db` | `postgres:16-alpine` | database, not published to the host | | Volume | Mounted at | Holds | |---|---|---| | `lim-data` | `/data` | `uploads/` and `backups/` | | `lim-db` | `/var/lib/postgresql/data` | the database | Only `lim-app` is reachable from outside; Postgres is on the internal compose network only. ### Two deliberate choices **One gunicorn worker, eight threads.** The backup job registry lives in process memory. With several workers, the browser's progress-poll can hit a worker that has never heard of the job and the bar sticks at 0%. Threads keep it coherent and are ample for household use. If you ever need real concurrency, the job state has to move into Postgres or Redis first. **Uploads get their own route.** Stored paths render as `/static/uploads/`, which only worked because the folder happened to sit inside `app/static`. `app/__init__.py` now registers an explicit `/static/uploads/` route reading from `UPLOAD_FOLDER`, so the same URLs keep working with the folder on a volume. It takes precedence over Flask's static handler; ordinary static files are unaffected. --- ## Proxmox host prep An LXC is fine, but Docker inside an unprivileged container needs nesting on. A small VM avoids the whole question — pick whichever you normally run. For an LXC, on the Proxmox host: ```bash pct set -features nesting=1,keyctl=1 pct reboot ``` Then inside the container or VM (Debian/Ubuntu): ```bash curl -fsSL https://get.docker.com | sh docker compose version ``` Give it enough disk for the collection plus room for backups. PDFs dominate and backups store them uncompressed, so budget roughly **2.5×** the size of your current uploads folder. --- ## Deploy ```bash git clone https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git lim cd lim cp .env.docker.example .env ``` Fill in the two required values: ```bash python3 -c "import secrets; print(secrets.token_hex(32))" # SECRET_KEY python3 -c "import secrets; print(secrets.token_urlsafe(24))" # POSTGRES_PASSWORD ``` Then: ```bash docker compose up -d --build docker compose logs -f app ``` You want to see `Database is up.`, `Schema ready.` and gunicorn binding to `0.0.0.0:8000`. The UI is on `http://:8000`. The entrypoint runs `db.create_all()` on every start. That is additive — it creates missing tables and leaves existing ones alone. It does **not** alter existing columns, so a future schema change still needs a migration. Set `SKIP_DB_INIT=1` to turn it off. --- ## Move your data in On the Windows box: ```powershell cd E:\LIM flask backup create --label pre-docker ``` Copy the archive into the volume and restore: ```bash # from wherever you dropped the .zip docker cp lim-backup-....zip lim-app:/data/backups/ docker compose exec app flask backup verify lim-backup-....zip docker compose exec app flask backup restore lim-backup-....zip --mode fresh --yes ``` Expected: row counts matching the source, and a file count matching the manifest. Restore preserves primary keys, rewrites Windows backslashes to forward slashes, and realigns Postgres sequences afterwards so new rows don't collide with restored IDs. Then check, in this order: 1. Log in with your existing password (hashes migrate intact). 2. Open a set and confirm the cover image renders — that proves the volume path and the uploads route are both working. 3. Open a PDF and an extra file. 4. Take a backup from inside the container to prove the trip is reversible: `docker compose exec app flask backup create --label first-container-backup` --- ## Day-to-day ```bash docker compose ps docker compose logs -f app docker compose restart app docker compose down # stop, keep data docker compose up -d --build # after a git pull ``` Nightly backup on the host: ```cron 0 2 * * * cd /srv/lim && docker compose exec -T app flask backup create --label nightly --keep 7 ``` Copy the archives off the host as well — `docker cp lim-app:/data/backups .`, or point rsync at the volume under `/var/lib/docker/volumes/lim_lim-data/_data/backups`. A backup living only on the machine it backs up is half a backup. ### Database dump (belt and braces) ```bash docker compose exec -T db pg_dump -U lim lim | gzip > lim-$(date +%F).sql.gz ``` The LIM backup already carries a portable row dump, so this is optional — useful if you want something `psql` can restore directly. --- ## Reverse proxy The app speaks plain HTTP on 8000. Front it with whatever you already run and pass the standard headers: ```nginx 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; client_max_body_size 100M; } ``` `client_max_body_size` must be at least `MAX_CONTENT_LENGTH` or large PDF uploads fail at the proxy with a 413 before Flask ever sees them. If you terminate TLS at the proxy, add `ProxyFix` in `run.py` so Flask builds correct external URLs: ```python from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) ``` --- ## Troubleshooting **`Permission denied` writing uploads** — the entrypoint chowns `/data` on every start, including after `create_all()` has run as root. If you mounted a host path instead of a named volume, chown it to UID 1000 yourself. **Images 404 but the page loads** — `UPLOAD_FOLDER` and the restored files disagree. Check `docker compose exec app ls /data/uploads` against `manifest.uploads` in the archive. **App restarts in a loop** — read `docker compose logs app`. Usually a missing `SECRET_KEY` or `POSTGRES_PASSWORD`; compose fails loudly on both. **`Database did not become available in time`** — Postgres didn't pass its healthcheck. `docker compose logs db`; after a bad shutdown the data volume sometimes needs a plain `docker compose up -d db` on its own first. **Backup progress bar stuck at 0%** — more than one gunicorn worker. Check the `CMD` in the Dockerfile hasn't been changed to `--workers 2` or higher. --- ## Rolling back Nothing here touches `E:\LIM`. If the container instance misbehaves, keep using Windows and try again later. Going the other way, take a backup from the container and restore it onto Windows with the same commands — the format is identical in both directions. --- Created by: Jess Rogerson (yelling commands at Claude.AI)