# 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//` — 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: : build: . container_name: restart: unless-stopped volumes: - _data:/app/ networks: - proxy expose: - "" volumes: _data: networks: proxy: external: true ``` ## Caddy site block template ``` .bujour.de { reverse_proxy : } ``` 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 ~/stacks/` 3. Add the Caddy site block, reload Caddy. 4. `cd ~/stacks/ && docker compose up -d` 5. Check logs: `docker compose logs -f` 6. Test internally: `curl localhost:` (bypasses Caddy/DNS — isolates app vs. proxy problems) 7. Test externally: `https://.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`.