Files
team-tryouts/docs/deployment.md
T
GGThedandClaude Opus 5 bda23dbb67 fix(ops): la sauvegarde des contrats archivait le mauvais repertoire
OBS-006. Trois racines etaient baties sur os.getcwd() : le magasin de
documents, les journaux et les sauvegardes. La vague G a corrige la
premiere, parce qu'elle bloquait aussi OPS-011, et a laisse les deux autres.
C'est la lecon deja consignee deux fois : un motif fautif corrige dans une
seule couche reste dans les autres.

Le plus serieux n'est pas le motif, c'est l'ecart qu'il a ouvert.
backup.py gardait sa propre constante DOCUMENTS_DIR sur os.getcwd(), donc
il ignorait DOCUMENTS_ROOT -- la variable que la vague G a introduite et que
docs/deployment.md dit maintenant de regler pour sortir les televersements
des repertoires de version. Des qu'un exploitant suit cette consigne, le
script archive un repertoire ou l'application n'a jamais rien ecrit. Et
comme il repond a un repertoire absent par une ligne d'information et un
code de sortie 0, une tache planifiee qui surveille le code de sortie voit
vert indefiniment.

Autrement dit : plus l'exploitant suivait correctement la documentation de
deploiement, plus surement ses sauvegardes de contrats etaient vides.

Les trois racines viennent desormais d'app/storage.py, resolues a l'appel et
non a l'import, et la sauvegarde imprime la source qu'elle a utilisee. Le
message d'absence nomme le chemin ou elle a cherche : "No documents
directory found" se lisait comme "il n'y a pas de documents" plutot que
comme "je regarde au mauvais endroit".

Le test qui porte est celui qui ouvre l'archive : un zip vide est un fichier
de taille non nulle, donc verifier qu'un fichier a ete produit ne prouvait
rien. Verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 18:34:52 -04:00

330 lines
13 KiB
Markdown

