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]>
13 KiB
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
- Python 3.12+ installed
- Nginx for Windows downloaded from nginx.org
- SSL Certificate (Let's Encrypt via certbot or commercial provider)
- Windows Firewall configured properly
Step 1: Install Dependencies
# 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:
# 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 inauth.logall 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:
# 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
- Copy
app/nginx.confto your Nginx installation directory (e.g.,C:\nginx\conf\) - Edit the
aliasin thelocation /static/block to point at this checkout'sapp/static/directory — absolute path, forward slashes, keep the trailing slash. It ships asC:/team-tryouts/app/static/, which is a guess about your machine. Nginx resolves a relative path against its own install prefix, not againstnginx.conf. - Place SSL certificate files:
C:\nginx\certs\fullchain.pemC:\nginx\certs\privkey.pem
- Check the configuration parses before restarting:
C:\nginx\nginx.exe -t - 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:
# 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):
# Using certbot on Windows
certbot certonly --standalone -d yourdomain.com
Or use your hosting provider's SSL certificate.
Step 4: Start the Application
# 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
# 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:
# 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:
# 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
- Nothing ships from a broken tree. The suite,
ruff checkandruff format --checkall 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. - 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 howclear_db.py, the test suite and the CI definitions got onto the production node. - The deployment is verified.
/healthis polled for two minutes after the upload and the job fails if it never reports healthy. Set theHEALTH_URLrepository secret tohttps://<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:
# 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
.envis not committed to repositorySECRET_KEYis strong (64+ hex characters, randomly generated)FLASK_DEBUGis set tofalse- 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_tokensisoffin 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-auditweekly to check for vulnerabilities - Run
python security_scan.pyafter any configuration changes - Test backup restoration quarterly
- Review and rotate
SECRET_KEYif compromised - Keep Python and system packages updated