Add Docker deployment runbook
This commit is contained in:
1 parent
8a9966f7b0
commit
842ef065e1
1 file changed
+226
@@ -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/<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)
|
||||||
Reference in new issue
Block a user