# Team Tryouts - Production Deployment Guide (Windows)
This document outlines the secure production deployment for the Team Tryouts
Flask application on Windows with Nginx reverse proxy.
## Architecture Overview
```
Internet → Nginx (HTTPS:443) → Waitress (127.0.0.1:5000) → Flask App
```
- **Nginx**: TLS termination, static file serving, rate limiting, security headers
- **Waitress**: Production WSGI server with multiple worker threads
- **Flask**: Application logic (never exposed directly to the internet)
## Prerequisites
1. **Python 3.12+** installed
2. **Nginx for Windows** downloaded from [nginx.org](https://nginx.org/en/download.html)
3. **SSL Certificate** (Let's Encrypt via certbot or commercial provider)
4. **Windows Firewall** configured properly
## Step 1: Install Dependencies
```powershell
# Install Python packages
pip install -r requirements.txt
# Create required directories
mkdir logs
mkdir backups
```
## Step 2: Configure Environment Variables
Create a `.env` file in the project root:
```env
# Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=<your-generated-64-char-hex-key>
# Database — PostgreSQL. create_app() refuses to start without this.
# Either form works; the driver is named for you if you leave it out.
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Security settings
SESSION_COOKIE_SECURE=true
FORCE_HTTPS=true
FLASK_DEBUG=false
# CORS (set to your actual domain in production)
CORS_ALLOWED_ORIGINS=https://yourdomain.com
# Discord bot (optional)
DISCORD_BOT_TOKEN=
DISCORD_WEBHOOK_URL=
# Backup settings
BACKUP_DIR=./backups
BACKUP_RETENTION_DAYS=30
```
**Important**: Never commit `.env` to version control.
Start from `app/.env.example`, which carries production-safe defaults and
documents every variable. Copying it verbatim gives a configuration that
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
than one that starts and is wide open (OPS-003).
### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the
audit log mean anything: `HOST` and `TRUSTED_PROXY`. Their built-in defaults
(`0.0.0.0` and `*`) are what this application has always done, kept so that
an existing deployment is not changed under it — **they are not the right
values**, and which values are right depends on your topology.
`TRUSTED_PROXY` decides whose `X-Forwarded-For` Waitress believes, and
therefore which address is recorded and counted. Getting it wrong fails in
one of two directions:
- **too trusting** — anyone who can reach the app without going through nginx
sets their own client address. Rate limiting, lockout and the `ip=` field
in `auth.log` all become suggestions;
- **not trusting enough** — every request appears to come from the proxy. One
shared bucket, so the first person to mistype a password five times locks
the limiter for the whole club.
Find your case:
| Topology | `HOST` | `TRUSTED_PROXY` | Why |
|---|---|---|---|
| **nginx on the same machine as the app** (the common case) | `127.0.0.1` | `127.0.0.1` | Waitress is unreachable except through nginx, and only nginx's forwarded header is believed |
| **App in a Pterodactyl container, nginx elsewhere** | `0.0.0.0` | the proxy's address on the container network, e.g. `10.0.0.5` | The app must accept connections from outside the container, so it cannot bind to loopback. Name the proxy rather than trusting `*` |
| **Same as above, but the port is only reachable from the proxy** (firewall or container network) | `0.0.0.0` | `*` | Acceptable *only* because the network already prevents anyone else connecting. If that is not enforced, this is the first failure above |
| **No proxy at all** | `0.0.0.0` | *(empty)* | Nothing forwards anything; the peer address is the client |
To find out which one you are in, on the node:
```powershell
# Does anything answer on the app's port from outside the machine?
Test-NetConnection <public-ip> -Port 5000
# What address does nginx come from, as the app sees it?
# Set TRUSTED_PROXY= (empty) briefly, make one request, and read auth.log:
# the ip= field is then the real peer — which is the proxy.
```
`wsgi.py` prints a warning at startup while both defaults are in place, so an
unconfigured deployment says so in the Pterodactyl console.
## Step 3: Configure Nginx
1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
2. **Edit the `alias` in the `location /static/` block** to point at this
checkout's `app/static/` directory — absolute path, forward slashes, keep
the trailing slash. It ships as `C:/team-tryouts/app/static/`, which is a
guess about your machine. Nginx resolves a relative path against its own
install prefix, not against `nginx.conf`.
3. Place SSL certificate files:
- `C:\nginx\certs\fullchain.pem`
- `C:\nginx\certs\privkey.pem`
4. Check the configuration parses before restarting: `C:\nginx\nginx.exe -t`
5. Start Nginx: `C:\nginx\nginx.exe`
Nginx serves `/static/` from disk with a 30-day `immutable` cache. That is
only safe because `url_for('static', …)` appends `?v=<mtime>` to every static
URL (`version_static_urls` in `app/app.py`), so a redeployed file is requested
under a new URL. If that stamp is ever removed, remove the cache headers with
it or visitors keep a month-old stylesheet.
After a deploy, confirm the stamp changed rather than trusting it:
```powershell
# The v= value must differ from the one served before the deploy.
(Invoke-WebRequest https://your-domain/auth/login).Content -match 'style\.css\?v=(\d+)'
```
### Obtaining SSL Certificates
Using Let's Encrypt with certbot (recommended):
```powershell
# Using certbot on Windows
certbot certonly --standalone -d yourdomain.com
```
Or use your hosting provider's SSL certificate.
## Step 4: Start the Application
```powershell
# Production start
python wsgi.py
# Or with custom port/threads
$env:PORT=5000
$env:WAITRESS_THREADS=5
python wsgi.py
```
## Step 5: Configure Windows Firewall
```powershell
# Allow only necessary ports
New-NetFirewallRule -DisplayName "Nginx HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow
New-NetFirewallRule -DisplayName "Nginx HTTP Redirect" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
# Block direct access to Waitress port (5000) from outside
New-NetFirewallRule -DisplayName "Block Waitress External" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Block
```
## Step 6: Set Up Automated Backups
Create a scheduled task for daily backups:
```powershell
# Run backup script daily at 2:00 AM
$Action = New-ScheduledTaskAction -Execute "python" -Argument "backup.py" -WorkingDirectory "C:\path\to\team-tryouts"
$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM
Register-ScheduledTask -TaskName "TeamTryoutsBackup" -Action $Action -Trigger $Trigger
```
## Step 7: Run Security Validation
Before going live:
```powershell
# Run the security scanner
python security_scan.py --url https://yourdomain.com
```
All checks must pass before deployment.
## Deploying, and undoing a deployment (OPS-011)
Deployment is the Gitea workflow `.gitea/workflows/git-to-ptero.yaml`, run by
hand (`workflow_dispatch`). It mirrors files over SFTP to the Pterodactyl
node.
### What the workflow guarantees
1. **Nothing ships from a broken tree.** The suite, `ruff check` and
`ruff format --check` all run on the deploy runner first. A green CI on
GitHub proves nothing here: the deploy is triggered by hand, on whatever
the branch currently holds.
2. **Only named files ship.** The payload is an allowlist — `app/`,
`wsgi.py`, `requirements.txt` — not the working tree minus exclusions.
A new file at the repository root does not reach production unless
somebody adds it. The old form is how `clear_db.py`, the test suite and
the CI definitions got onto the production node.
3. **The deployment is verified.** `/health` is polled for two minutes after
the upload and the job fails if it never reports healthy. Set the
`HEALTH_URL` repository secret to `https://<host>/health`; without it the
workflow warns that the deployment went out unverified.
Deliberately *not* shipped: `run.py` (development entry point), `tests/`,
`migrations/add_tryout_coaches.py` (a one-off, already applied — run it by
hand if a fresh database ever needs it), `docs/`, `audit/`, `clear_db.py`.
### What it does not guarantee
**The switch is not atomic.** Files are mirrored in place, so for the length
of the transfer the node runs a mixture of two versions. And because
`--delete` is off — uploaded contracts, logs and the server's `.env` live
under the deployment root and are absent from the repository — a file
removed from the repository stays on the server for ever.
### Rolling back
There is no previous release on the node to switch back to, so a rollback is
a forward deployment of a known-good commit:
```bash
# 1. Find the last deployment that was verified healthy — the workflow run
# log names the commit.
git log --oneline
# 2. Deploy that commit. In the Gitea UI, run the "Push to SFTP" workflow
# against the tag or branch pointing at it. Tag known-good releases so
# this step does not depend on reading a log:
git tag -a deploy-2026-08-11 -m "verified healthy" <commit>
git push origin deploy-2026-08-11 # requires the push freeze to be lifted
# 3. Confirm.
curl -fsS https://<host>/health
```
A rollback does **not** undo a database migration. If the deployment that
broke production also changed the schema, restore from backup first —
`docs/database-restore.md`.
### Making the switch atomic
The remaining work, and why it is not done here. A release-directory layout
looks like this on the node:
```
/home/container/
├── releases/
│ ├── 2026-08-11-a1b2c3/
│ └── 2026-08-10-9f8e7d/
├── current -> releases/2026-08-11-a1b2c3
├── documents/ # shared, never inside a release
├── logs/ # shared
└── .env # shared
```
Three prerequisites. One is done, two are not:
| # | Prerequisite | State |
|---|---|---|
| 1 | The Pterodactyl startup command must run the app from `current/`, and the server must be restarted on switch | **Panel change.** Cannot be made or verified from the repository |
| 2 | `documents/`, `logs/` and `.env` must sit beside the releases, not inside one. `DOCUMENTS_ROOT` and `LOG_DIR` point the document store and the logs at fixed paths (`app/storage.py`) | Mechanism ready, **not yet configured on the node** |
| 3 | Contract paths must be relative to that root, or the first switch strands every contract ever uploaded | **Done.** New rows store a relative path; rows written earlier keep their absolute one and still resolve, so no data migration is needed |
Prerequisite 3 was a defect on its own, not just a blocker: paths were built
from `os.getcwd()`, so starting the server from a different directory would
have sent new contracts to a new tree and made the existing ones unreadable
— with the database still claiming they were there.
**The same defect was in two more places, and one of them mattered more.**
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
backup script kept its own copy of the document path — so it archived
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
was therefore enough, on its own, to make every contract backup empty; the
script prints `No documents directory…` and still exits 0, so a scheduled
task watching the exit code would have seen green indefinitely. All three
roots now come from `app/storage.py`, and the backup run prints the document
source it used.
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
and check the `Document source:` line and the size of the resulting
`documents_backup_*.zip`.
## Security Checklist
- [ ] `.env` is not committed to repository
- [ ] `SECRET_KEY` is strong (64+ hex characters, randomly generated)
- [ ] `FLASK_DEBUG` is set to `false`
- [ ] Nginx is running with HTTPS (port 443)
- [ ] HTTP (port 80) redirects to HTTPS
- [ ] TLS 1.2+ configured (TLS 1.0/1.1 disabled)
- [ ] HSTS header is present (`max-age=31536000; includeSubDomains; preload`)
- [ ] CSP header is configured with no `unsafe-eval`
- [ ] `server_tokens` is `off` in Nginx
- [ ] File uploads restricted to PDF only
- [ ] File uploads limited to 16MB
- [ ] Database backups scheduled daily
- [ ] Firewall rules applied (only 80/443 open)
- [ ] Application is not running as administrator
- [ ] Logs directory exists and is writable
- [ ] Health check endpoint returns 200
## Monitoring
- **Application logs**: `./logs/app.log`
- **Error logs**: `./logs/errors.log`
- **Auth logs**: `./logs/auth.log`
- **Nginx access logs**: `C:\nginx\logs\access.log`
- **Nginx error logs**: `C:\nginx\logs\error.log`
Monitor these logs regularly for suspicious activity.
## Maintenance
- Run `pip-audit` weekly to check for vulnerabilities
- Run `python security_scan.py` after any configuration changes
- Test backup restoration quarterly
- Review and rotate `SECRET_KEY` if compromised
- Keep Python and system packages updated