# 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](https://nginx.org/en/download.html) 3. **SSL Certificate** (Let's Encrypt via certbot or commercial provider) 4. **Windows Firewall** configured properly ## Step 1: Install Dependencies ```powershell # 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: ```env # Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))") SECRET_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: ```powershell # Does anything answer on the app's port from outside the machine? Test-NetConnection -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=` 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: ```powershell # 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): ```powershell # Using certbot on Windows certbot certonly --standalone -d yourdomain.com ``` Or use your hosting provider's SSL certificate. ## Step 4: Start the Application ```powershell # 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 ```powershell # 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: ```powershell # 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: ```powershell # 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:///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: ```bash # 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" git push origin deploy-2026-08-11 # requires the push freeze to be lifted # 3. Confirm. curl -fsS https:///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 - [ ] `.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