108 lines
5.4 KiB
Markdown
108 lines
5.4 KiB
Markdown
# Flask App VPS Deployment — LLM Prompt
|
|
|
|
> **Purpose:** Paste this into a chat with a local model (e.g. `architect` or
|
|
> `debug` via Cline) when preparing to deploy a new Flask app to the VPS.
|
|
> It describes the established pattern, confirmed working end-to-end with
|
|
> the `better_todo` app (deployed as `steady.bujour.de`).
|
|
|
|
---
|
|
|
|
## Context: the VPS deployment pattern
|
|
|
|
- **Reverse proxy:** Caddy, handles HTTP/HTTPS only, automatic TLS via Let's Encrypt (HTTP-01 challenge). No manual certificate steps — Caddy requests, stores, and renews certs itself, as long as DNS points at the VPS and ports 80/443 are reachable.
|
|
- **Isolation:** Docker Compose, one app per subdirectory under `~/stacks/` on the VPS.
|
|
- **Shared network:** an external Docker network named `proxy` — every app container and Caddy join it, so Caddy can reach containers by name without publishing their ports to the internet.
|
|
- **Own-app pattern (not a pre-built image):** the app's own git repo contains both `Dockerfile` and `compose.yaml`, committed and pushed like any other file. The VPS deploys by cloning that repo directly into `~/stacks/<appname>/` — both files arrive together, already in the right relative location. No copying config files around separately.
|
|
- **Port exposure:** `expose:` in compose.yaml (visible only to other containers on `proxy`), never `ports:` — Caddy is the only thing that should reach the app directly. `ports:` is only needed for non-HTTP protocols (e.g. Forgejo's SSH on 2222), which requires opening the port in *both* UFW and the IONOS cloud firewall.
|
|
|
|
## Standard inputs needed before generating files
|
|
|
|
Ask for these if not already known:
|
|
1. App/repo name (used as container name and `~/stacks/` subfolder name)
|
|
2. Public subdomain (e.g. `steady.bujour.de`) — separate from the repo/container name, no need to match
|
|
3. Python version used during local development
|
|
4. Entry point — confirm app-factory pattern (`wsgi.py` importing `app = create_app()`)
|
|
5. Contents of `requirements.txt` — specifically, is a production WSGI server (`gunicorn`) already listed? Never add a redundant `pip install gunicorn` line if it's already in requirements.txt.
|
|
6. Does the app hold persistent data (SQLite file, uploads, etc.)?
|
|
7. If SQLite: the **exact** resolved path of the DB file inside the container. Don't assume Flask's default `instance_path` — trace it from the actual config. Find `BASE_DIR` (or equivalent) in the config file, confirm how many `.parent` calls it uses and where that config file sits relative to `wsgi.py`. If `wsgi.py` and the config are both at repo root, `BASE_DIR` = `/app` inside the container (since `Dockerfile` does `COPY . .` from repo root into `/app`), so a `sqlite:///{BASE_DIR}/instance/x.db` pattern resolves to `/app/instance/x.db`. If either file lives in a subfolder, adjust accordingly.
|
|
8. Port the app listens on (gunicorn's `--bind` target)
|
|
|
|
## Dockerfile template
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim # match confirmed local Python version
|
|
|
|
WORKDIR /app
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 5000 # match confirmed app port
|
|
|
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "wsgi:app"]
|
|
```
|
|
|
|
Notes:
|
|
- `requirements.txt` copied and installed *before* the rest of the app — preserves Docker's layer cache so code-only changes don't re-trigger a full pip install.
|
|
- Never use `flask run` / `app.run()` as the production `CMD` — single-threaded dev server, not meant for real traffic.
|
|
|
|
## compose.yaml template
|
|
|
|
```yaml
|
|
services:
|
|
<appname>:
|
|
build: .
|
|
container_name: <appname>
|
|
restart: unless-stopped
|
|
volumes:
|
|
- <appname>_data:/app/<resolved instance path>
|
|
networks:
|
|
- proxy
|
|
expose:
|
|
- "<port>"
|
|
|
|
volumes:
|
|
<appname>_data:
|
|
|
|
networks:
|
|
proxy:
|
|
external: true
|
|
```
|
|
|
|
## Caddy site block template
|
|
|
|
```
|
|
<subdomain>.bujour.de {
|
|
reverse_proxy <appname>:<port>
|
|
}
|
|
```
|
|
|
|
Added to the existing Caddyfile under `~/stacks/caddy/`, then Caddy reloaded.
|
|
|
|
## Deployment steps (VPS side)
|
|
|
|
1. Confirm DNS: subdomain covered by wildcard, or new A record added in IONOS.
|
|
2. `git clone <repo-url> ~/stacks/<appname>`
|
|
3. Add the Caddy site block, reload Caddy.
|
|
4. `cd ~/stacks/<appname> && docker compose up -d`
|
|
5. Check logs: `docker compose logs -f`
|
|
6. Test internally: `curl localhost:<port>` (bypasses Caddy/DNS — isolates app vs. proxy problems)
|
|
7. Test externally: `https://<subdomain>.bujour.de` — confirm valid HTTPS, not just that it loads
|
|
8. Log the new app in `VPS_Setup_Guide.md`: add row to "Current Stack on VPS" table, add bullet under "What Is Confirmed Working"
|
|
|
|
## Worked example on file: `better_todo` → `steady.bujour.de`
|
|
|
|
- Repo: `better_todo`, container name `better_todo`, subdomain `steady.bujour.de`
|
|
- `wsgi.py` at repo root, `app = create_app()`
|
|
- `gunicorn` already present in `requirements.txt`
|
|
- Config: `SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE_DIR / 'instance' / 'steady.db'}"`, with `BASE_DIR = Path(__file__).resolve().parent` — config file also at repo root
|
|
- Resolved container path: `/app/instance/steady.db` → volume `better_todo_data:/app/instance`
|
|
- Port: 5000
|
|
- Confirmed working end-to-end (internal curl + external HTTPS test both passed)
|
|
|
|
---
|
|
|
|
**Getan:** Reusable LLM deployment prompt written, generalizing the `better_todo`/`steady` deployment into a template for future Flask apps.
|
|
**Gelernt:** SQLite path resolution must be traced from the actual `BASE_DIR` definition and its file's location relative to `wsgi.py` — never assumed from Flask's default `instance_path`.
|