deployment ready

This commit is contained in:
patsy 2026-08-08 06:08:24 +02:00
parent 87d087b913
commit 11820b9f4e
10 changed files with 348 additions and 43 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.gitignore
.env
.venv
__pycache__
*.py[cod]
.pytest_cache
.coverage
htmlcov
instance/*.db
instance/*.sqlite*
tests
*.md

View file

@ -1,8 +1,10 @@
# Local development values. Docker Compose sets STEADY_ENV and DATABASE_URL.
STEADY_ENV=development STEADY_ENV=development
SECRET_KEY=replace-with-a-long-random-value SECRET_KEY=replace-with-a-long-random-value
# DATABASE_URL may override the default absolute instance/steady.db location. # DATABASE_URL may override the default absolute instance/steady.db location.
FEATURE_ADMIN=false 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_BIND=127.0.0.1:8000
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
GUNICORN_THREADS=2 GUNICORN_THREADS=2

View file

@ -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 ```bash
STEADY_ENV=production docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
SECRET_KEY=<long-random-secret>
DATABASE_URL=sqlite:////srv/steady/instance/steady.db
TRUSTED_HOSTS=steady.example.com
FEATURE_ADMIN=false
``` ```
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 ```bash
python -m venv .venv mkdir -p ~/stacks
source .venv/bin/activate git clone <repository-url> ~/stacks/steady
python -m pip install -r requirements.txt cd ~/stacks/steady
flask --app app db upgrade
python -m pytest
gunicorn --config gunicorn.conf.py wsgi:app
``` ```
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. Compose automatically reads `~/stacks/steady/.env`. Create it on the VPS; do
- Back up the SQLite database using a consistent database-aware snapshot. Per-user JSON exports are portability backups, not a replacement for server backups. not commit it:
- 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.
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).

View file

@ -1,7 +1,4 @@
# Base image: matches Python 3.12. Change this if you tested locally on a FROM python:3.14-slim
# 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
# All subsequent commands run from here inside the container. # All subsequent commands run from here inside the container.
WORKDIR /app WORKDIR /app
@ -12,10 +9,16 @@ WORKDIR /app
# edited your Python files. Copying everything first would bust that cache # edited your Python files. Copying everything first would bust that cache
# on every single code change. # on every single code change.
COPY requirements.txt . 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. # 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 # Documents which port the app listens on. This does NOT actually publish
# the port anywhere — that happens in compose.yaml via `expose`. # 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 # that's Flask's single-threaded development server, not meant to handle
# real traffic or run unattended. gunicorn is a proper WSGI server. # real traffic or run unattended. gunicorn is a proper WSGI server.
# "wsgi:app" means: import the `app` object from wsgi.py. # "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"]

View file

@ -10,6 +10,8 @@ theme, reading style, completion sound, and future reminder frequency.
## Local setup ## Local setup
Python 3.14 is the development and container runtime used by this repository.
```bash ```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate 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, use a bounded cache; and responses include CSP, framing, MIME, referrer,
permissions, and cross-origin policies. permissions, and cross-origin policies.
Use [DEPLOYMENT.md](DEPLOYMENT.md) for Gunicorn, environment, TLS/reverse-proxy, The production stack uses Docker Compose behind Caddy at
migration, backup, health-check, and rollback guidance. Use `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 [ACCESSIBILITY.md](ACCESSIBILITY.md) for automated evidence and the mandatory
manual browser, keyboard, zoom, forced-colors, and screen-reader release matrix. manual browser, keyboard, zoom, forced-colors, and screen-reader release matrix.

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any from typing import Any
from flask import Flask from flask import Flask
from werkzeug.middleware.proxy_fix import ProxyFix
from config import CONFIGS from config import CONFIGS
@ -33,6 +34,7 @@ def create_app(
Path(app.instance_path).mkdir(parents=True, exist_ok=True) Path(app.instance_path).mkdir(parents=True, exist_ok=True)
_validate_config(app) _validate_config(app)
_configure_proxy_headers(app)
_initialize_extensions(app) _initialize_extensions(app)
_register_blueprints(app) _register_blueprints(app)
configure_security_headers(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.") 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: def _initialize_extensions(app: Flask) -> None:
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)

View file

@ -1,22 +1,39 @@
services: services:
steady: steady:
build: /home/patsy/apps/steady build: .
container_name: steady container_name: steady
restart: unless-stopped restart: unless-stopped
environment: environment:
- STEADY_ENV=production STEADY_ENV: production
- SECRET_KEY=${SECRET_KEY} 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: volumes:
- steady_data:/app/instance - steady_data:/app/instance
networks: networks:
- proxy - proxy
expose: 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: volumes:
steady_data: steady_data:
a
networks: networks:
proxy: proxy:
external: true external: true

View file

@ -36,6 +36,7 @@ class Config:
WTF_CSRF_TIME_LIMIT = 3600 WTF_CSRF_TIME_LIMIT = 3600
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 MAX_CONTENT_LENGTH = 2 * 1024 * 1024
TRUSTED_HOSTS = environment_list("TRUSTED_HOSTS") TRUSTED_HOSTS = environment_list("TRUSTED_HOSTS")
TRUST_PROXY_HEADERS = environment_flag("TRUST_PROXY_HEADERS")
ENABLE_HSTS = False ENABLE_HSTS = False
FEATURE_ADMIN = environment_flag("FEATURE_ADMIN") FEATURE_ADMIN = environment_flag("FEATURE_ADMIN")

108
flask_app_deploy_prompt.md Normal file
View file

@ -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/<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`.

View file

@ -38,6 +38,26 @@ def test_production_hsts_is_https_only():
assert "Strict-Transport-Security" not in insecure.headers 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(): def test_trusted_hosts_reject_unknown_host():
app = create_app( app = create_app(
"testing", "testing",