5.4 KiB
5.4 KiB
Flask App VPS Deployment — LLM Prompt
Purpose: Paste this into a chat with a local model (e.g.
architectordebugvia Cline) when preparing to deploy a new Flask app to the VPS. It describes the established pattern, confirmed working end-to-end with thebetter_todoapp (deployed assteady.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
Dockerfileandcompose.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 onproxy), neverports:— 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:
- App/repo name (used as container name and
~/stacks/subfolder name) - Public subdomain (e.g.
steady.bujour.de) — separate from the repo/container name, no need to match - Python version used during local development
- Entry point — confirm app-factory pattern (
wsgi.pyimportingapp = create_app()) - Contents of
requirements.txt— specifically, is a production WSGI server (gunicorn) already listed? Never add a redundantpip install gunicornline if it's already in requirements.txt. - Does the app hold persistent data (SQLite file, uploads, etc.)?
- 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. FindBASE_DIR(or equivalent) in the config file, confirm how many.parentcalls it uses and where that config file sits relative towsgi.py. Ifwsgi.pyand the config are both at repo root,BASE_DIR=/appinside the container (sinceDockerfiledoesCOPY . .from repo root into/app), so asqlite:///{BASE_DIR}/instance/x.dbpattern resolves to/app/instance/x.db. If either file lives in a subfolder, adjust accordingly. - Port the app listens on (gunicorn's
--bindtarget)
Dockerfile template
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.txtcopied 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 productionCMD— single-threaded dev server, not meant for real traffic.
compose.yaml template
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)
- Confirm DNS: subdomain covered by wildcard, or new A record added in IONOS.
git clone <repo-url> ~/stacks/<appname>- Add the Caddy site block, reload Caddy.
cd ~/stacks/<appname> && docker compose up -d- Check logs:
docker compose logs -f - Test internally:
curl localhost:<port>(bypasses Caddy/DNS — isolates app vs. proxy problems) - Test externally:
https://<subdomain>.bujour.de— confirm valid HTTPS, not just that it loads - 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 namebetter_todo, subdomainsteady.bujour.de wsgi.pyat repo root,app = create_app()gunicornalready present inrequirements.txt- Config:
SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE_DIR / 'instance' / 'steady.db'}", withBASE_DIR = Path(__file__).resolve().parent— config file also at repo root - Resolved container path:
/app/instance/steady.db→ volumebetter_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.