Files
team-tryouts/docs/deployment.md
T
GGThed 3882b6035f fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune
liste de « ce qui reste » ne reprenait.

OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les
valeurs pour la production », puis posait FLASK_DEBUG=true,
SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug
execute du code soumis par le navigateur : cette ligne transformait un
copier-coller en shell distant. Chaque valeur est desormais sure a la
copie, et le fichier refuse de demarrer tant que les deux secrets
obligatoires ne sont pas remplis plutot que de demarrer grand ouvert.

Renomme en .env.example : l orthographe francaise ne correspondait pas a
l exception !.env.example du .gitignore, donc le fichier n etait suivi que
par accident de l ordre des regles. Les deux points de la decision ouverte
du §8 tombent d un seul git mv.

OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les
defauts sont **inchanges**, deliberement : choisir sans connaitre la
topologie coupe la prod si nginx est ailleurs, ou casse la limitation de
debit pour tout le monde si on cesse de croire X-Forwarded-For alors que
c etait la seule source d adresses. Ce sont maintenant des variables, les
valeurs sures sont dans .env.example pour un nouveau deploiement, et
docs/deployment.md donne les quatre topologies avec la valeur de chacune.
wsgi.py avertit au demarrage tant que les deux defauts sont en place.

Le commentaire de HOST annoncait « bind to localhost by default » a cote
d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code
faisait l inverse. Il dit maintenant ce qu il fait.

QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte,
depuis des hotes que la CSP autorise nommement. Qui controle ces CDN
controlait ce qui s execute sur chaque page. Empreintes posees, avec ce
que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier,
ca ne prouve pas qu il etait honnete au moment du calcul.

**Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans
le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque
ouverture du calendrier depuis la montee de version. Une feuille de style
en echec est silencieuse dans le navigateur, c est ce qui l a fait durer.

CI-003 — actions epinglees sur un commit, version en commentaire, dans les
deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du
code arbitraire dans le job qui detient la cle SSH de production. Ce job
recoit aussi enfin un bloc permissions.

517 tests.
2026-08-11 14:42:23 -04:00

12 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

  1. Python 3.12+ installed
  2. Nginx for Windows downloaded from nginx.org
  3. SSL Certificate (Let's Encrypt via certbot or commercial provider)
  4. 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 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:

# 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:

# 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

  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:

# 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 points the document store at a fixed path (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.

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