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