Skip to content

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

yaml
server:
  host: 0.0.0.0
  port: 8000
  debug: false

The 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:

bash
gunicorn "potato.flask_server:create_app('config.yaml')" \
  --workers 1 \
  --threads 8 \
  --bind 0.0.0.0:8000 \
  --timeout 120

The 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

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

Reverse Proxy Setup

Nginx Configuration

nginx
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

apache
<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:

bash
export POTATO_PROXY_FIX=1
potato start config.yaml -p 8000
nginx
location /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:

bash
export POTATO_URL_PREFIX=/app1
potato start config.yaml -p 8000

Test 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:

nginx
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:

bash
docker run -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_SECRET_KEY="$(openssl rand -hex 32)" \
  ghcr.io/davidjurgens/potato:latest

The 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:

bash
docker run -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:latest potato validate config.yaml --strict

Docker Compose

yaml
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-stopped

Inside 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:

yaml
user_config:
  allow_all_users: false
  users:
    - annotator1@example.com
    - annotator2@example.com

Both 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:

yaml
admin_api_key: "your-shared-key"
 
rbac:
  enabled: true
  user_role_assignments:
    lead@example.com: admin

With 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

yaml
secret_key: "a-long-random-string"
session_timeout_minutes: 60
persist_sessions: true
session_lifetime_days: 7
max_session_seconds: 7200

secret_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

yaml
output_annotation_dir: /var/potato/annotations/
export_annotation_format: [csv, jsonl]
auto_export_interval: 300

Potato 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:

yaml
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:

bash
curl -sf https://annotation.example.com/health

Logging verbosity is set in the config or on the command line rather than in a logging block:

yaml
verbose: true
debug_log: server

debug_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:

yaml
item_store:
  backend: paged      # default: memory
  cache_size: 2048

Measure 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:

yaml
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 S3

endpoint_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:

bash
#!/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:

bash
0 2 * * * /var/potato/backup.sh

A 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, or POTATO_SECRET_KEY, so sessions survive a restart
  • Set admin_api_key rather than shipping the generated file
  • Restrict registration with user_config.users
  • Keep debug off, 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_FIX only 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

bash
# .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_db

Generate a signing key with:

bash
python3 -c 'import secrets; print(secrets.token_hex(32))'

Load in Docker Compose:

yaml
services:
  potato:
    env_file:
      - .env

Troubleshooting

Check Logs

bash
# Gunicorn logs
journalctl -u potato -f
 
# Docker logs
docker-compose logs -f potato

Common 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

For implementation details, see the reverse proxy guide and the scaling guide.