Deployment
This guide covers deploying WineBox to a production server using Digital Ocean.
Architecture
WineBox uses a standard production stack:
Internet → nginx (HTTPS) → uvicorn (Python) → MongoDB Atlas
nginx: Reverse proxy with SSL termination
uvicorn: ASGI server running FastAPI
MongoDB Atlas: Cloud-hosted document database
systemd: Process management
Prerequisites
Digital Ocean account
Domain name (e.g.,
winebox.app)Local development environment with WineBox installed
MongoDB Atlas cluster (connection string in
.env)
One-Command Deployment
The recommended way to set up a fresh droplet is with initialise-droplet, which
runs all steps in sequence:
# Full initialisation (setup + DNS + firewall + SSL + deploy + X-Wines)
uv run python -m invoke initialise-droplet
# Preview without making changes
uv run python -m invoke initialise-droplet --dry-run
# Skip X-Wines dataset import
uv run python -m invoke initialise-droplet --skip-xwines
# Specify a particular version
uv run python -m invoke initialise-droplet --version 0.5.6
This runs the following steps automatically:
Server setup — installs packages, creates user/directories, uploads configs
Cloud firewall — ensures ports 22/80/443 are open via DO API
DNS configuration — creates A records for
@,www,boozeonwinebox.appDNS propagation — waits until all domains resolve to the droplet IP
SSL certificates — obtains Let’s Encrypt certs via certbot
Start nginx — starts the reverse proxy
Application deployment — installs WineBox from PyPI, syncs secrets, starts service
X-Wines import — imports the wine dataset (skippable with
--skip-xwines)
Required Environment Variables
Set these in your local .env file before running:
WINEBOX_DO_TOKEN=your-digital-ocean-api-token
WINEBOX_DROPLET_IP=your-droplet-ip
WINEBOX_MONGODB_URL=mongodb+srv://user:pass@cluster.mongodb.net
WINEBOX_ANTHROPIC_API_KEY=sk-ant-...
WINEBOX_SECRET_KEY=your-secret-key
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
Step-by-Step Deployment
If you prefer to run each step individually:
1. Initial Server Setup
Run the setup script on a fresh Ubuntu 22.04/24.04 droplet:
# Set your droplet IP
export WINEBOX_DROPLET_IP=your-droplet-ip
# Run setup (installs nginx, creates directories)
uv run python -m invoke deploy-setup --host $WINEBOX_DROPLET_IP
# Or run directly:
uv run python -m deploy.setup --host $WINEBOX_DROPLET_IP
This installs:
nginx with SSL support
Tesseract OCR
Python with uv package manager
WineBox from PyPI
2. Configure DNS
Point your domain to the droplet:
# Using the deploy script with DNS setup
uv run python -m invoke deploy --setup-dns
Or manually add A records:
@→<droplet-ip>www→<droplet-ip>booze→<droplet-ip>
3. Deploy Application
# Deploy latest version from PyPI
uv run python -m invoke deploy
# Deploy specific version
uv run python -m invoke deploy --version 0.4.0
# Preview changes without applying
uv run python -m invoke deploy --dry-run
4. Setup SSL
After DNS propagates (5-10 minutes):
ssh root@<droplet-ip> "certbot --nginx -d winebox.app -d www.winebox.app"
ssh root@<droplet-ip> "certbot --nginx -d booze.winebox.app"
5. Create Admin User
ssh root@<droplet-ip> "sudo -u winebox /opt/winebox/.venv/bin/winebox-admin add admin --password <password> --admin"
6. Deploy X-Wines Dataset (Optional)
To enable wine autocomplete with 100K+ wines:
# Deploy full dataset (downloads ~500MB)
uv run python -m invoke deploy-xwines
# Or deploy test dataset (100 wines, for testing)
uv run python -m invoke deploy-xwines --test
Deploy Module
The deploy/ directory is a Python module with shared utilities and deployment scripts:
deploy/
├── __init__.py # Package exports
├── common.py # Shared utilities (SSH, DO API, secrets)
├── app.py # Application deployment
├── setup.py # Initial server setup
├── initialise.py # Full droplet initialisation
├── xwines.py # X-Wines dataset deployment
├── winebox.service # systemd service file
└── nginx-winebox.conf # nginx configuration
deploy.initialise
Full droplet initialisation. Combines all steps into a single command.
# Via invoke task
uv run python -m invoke initialise-droplet [options]
# Or directly
uv run python -m deploy.initialise [options]
Options:
--host TEXT Droplet IP (auto-discovered if not set)
--domain TEXT App domain (default: booze.winebox.app)
--version TEXT Package version (default: latest)
--skip-xwines Skip X-Wines dataset import
--dry-run Preview without applying
deploy.setup
Initial server configuration. Run once on a fresh droplet.
# Via invoke task
uv run python -m invoke deploy-setup [options]
# Or directly
uv run python -m deploy.setup [options]
Options:
--host TEXT Droplet IP address
--domain TEXT Domain name (default: winebox.app)
Creates:
/opt/winebox/- Application directory/opt/winebox/data/- Data storage/opt/winebox/logs/- Log files/opt/winebox/config.toml- Configuration/opt/winebox/secrets.env- Secrets/etc/systemd/system/winebox.service- Service file/etc/nginx/sites-available/winebox- nginx config
deploy.app
Deploy updates to an existing server.
# Via invoke task
uv run python -m invoke deploy [options]
# Or directly
uv run python -m deploy.app [options]
Options:
--host TEXT Droplet IP (auto-discovered if not set)
--droplet-name TEXT Droplet name for API lookup
--version TEXT Package version (default: latest)
--no-secrets Skip syncing secrets
--setup-dns Configure DNS A records
--dry-run Preview without applying
Actions:
Discovers droplet IP from Digital Ocean API
Installs/upgrades WineBox from PyPI
Syncs secrets from local
.envto productionRestarts the service
Verifies service is running
deploy.xwines
Deploy the X-Wines dataset for wine autocomplete. Run once after setup. Supports download caching — if CSVs already exist on the droplet from a previous run, the download step is skipped automatically.
# Via invoke task
uv run python -m invoke deploy-xwines [options]
# Or directly
uv run python -m deploy.xwines [options]
Options:
--host TEXT Droplet IP (auto-discovered if not set)
--droplet-name TEXT Droplet name for API lookup
--test Use test dataset (100 wines) instead of full
--dry-run Preview without applying
Actions:
Checks for cached CSVs on the droplet
If not cached: installs gdown, downloads X-Wines dataset (~500MB)
Imports wines into MongoDB
Cleans up temporary files
OAT Admin Panel
The OAT environment runs the admin panel as a separate FastAPI app on its own subdomain (oatadmin.winebox.app, port 8001), distinct from the main wine app on oat.winebox.app (port 8000). Both ship from the same winebox PyPI wheel — there is no separate admin package.
Architecture
Internet ─┬─► oat.winebox.app → nginx → uvicorn winebox.main:app (port 8000)
│
└─► oatadmin.winebox.app → nginx (IP allowlist) → uvicorn winebox.admin.main:app (port 8001)
│
└─ both share the same /opt/winebox/.venv and secrets.env
The admin host is fully IP-restricted at the nginx layer — every path on oatadmin.winebox.app requires a source IP in the allowlist.
Allowlist
The single source of truth is deploy/winebox-admin.toml:
[oat]
allow = [
"109.255.27.13", # operator — home
]
[production]
allow = [
"109.255.27.13",
]
Edit and re-run invoke deploy-oat. The renderer in deploy/common.py:render_nginx_config substitutes each standalone # __ADMIN_ALLOWLIST__ line in the nginx template with allow ...; deny all; directives, indented to the placeholder’s own column. Empty sections are rejected so a typo can’t silently ship an open admin panel.
One-time bring-up
Run once per droplet, in order:
# 1. Add the A record at DigitalOcean (idempotent).
invoke oat-admin-dns
# 2. Wait for DNS to propagate.
dig +short oatadmin.winebox.app
# 3. Issue the Let's Encrypt cert. Briefly stops nginx for the standalone
# HTTP-01 challenge — the same approach as `oat-ssl`.
invoke oat-admin-ssl
# 4. Roll out the nginx config that references the new cert and start the
# admin systemd unit.
invoke deploy-oat
After step 4, the admin panel is live at https://oatadmin.winebox.app.
Routine deploys
invoke deploy-oat (with or without --release) ships both services:
Installs the new
wineboxwheel from PyPI (containswinebox.mainANDwinebox.admin).Uploads
deploy/winebox-oat.service→/etc/systemd/system/winebox.service.Uploads
deploy/winebox-admin-oat.service→/etc/systemd/system/winebox-admin.service.Renders
deploy/nginx-winebox-oat.confagainst the[oat]allowlist and uploads it.Restarts
winebox(port 8000), thenwinebox-admin(port 8001).Health-checks both services. The admin curl talks to
http://localhost:8001/healthfrom the droplet, so the allowlist is bypassed for this internal probe.
Smoke test (post-deploy)
Run from a machine whose IP is in the OAT allowlist (typically the same machine that ran invoke deploy-oat):
uv run python -m pytest tests/test_oat_admin_smoke.py -v
The test module auto-skips on machines that can’t reach /health — CI and unallowlisted dev environments don’t see it as a failure.
Service status / logs
invoke oat-status # both services + both health endpoints
ssh root@<oat-ip> "journalctl -u winebox-admin -n 50" # admin logs
Production Admin Panel
Production runs the admin panel at https://admin.winebox.app on the production droplet (winebox-production, 104.248.46.96), port 8001. Architecture, allowlist semantics, and unit layout match OAT exactly — this section only covers what differs.
Allowlist
The same deploy/winebox-admin.toml file holds both environments. Production uses the [production] section; the renderer in deploy/common.py picks the section based on the nginx config filename (nginx-winebox.conf → production).
One-time bring-up
# 1. Add the A record at DigitalOcean (idempotent).
invoke prod-admin-dns
# 2. Wait for DNS propagation.
dig +short admin.winebox.app
# 3. Issue the cert. Briefly stops nginx for the standalone HTTP-01
# challenge — same approach as `oat-admin-ssl`.
invoke prod-admin-ssl
# 4. Roll out the nginx config that references the new cert and start
# the admin systemd unit.
invoke deploy # full release pipeline
# or
invoke deploy-only --version 0.x.y # re-deploy without bumping
Routine deploys
Both invoke deploy and invoke deploy-only now upload deploy/winebox-admin.service to /etc/systemd/system/winebox-admin.service and start/restart the admin service after the main app, with a 30s polling health check against http://localhost:8001/health.
Smoke test (post-deploy)
Run from a machine in the production allowlist (typically the same machine that ran invoke deploy):
uv run python -m pytest tests/test_production_login.py tests/test_production_admin_smoke.py -v
Both auto-skip on machines that can’t reach the relevant endpoint, so CI doesn’t see them as failures.
Database Migrations
Text Index Migration (v0.5.12)
Version 0.5.12 added sub_region and appellation to the MongoDB text search index.
MongoDB does not allow creating a new text index when one already exists with different
fields, so the old index must be dropped before deploying v0.5.12 or later.
A migration script is provided:
# Run locally
uv run python scripts/migrations/drop_old_text_index.py [MONGODB_URL]
# Run on production
scp scripts/migrations/drop_old_text_index.py root@<droplet-ip>:/tmp/
ssh root@<droplet-ip> "sudo -u winebox /opt/winebox/.venv/bin/python /tmp/drop_old_text_index.py \$WINEBOX_MONGODB_URL"
If MONGODB_URL is not provided, the script falls back to the WINEBOX_MONGODB_URL
environment variable, then defaults to mongodb://localhost:27017.
Database
MongoDB Atlas
WineBox uses MongoDB Atlas as its cloud database. The connection string is
stored in WINEBOX_MONGODB_URL in your local .env file and synced to
production secrets.env on deploy.
The config.toml on the server only sets the database name:
[database]
mongodb_database = "winebox"
# mongodb_url is set via WINEBOX_MONGODB_URL in secrets.env
The environment variable WINEBOX_MONGODB_URL overrides any URL in config.toml.
Configuration
Environment Variables
Set these in your local .env file:
# Required for deployment
WINEBOX_DO_TOKEN=your-digital-ocean-api-token
# Optional: Override droplet lookup
WINEBOX_DROPLET_IP=your-droplet-ip
WINEBOX_DROPLET_NAME=winebox-production
# MongoDB Atlas connection string
WINEBOX_MONGODB_URL=mongodb+srv://user:pass@cluster.mongodb.net
# Secrets to sync to production
WINEBOX_ANTHROPIC_API_KEY=sk-ant-...
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
Production Configuration
The setup script creates these files on the server:
/opt/winebox/config.toml:
[server]
host = "127.0.0.1"
port = 8000
workers = 2
enforce_https = true
[database]
mongodb_database = "winebox"
# mongodb_url is set via WINEBOX_MONGODB_URL in secrets.env
[storage]
data_dir = "/opt/winebox/data"
log_dir = "/opt/winebox/logs"
[email]
backend = "ses"
from_address = "support@winebox.app"
frontend_url = "https://booze.winebox.app"
/opt/winebox/secrets.env:
WINEBOX_SECRET_KEY=<auto-generated>
WINEBOX_ANTHROPIC_API_KEY=<synced-from-local>
WINEBOX_MONGODB_URL=<synced-from-local>
AWS_ACCESS_KEY_ID=<synced-from-local>
AWS_SECRET_ACCESS_KEY=<synced-from-local>
Server Management
systemd Commands
# Start/stop/restart
sudo systemctl start winebox
sudo systemctl stop winebox
sudo systemctl restart winebox
# Check status
sudo systemctl status winebox
# View logs
sudo journalctl -u winebox -f
sudo journalctl -u winebox -n 100
nginx Commands
# Test configuration
sudo nginx -t
# Reload configuration
sudo systemctl reload nginx
# View access logs
sudo tail -f /var/log/nginx/access.log
File Locations
Path |
Purpose |
|---|---|
|
Application root |
|
Python virtual environment |
|
Configuration |
|
Secrets (600 permissions) |
|
Data directory |
|
Uploaded images |
|
Application logs |
|
systemd service |
|
nginx config |
Synced Secrets
The deploy script syncs these secrets from local .env:
Secret |
Purpose |
|---|---|
|
JWT signing key |
|
Claude Vision API |
|
MongoDB Atlas connection |
|
AWS SES email |
|
AWS SES email |
|
AWS SES region |
These are never synced (local-only):
WINEBOX_DROPLET_IPWINEBOX_DO_TOKEN
Updating WineBox
Standard Update
# Deploy latest from PyPI
uv run python -m invoke deploy
# The script will:
# 1. pip install winebox --upgrade
# 2. Sync secrets
# 3. Restart service
# 4. Verify health
Specific Version
uv run python -m invoke deploy --version 0.4.1
Manual Update
ssh root@<droplet-ip>
sudo -u winebox /opt/winebox/.venv/bin/pip install winebox --upgrade
sudo systemctl restart winebox
Monitoring
Health Check
curl https://winebox.app/health
Service Status
ssh root@<droplet-ip> "systemctl status winebox"
Recent Logs
ssh root@<droplet-ip> "journalctl -u winebox -n 50"
Backup and Recovery
Backup
With MongoDB Atlas, database backups are handled by the Atlas service.
You can also use mongodump with the Atlas connection string:
mongodump --uri "mongodb+srv://user:pass@cluster.mongodb.net/winebox" --out ./backups/
# Backup images
ssh root@<droplet-ip> "tar -czf /opt/winebox/backups/images-$(date +%Y%m%d).tar.gz /opt/winebox/data/images"
# Download backups
scp -r root@<droplet-ip>:/opt/winebox/backups/ ./backups/
Restore
# Restore MongoDB
mongorestore --uri "mongodb+srv://user:pass@cluster.mongodb.net" /path/to/backup/winebox/
# Restore images
tar -xzf images-backup.tar.gz -C /opt/winebox/data/
Troubleshooting
Service Won’t Start
# Check logs
journalctl -u winebox -n 100
# Check configuration
sudo -u winebox /opt/winebox/.venv/bin/python -c "from winebox.config import settings; print(settings)"
502 Bad Gateway
nginx can’t connect to uvicorn:
# Check if uvicorn is running
systemctl status winebox
# Check port binding
ss -tlnp | grep 8000
# Restart service
systemctl restart winebox
SSL Certificate Issues
# Test SSL
openssl s_client -connect winebox.app:443
# Renew certificate
certbot renew
# Check certificate expiry
certbot certificates
Database Connection Issues
# Test Atlas connectivity from the droplet
ssh root@<droplet-ip> "sudo -u winebox /opt/winebox/.venv/bin/python -c \"
from winebox.config import settings
print(f'MongoDB URL: {settings.database.mongodb_url[:30]}...')
\""
# Check if the WINEBOX_MONGODB_URL is set in secrets.env
ssh root@<droplet-ip> "grep WINEBOX_MONGODB_URL /opt/winebox/secrets.env"
Security
Firewall
The initialisation script configures both UFW (host-level) and a DO cloud firewall (network-level):
sudo ufw status
# Should show:
# 22/tcp ALLOW (SSH)
# 80/tcp ALLOW (HTTP)
# 443/tcp ALLOW (HTTPS)
Secrets
secrets.envhas 600 permissions (owner read/write only)Owned by
wineboxuserNever synced:
WINEBOX_DO_TOKEN,WINEBOX_DROPLET_IP
Updates
Keep the system updated:
ssh root@<droplet-ip> "apt update && apt upgrade -y"