Production Setup
Deploy Potato in production with gunicorn, nginx or Apache, TLS termination, Docker, session persistence, and the access control a shared server needs.
Deploy Potato for real-world annotation tasks with multiple users, data persistence, and proper security.
Server Configuration
Basic Production Config
server:
host: 0.0.0.0
port: 8000
debug: falseThe server block accepts host, port and debug, and nothing else. Worker counts and timeouts belong to whichever WSGI server runs Potato rather than to the config file. Keep debug off in production, because debug mode opens the admin pages without a key to requests from the server's own machine, which on a deployed host includes its reverse proxy.
Using HTTPS
Potato's config carries no TLS settings, so certificates are not configured there. Terminate TLS at the reverse proxy in front of Potato, which is what the nginx and Apache sections below set up, or hand the certificate to gunicorn directly as shown under "With SSL".
Running with Gunicorn
Run Potato under gunicorn with exactly one worker, and raise the thread count when you need more concurrency:
gunicorn "potato.flask_server:create_app('config.yaml')" \
--workers 1 \
--threads 8 \
--bind 0.0.0.0:8000 \
--timeout 120The single worker is a correctness requirement rather than a tuning choice. Potato holds its item pool, its assignment queue and every annotator's state in memory inside the process. A second worker gets its own copy of all three, so it hands out instances the first worker already assigned, and because user_state.json is rewritten in full on every save, whichever worker saves last discards the other's annotations. Nothing reports the loss, and the annotations are simply gone. Threads share one copy of that state, so --threads raises concurrency safely where --workers does not.
With SSL
gunicorn "potato.flask_server:create_app('config.yaml')" \
--workers 1 \
--threads 8 \
--bind 0.0.0.0:443 \
--certfile /path/to/cert.pem \
--keyfile /path/to/key.pemReverse Proxy Setup
Nginx Configuration
upstream potato {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name annotation.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name annotation.example.com;
ssl_certificate /etc/letsencrypt/live/annotation.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/annotation.example.com/privkey.pem;
location / {
proxy_pass http://potato;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long annotation sessions
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
}Apache Configuration
<VirtualHost *:443>
ServerName annotation.example.com
SSLEngine on
SSLCertificateFile /path/to/cert.pem
SSLCertificateKeyFile /path/to/key.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/
<Proxy *>
Order allow,deny
Allow from all
</Proxy>
</VirtualHost>Mounting Under a URL Path
Serving Potato at https://host/app1/ rather than at a domain root takes one extra step. The annotation page requests /static/... and /updateinstance relative to the public root, so under a path prefix the assets 404 and annotations fail to save while the shell still renders.
Tell Potato its public prefix. When you control the proxy and it can send forwarded headers:
export POTATO_PROXY_FIX=1
potato start config.yaml -p 8000location /app1/ {
proxy_pass http://127.0.0.1:8000/; # trailing slash strips /app1/
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app1;
}Enable POTATO_PROXY_FIX only when the app is reachable exclusively through that proxy. It trusts forwarded headers, so a client that can reach the internal port directly could spoof the prefix and poison every generated URL.
When you cannot add headers but know the mount path, set it directly. The proxy must still strip the prefix before forwarding:
export POTATO_URL_PREFIX=/app1
potato start config.yaml -p 8000Test by signing in on a browser with no session, since the login form is the page a missing prefix breaks first.
The live agent and live coding viewers stream over Server-Sent Events, which a buffering proxy holds back until the stream ends. Turn buffering off on those routes:
location /app1/api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 3600s;
}Docker Deployment
Potato publishes a prebuilt image to GitHub Container Registry, ghcr.io/davidjurgens/potato. It holds Potato and its dependencies and nothing else. Your project folder, with config.yaml and its data, mounts at /app, so one image serves every task:
docker run -p 8000:7860 \
-v "$PWD/myproject:/app" \
-e POTATO_SECRET_KEY="$(openssl rand -hex 32)" \
ghcr.io/davidjurgens/potato:latestThe latest tag and the release tags, such as 2.12.1, carry the core dependencies only. The -all tags (latest-all, 2.12.1-all) add the AI SDKs, the format readers, and the export and authentication extras, and leave out the vision models.
The container serves the task with one gunicorn worker and eight threads, for the reason given in the gunicorn section above. Setting GUNICORN_WORKERS above 1 stops the container at startup unless POTATO_ALLOW_MULTIWORKER=1 is also set, which loses annotations in a real study. Raise GUNICORN_THREADS for more concurrency. POTATO_CONFIG names a config file other than config.yaml, and PORT changes the port inside the container from 7860.
The container runs as uid 1000 and writes annotation output, potato.log and its SQLite databases into /app. On Linux, a bind-mounted project folder owned by a different user stops the server at boot with an error that names the problem. Give the folder to uid 1000 with sudo chown -R 1000:1000 myproject. Docker Desktop on a Mac ignores bind-mount ownership, so the error appears only on Linux.
Arguments after the image name replace the server command, which is useful for one-off checks:
docker run -v "$PWD/myproject:/app" \
ghcr.io/davidjurgens/potato:latest potato validate config.yaml --strictDocker Compose
services:
potato:
image: ghcr.io/davidjurgens/potato:latest
ports:
- "8000:7860"
volumes:
- ./myproject:/app
environment:
- POTATO_SECRET_KEY=${POTATO_SECRET_KEY}
- POTATO_ADMIN_API_KEY=${POTATO_ADMIN_API_KEY}
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/ssl/certs
depends_on:
- potato
restart: unless-stoppedInside the Compose network, nginx reaches Potato by service name on the container's port, so the upstream block in the reverse proxy section above becomes server potato:7860; rather than server 127.0.0.1:8000;.
Because the project folder is mounted rather than copied into an image, the annotations Potato writes into it stay on the host when the container is replaced.
Access Control
Restricting Who Can Register
Potato allows self-registration by default. On a public server, list who may sign up:
user_config:
allow_all_users: false
users:
- annotator1@example.com
- annotator2@example.comBoth keys belong under user_config. Written at the top level they are ignored, which reads as an access list that admits everyone.
Admin Access
admin_api_key is a shared key sent as the X-API-Key header. Potato generates one and writes it to {task_dir}/admin_api_key.txt when you do not set it, so on a production host set it yourself and keep it out of the repository:
admin_api_key: "your-shared-key"
rbac:
enabled: true
user_role_assignments:
lead@example.com: adminWith rbac configured, named people reach /admin by logging in normally instead of passing the shared key around. See User Management for the full permission set.
Sessions
secret_key: "a-long-random-string"
session_timeout_minutes: 60
persist_sessions: true
session_lifetime_days: 7
max_session_seconds: 7200secret_key signs the session cookie and can come from POTATO_SECRET_KEY instead. Without one, Potato generates a new key at every startup, so every restart logs out everyone mid-task. On a server that restarts on deploy, this is the setting that most often costs annotations.
session_timeout_minutes clears a session that has been idle that long, and the clock restarts on every request. Potato 2.11 is the first release that enforces it, with a default of 480 minutes. At 60, as in this example, an annotator who steps away for an hour has to log in again when they return.
Annotation Output
output_annotation_dir: /var/potato/annotations/
export_annotation_format: [csv, jsonl]
auto_export_interval: 300Potato writes one directory per annotator under output_annotation_dir, each holding that person's user_state.json. export_annotation_format names the formats the periodic export writes, and an empty value means no auto-export at all. auto_export_interval is the gap between exports in seconds, and defaults to 60.
Database Backend
Annotations live on disk by default. For deployments that need a database, database.type accepts mysql or file:
database:
type: mysql
host: localhost
port: 3306
database: potato_db
username: potato
password: ${DB_PASSWORD}The keys are database and username, not name and user. A connection string can come from POTATO_DB_CONNECTION instead, which keeps the credential out of the config file.
Install the driver with pip install 'potato-annotation[mysql]'. Before 2.10.2 the MySQL backend never turned on, and a config that named it silently stored annotations on disk. From 2.10.2 a missing driver or an unreachable server stops startup, and export, backups and potato deploy pull read the database.
Health Checks and Logging
Potato serves GET /health on every deployment, and the route needs no authentication, so it works as the readiness probe for a load balancer or an orchestrator:
curl -sf https://annotation.example.com/healthLogging verbosity is set in the config or on the command line rather than in a logging block:
verbose: true
debug_log: serverdebug_log takes all, ui, server or none. The POTATO_LOGGERS environment variable narrows output to named loggers when you are chasing one subsystem.
Scaling
Potato runs as a single process holding the whole item set in memory, so scale the machine rather than the number of servers. Running several replicas behind a load balancer does not divide the work. Each replica keeps its own copy of the assignment queue and annotator state, which reproduces exactly the data loss described under gunicorn above.
Memory is the resource that grows with the dataset. Around a million items, payloads can move to a SQLite cache:
item_store:
backend: paged # default: memory
cache_size: 2048Measure before turning this on. Paging saves roughly 28% on vision items and 51% on text rather than an order of magnitude, and it makes loading the corpus about 2.5 times slower. The file is a cache, rebuilt from the data files on every boot, so deleting it is safe.
For large projects, the effective levers are bounding what any one annotator is assigned with per_annotator_quota, splitting the corpus across cohorts with batch_assignment, and adding data files over time with data_directory rather than loading everything at startup.
Backup Strategy
A backup has to cover more than the annotation output directory. The task directory also holds up to three SQLite databases, and none of them can be rebuilt from the data files: project.sqlite (memos, the codebook, the review workflow), datasets.sqlite, and engine.sqlite when the labeling engine is in use. Potato stores accounts created through the sign-up form in user_config.json. The databases run in WAL mode while the server writes to them, so a tar or cp of a database file can produce a copy that is corrupt or missing recent work, and neither command warns you.
The built-in backup
From version 2.10, Potato's backup block copies the annotation output and .backup snapshots of the databases off the server, and restores them when the server starts with an empty disk. The restore brings back the account list with the annotations, and it never overwrites annotations already on the disk. The block below sends the copy to an S3 bucket every five minutes, the default interval, so a lost disk costs annotators at most five minutes of work:
backup:
schedule_minutes: 5
restore_on_boot: true
sinks:
- type: s3
bucket: lab-potato-backups
prefix: pilot-study
endpoint_url: https://s3.example.edu # leave out for AWS S3endpoint_url points the sink at any service that speaks the S3 API, such as a university object store, MinIO, Cloudflare R2 or Backblaze B2. The credentials come from POTATO_S3_ACCESS_KEY_ID and POTATO_S3_SECRET_ACCESS_KEY in the server's environment, or from boto3's usual chain. A key scoped to that one bucket limits what the server can reach. A type: huggingface sink with a repo_id sends the copy to a private Hugging Face dataset instead, using HF_TOKEN. Both sinks need the hosting extra, pip install 'potato-annotation[hosting]'.
A copy that stays on your own storage
When a data-use agreement keeps the data on the server, a nightly job can copy it to local or mounted storage instead. The script below archives the output directory, snapshots each database with SQLite's .backup command rather than copying the file, and keeps seven days of copies. It needs the sqlite3 command-line tool:
#!/bin/bash
# backup.sh
set -e
TASK_DIR=/var/potato
DEST=/var/backups/potato/$(date +%Y%m%d_%H%M%S)
mkdir -p "$DEST"
tar -czf "$DEST/annotations.tar.gz" -C "$TASK_DIR" annotations
for db in project.sqlite datasets.sqlite engine.sqlite; do
if [ -f "$TASK_DIR/$db" ]; then
sqlite3 "$TASK_DIR/$db" ".backup '$DEST/$db'"
fi
done
cp "$TASK_DIR/config.yaml" "$DEST/"
if [ -f "$TASK_DIR/user_config.json" ]; then cp "$TASK_DIR/user_config.json" "$DEST/"; fi
find /var/backups/potato -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} +Run it from cron at 2 a.m. every night:
0 2 * * * /var/potato/backup.shA nightly copy can lose up to a day of work, so run it more often during a study with many annotators. Before the study starts, restore one copy to a test server and check that the last annotation is there.
Security Checklist
- Terminate TLS at the proxy with a valid certificate
- Set
secret_key, orPOTATO_SECRET_KEY, so sessions survive a restart - Set
admin_api_keyrather than shipping the generated file - Restrict registration with
user_config.users - Keep
debugoff, since debug mode lets the reverse proxy's requests reach the admin pages without a key - Run gunicorn with a single worker
- Keep credentials in environment variables, not in the config
- Enable
POTATO_PROXY_FIXonly behind a trusted proxy - Back up the output directory, the account file and the databases, and test a restore
- Review who holds an admin role
Environment Variables
# .env file
POTATO_SECRET_KEY=your-super-secret-key
POTATO_ADMIN_API_KEY=your-admin-key
POTATO_DB_CONNECTION=mysql://potato:password@localhost:3306/potato_dbGenerate a signing key with:
python3 -c 'import secrets; print(secrets.token_hex(32))'Load in Docker Compose:
services:
potato:
env_file:
- .envTroubleshooting
Check Logs
# Gunicorn logs
journalctl -u potato -f
# Docker logs
docker-compose logs -f potatoCommon Issues
502 Bad Gateway: Check that Potato is running and bound to the port the proxy forwards to.
Everyone logged out after a deploy: secret_key is unset, so a new signing key was generated at startup.
Missing annotations: Check the worker count. More than one gunicorn worker discards annotations without reporting it.
Assets 404 under a path prefix: Set POTATO_PROXY_FIX or POTATO_URL_PREFIX, and confirm the proxy strips the prefix before forwarding.
Slow with a large corpus: Add memory before adding servers, and consider item_store.backend: paged past about a million items.
Next Steps
- User Management - Access lists, roles, and quotas
- Crowdsourcing - Prolific, SONA and other panels
- Data Formats - Storage and export
For implementation details, see the reverse proxy guide and the scaling guide.