From 11820b9f4e63dad30ae72904ad1330c42f0a17ce Mon Sep 17 00:00:00 2001 From: patsy Date: Sat, 8 Aug 2026 06:08:24 +0200 Subject: [PATCH] deployment ready --- .dockerignore | 13 +++ .env.example | 4 +- DEPLOYMENT.md | 169 +++++++++++++++++++++++++++----- Dockerfile | 17 ++-- README.md | 13 ++- app/__init__.py | 17 ++++ compose.yaml | 29 ++++-- config.py | 1 + flask_app_deploy_prompt.md | 108 ++++++++++++++++++++ tests/polish/test_production.py | 20 ++++ 10 files changed, 348 insertions(+), 43 deletions(-) create mode 100644 .dockerignore create mode 100644 flask_app_deploy_prompt.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ad0e34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.env +.venv +__pycache__ +*.py[cod] +.pytest_cache +.coverage +htmlcov +instance/*.db +instance/*.sqlite* +tests +*.md diff --git a/.env.example b/.env.example index 804c9e3..4926982 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ +# Local development values. Docker Compose sets STEADY_ENV and DATABASE_URL. STEADY_ENV=development SECRET_KEY=replace-with-a-long-random-value # DATABASE_URL may override the default absolute instance/steady.db location. FEATURE_ADMIN=false -# TRUSTED_HOSTS=steady.example.com,www.steady.example.com +TRUSTED_HOSTS=steady.bujour.de +TRUST_PROXY_HEADERS=false GUNICORN_BIND=127.0.0.1:8000 GUNICORN_WORKERS=2 GUNICORN_THREADS=2 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 43d83aa..d7e0ac2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,43 +1,158 @@ -# Deployment Guide +# Deploy Steady with Docker Compose and Caddy -## Production Requirements +This repository is ready for the established VPS layout: -Use a supported Python version, Gunicorn, TLS termination, and a reverse proxy or managed platform. Never use `flask run` in production. Flask's official guidance recommends a dedicated WSGI server and commonly a reverse proxy: [Flask deployment documentation](https://flask.palletsprojects.com/en/stable/deploying/). +- repository directory: `~/stacks/steady` +- container/service name: `steady` +- public address: `https://steady.bujour.de` +- private Docker port: `5000` (it is not published on the VPS) +- persistent SQLite volume: `steady_data`, mounted at `/app/instance` +- reverse proxy: Caddy on the external Docker network `proxy` -Set environment variables outside the repository: +The container runs database migrations before Gunicorn starts. Caddy terminates +TLS and forwards requests over the private Docker network. + +## 1. Prerequisites + +Install Git, Docker Engine with the Compose plugin, and Caddy in its existing +Compose stack. DNS for `steady.bujour.de` must resolve to the VPS, and inbound +ports 80 and 443 must be allowed by both the VPS firewall and provider firewall. +Do not open port 5000. + +Confirm the shared proxy network exists: ```bash -STEADY_ENV=production -SECRET_KEY= -DATABASE_URL=sqlite:////srv/steady/instance/steady.db -TRUSTED_HOSTS=steady.example.com -FEATURE_ADMIN=false +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy ``` -Generate a secret with `python -c "import secrets; print(secrets.token_urlsafe(48))"`. Restrict the instance directory and environment to the service account. SQLite is suitable for a single-host deployment; use platform-managed storage and a database designed for concurrency before horizontal scaling. +## 2. Clone the application -## Release Procedure +Replace the example repository URL with the actual Git remote: ```bash -python -m venv .venv -source .venv/bin/activate -python -m pip install -r requirements.txt -flask --app app db upgrade -python -m pytest -gunicorn --config gunicorn.conf.py wsgi:app +mkdir -p ~/stacks +git clone ~/stacks/steady +cd ~/stacks/steady ``` -Gunicorn binds to `127.0.0.1:8000` by default. Put TLS and request buffering in a trusted reverse proxy; do not expose the internal bind when the proxy is intended to be mandatory. Configure forwarded headers only for known proxy addresses rather than applying `ProxyFix` generically. +For an existing checkout, use `git pull --ff-only` instead of cloning it again. -## Operations +## 3. Create the production environment -- Probe `/health` for process availability; it intentionally does not test every dependency. -- Back up the SQLite database using a consistent database-aware snapshot. Per-user JSON exports are portability backups, not a replacement for server backups. -- Back up before migrations and test restore procedures regularly. -- Rotate `SECRET_KEY` only with a plan to invalidate existing sessions. -- Review logs without recording passwords, secrets, backup contents, or task descriptions. -- Confirm HTTPS responses include HSTS and all responses include the configured security headers. -- Keep `FEATURE_ADMIN=false` unless the guarded placeholder is explicitly required. +Compose automatically reads `~/stacks/steady/.env`. Create it on the VPS; do +not commit it: -Rollback should restore both the prior application release and its compatible database backup. Do not run destructive schema downgrades without reviewing the generated migration and recovery plan. +```bash +cd ~/stacks/steady +umask 077 +python -c "import secrets; print('SECRET_KEY=' + secrets.token_urlsafe(48))" > .env +printf '%s\n' 'TRUSTED_HOSTS=steady.bujour.de' >> .env +``` +`compose.yaml` supplies the remaining production settings, including the exact +SQLite URL `sqlite:////app/instance/steady.db`. The named volume therefore +persists the database across container replacement. + +Validate the rendered configuration without printing the secret in shared logs: + +```bash +docker compose config --quiet +``` + +## 4. Build and start + +```bash +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=100 steady +``` + +Startup fails if the migration fails, so do not ignore an unhealthy or restarting +container. Check the private endpoint from another container on the proxy network: + +```bash +docker run --rm --network proxy curlimages/curl:8.14.1 -fsS http://steady:5000/health +``` + +The expected body is `{"status":"ok"}`. `curl localhost:5000` intentionally +does not work because the application port is not published to the host. + +## 5. Configure Caddy + +Add this site block to the existing Caddyfile in `~/stacks/caddy/`: + +```caddyfile +steady.bujour.de { + reverse_proxy steady:5000 +} +``` + +Ensure the Caddy service also joins the external `proxy` network. Validate and +reload Caddy using its actual Compose service name (the examples assume +`caddy`): + +```bash +cd ~/stacks/caddy +docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile +docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +Caddy obtains and renews the TLS certificate automatically after DNS and ports +80/443 are correct. + +## 6. Verify the public deployment + +```bash +curl -fsS https://steady.bujour.de/health +curl -I https://steady.bujour.de/ +``` + +Confirm the health JSON, a valid HTTPS certificate, and security headers such as +`Strict-Transport-Security`, `Content-Security-Policy`, and +`X-Content-Type-Options`. Then register an account and exercise a normal task +workflow in the browser. + +## Updates + +Back up first, then pull, rebuild, and replace the service: + +```bash +cd ~/stacks/steady +docker compose exec -T steady python -c "import sqlite3; source=sqlite3.connect('/app/instance/steady.db'); backup=sqlite3.connect('/app/instance/steady-backup.db'); source.backup(backup); backup.close(); source.close()" +git pull --ff-only +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=100 steady +``` + +Copy the backup off the named volume to protected storage as part of the VPS +backup process. The in-volume backup is only an immediate pre-update snapshot. +Test restore procedures regularly. + +## Operations and rollback + +- Probe `https://steady.bujour.de/health` for process availability. +- View logs with `docker compose logs --tail=200 steady` or add `-f` to follow. +- Restart with `docker compose restart steady`. +- Keep `FEATURE_ADMIN=false` unless the guarded, data-free placeholder is needed. +- Never expose Flask/Gunicorn directly or use `flask run` in production. +- Rotate `SECRET_KEY` only when intentionally invalidating all sessions. + +Application rollback requires both the prior Git revision/image and a compatible +database backup. Do not run destructive migration downgrades without reviewing +the migration and recovery plan. + +## Troubleshooting + +- **Compose says `SECRET_KEY` is missing:** create `.env` as shown above. +- **Container restarts:** inspect `docker compose logs steady`; migration or + configuration errors appear before Gunicorn starts. +- **Caddy returns 502:** verify both containers join `proxy`, the service is + healthy, and the upstream is exactly `steady:5000`. +- **Host rejected with HTTP 400:** make `TRUSTED_HOSTS` match the public hostname. +- **No certificate:** verify public DNS and inbound ports 80/443; inspect Caddy's + logs, not the application logs. +- **Permission error for SQLite:** verify `/app/instance` is the `steady_data` + volume. The image runs as the unprivileged user `steady` (UID 10001). diff --git a/Dockerfile b/Dockerfile index 783c110..650f2f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,4 @@ -# Base image: matches Python 3.12. Change this if you tested locally on a -# different version — a mismatch can cause subtle bugs that don't show up -# until production (e.g. dependency wheels built for the wrong version). -FROM python:3.12-slim +FROM python:3.14-slim # All subsequent commands run from here inside the container. WORKDIR /app @@ -12,10 +9,16 @@ WORKDIR /app # edited your Python files. Copying everything first would bust that cache # on every single code change. COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN python -m pip install --no-cache-dir -r requirements.txt # Now copy the rest of the application code. -COPY . . +RUN useradd --create-home --uid 10001 steady \ + && mkdir -p /app/instance \ + && chown -R steady:steady /app + +COPY --chown=steady:steady . . + +USER steady # Documents which port the app listens on. This does NOT actually publish # the port anywhere — that happens in compose.yaml via `expose`. @@ -25,4 +28,4 @@ EXPOSE 5000 # that's Flask's single-threaded development server, not meant to handle # real traffic or run unattended. gunicorn is a proper WSGI server. # "wsgi:app" means: import the `app` object from wsgi.py. -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "wsgi:app"] +CMD ["gunicorn", "--config", "gunicorn.conf.py", "wsgi:app"] diff --git a/README.md b/README.md index 2dcd8ad..123d349 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ theme, reading style, completion sound, and future reminder frequency. ## Local setup +Python 3.14 is the development and container runtime used by this repository. + ```bash python -m venv .venv source .venv/bin/activate @@ -81,8 +83,15 @@ status, and due-date query path. Dynamic responses are not cached; static assets use a bounded cache; and responses include CSP, framing, MIME, referrer, permissions, and cross-origin policies. -Use [DEPLOYMENT.md](DEPLOYMENT.md) for Gunicorn, environment, TLS/reverse-proxy, -migration, backup, health-check, and rollback guidance. Use +The production stack uses Docker Compose behind Caddy at +`https://steady.bujour.de`. In summary: clone the repository to +`~/stacks/steady`, create a private `.env` containing `SECRET_KEY`, start with +`docker compose up -d --build`, add `reverse_proxy steady:5000` to Caddy, and +verify `/health`. The application port is private and must not be published. + +Follow [DEPLOYMENT.md](DEPLOYMENT.md) for the complete copy-paste deployment, +update, TLS/reverse-proxy, migration, backup, verification, troubleshooting, and +rollback procedure. Use [ACCESSIBILITY.md](ACCESSIBILITY.md) for automated evidence and the mandatory manual browser, keyboard, zoom, forced-colors, and screen-reader release matrix. diff --git a/app/__init__.py b/app/__init__.py index e664e71..7af78a7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix from config import CONFIGS @@ -33,6 +34,7 @@ def create_app( Path(app.instance_path).mkdir(parents=True, exist_ok=True) _validate_config(app) + _configure_proxy_headers(app) _initialize_extensions(app) _register_blueprints(app) configure_security_headers(app) @@ -45,6 +47,21 @@ def _validate_config(app: Flask) -> None: raise RuntimeError("SECRET_KEY must be set outside development and testing.") +def _configure_proxy_headers(app: Flask) -> None: + """Trust one reverse proxy only when explicitly enabled. + + The production container is not published on a host port, so its only HTTP + peer on the Docker network is the Caddy reverse proxy. + """ + if app.config.get("TRUST_PROXY_HEADERS"): + app.wsgi_app = ProxyFix( + app.wsgi_app, + x_for=1, + x_proto=1, + x_host=1, + ) + + def _initialize_extensions(app: Flask) -> None: db.init_app(app) migrate.init_app(app, db) diff --git a/compose.yaml b/compose.yaml index b2506b9..fb70db0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,22 +1,39 @@ services: steady: - build: /home/patsy/apps/steady + build: . container_name: steady restart: unless-stopped environment: - - STEADY_ENV=production - - SECRET_KEY=${SECRET_KEY} + STEADY_ENV: production + SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in the VPS .env file} + DATABASE_URL: sqlite:////app/instance/steady.db + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-steady.bujour.de} + TRUST_PROXY_HEADERS: "true" + GUNICORN_BIND: 0.0.0.0:5000 volumes: - steady_data:/app/instance networks: - proxy expose: - - "8020" + - "5000" + command: + - /bin/sh + - -c + - flask --app app db upgrade && exec gunicorn --config gunicorn.conf.py wsgi:app + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=3)" + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s volumes: steady_data: -a + networks: proxy: external: true - diff --git a/config.py b/config.py index de316f3..f2dff15 100644 --- a/config.py +++ b/config.py @@ -36,6 +36,7 @@ class Config: WTF_CSRF_TIME_LIMIT = 3600 MAX_CONTENT_LENGTH = 2 * 1024 * 1024 TRUSTED_HOSTS = environment_list("TRUSTED_HOSTS") + TRUST_PROXY_HEADERS = environment_flag("TRUST_PROXY_HEADERS") ENABLE_HSTS = False FEATURE_ADMIN = environment_flag("FEATURE_ADMIN") diff --git a/flask_app_deploy_prompt.md b/flask_app_deploy_prompt.md new file mode 100644 index 0000000..170dc90 --- /dev/null +++ b/flask_app_deploy_prompt.md @@ -0,0 +1,108 @@ +# 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`. diff --git a/tests/polish/test_production.py b/tests/polish/test_production.py index 284ca19..0eecbd7 100644 --- a/tests/polish/test_production.py +++ b/tests/polish/test_production.py @@ -38,6 +38,26 @@ def test_production_hsts_is_https_only(): assert "Strict-Transport-Security" not in insecure.headers +def test_trusted_proxy_marks_forwarded_request_secure(): + app = create_app( + "production", + { + "SECRET_KEY": "production-test-only", + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "TRUST_PROXY_HEADERS": True, + }, + ) + client = app.test_client() + + response = client.get( + "/health", + headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "steady.example"}, + ) + + assert response.status_code == 200 + assert "Strict-Transport-Security" in response.headers + + def test_trusted_hosts_reject_unknown_host(): app = create_app( "testing",