Files

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

  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 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. All three roots now come from app/storage.py, and the backup run prints the document source it used. A missing or unarchivable document store makes the run exit non-zero even when the database dump itself is valid, so a scheduler cannot report a database-only recovery point as a complete backup.

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