Files
lego-instructions-manager/docs/DOCKER_DEPLOYMENT.md
T

227 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<path>`, which only worked because the folder happened to sit
inside `app/static`. `app/__init__.py` now registers an explicit
`/static/uploads/<path:filename>` 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 <CTID> -features nesting=1,keyctl=1
pct reboot <CTID>
```
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://<host>: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)