Files
team-tryouts/docs/deployment.md
T
GGThed 39808dd04e ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant :

- rien ne part d un arbre casse. La suite, ruff check et ruff format
  tournent sur le runner de deploiement avant tout envoi. Une CI verte sur
  GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce
  que la branche contient a cet instant ;
- seuls les fichiers nommes partent. La charge est une liste blanche —
  app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf
  exclusions. C est par cette porte que clear_db.py, la suite de tests et
  les definitions de CI se sont retrouves sur le noeud de production ;
- le deploiement est verifie. /health est interroge pendant deux minutes
  apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant,
  un arbre a moitie televerse etait un deploiement vert.

Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est
pas atomique. Le miroir se fait sur place, donc pendant le transfert la
production execute un melange de deux versions.

En cherchant a fermer ce point, un defaut a part entiere est apparu. Les
contrats etaient ranges a os.getcwd()/documents et leur chemin absolu
ecrit en base. La racine de stockage suivait donc le repertoire depuis
lequel le processus avait ete lance : redemarrer le serveur ailleurs
envoie les nouveaux contrats dans un nouvel arbre et rend les anciens
illisibles — la base continuant d affirmer qu ils sont la, la panne se
manifeste par un 500 au telechargement, pas par quelque chose
d actionnable.

app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles
lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu
et continuent de resoudre : aucune migration de donnees n est necessaire,
donc ce changement n attend pas Alembic.

C etait aussi le troisieme pre-requis de la bascule par repertoires de
version. Les deux autres sont hors d atteinte d ici — la commande de
demarrage Pterodactyl doit pointer sur current/, et les repertoires
partages doivent etre installes sur le noeud. Les deux sont decrits dans
docs/deployment.md, avec la procedure de retour arriere qui manquait.

511 tests.
2026-08-11 13:58:54 -04:00

9.6 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.

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