diff --git a/docs/DOCKER_DEPLOYMENT.md b/docs/DOCKER_DEPLOYMENT.md new file mode 100644 index 0000000..884c10c --- /dev/null +++ b/docs/DOCKER_DEPLOYMENT.md @@ -0,0 +1,226 @@ +# 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)