Deploying Potato on a Server
Run Potato in production behind nginx with TLS, in Docker or on a VM, and why a Potato server runs as a single process rather than several workers.
Run one Potato process per project, put nginx and a TLS certificate in front of it, and raise threads rather than worker processes when you need more concurrency. A second worker process silently loses annotations.
Potato holds its item pool, its assignment queue, and every annotator's state in memory inside the server process. That single fact decides most of a deployment. The in-process state is why a Potato server scales by threads rather than by workers, why the annotation output directory is the thing you back up, and why load balancing across several Potato processes corrupts a study rather than speeding it up. This guide covers the three ways to run a server, the reverse proxy and TLS in front of it, and the config keys that matter once real annotators are on it.
Why a Potato server runs as one process
A second gunicorn worker gets its own copy of the item pool, the assignment queue, and the user state. 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 worker's annotations. Nothing in the logs reports this. Annotations go missing, and the first sign is usually an annotator saying they labeled an item that now looks untouched.
Potato's published Docker image enforces this. It runs a single gunicorn worker and refuses to start with more, and POTATO_ALLOW_MULTIWORKER=1 overrides the refusal for anything that is not a real study. Concurrency comes from threads instead, which share one copy of the state. The image defaults to eight of them through GUNICORN_THREADS, which serves the dozens of simultaneous annotators a typical study has.
That constraint bounds the rest of this guide. Run one process per project, give it enough memory for the dataset, and use the reverse proxy for TLS and static files rather than for spreading load.
Three ways to run a server
| Method | What it gives you | When to use it |
|---|---|---|
potato deploy | Provisions the host, TLS, and the image from your config | A study you want running today |
| Docker image | The same bytes every time, no dependency build | A machine you already administer |
| VM plus systemd | Full control of the host and the Python environment | An institutional server with its own rules |
potato deploy for the fast path
potato deploy provisions a host, installs the image, and configures TLS from the config file you already have:
potato deploy up myproject/config.yaml --provider digitaloceanSeveral minutes later you have an HTTPS URL to send to annotators, and potato deploy pull brings the annotations back down. Since 2.10.0 the targets are local, potato share and thirteen cloud hosts, among them AWS, Jetstream2, Hetzner, Fly, and Railway, and they differ in whether the data survives a restart. The seven VM targets have a persistent disk, log streaming, and SSH together, and a host that wipes its disk needs --backup, which copies the annotations to a HuggingFace dataset or an S3 bucket. Running Potato Locally or in the Cloud compares them. Start with local, which runs the same bundle and the same image against no account and no bill, and tells you what would deploy before it costs anything.
The published Docker image
Potato publishes a prebuilt image to GitHub Container Registry. It contains the server and its dependencies, and your project mounts at /app at run time, 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 image exists because Potato's core dependencies include numpy, pandas, scipy, and scikit-learn. Installing those on a small VM is slow at best. On a 1 GB machine the build is killed for running out of memory, and on 2 GB it takes five to ten minutes on every redeploy. Pulling the image takes under a minute.
Two tags matter. latest carries the core dependencies at about 840 MB, and latest-all adds the AI SDKs, document formats, export, and OAuth logins at about 1.5 GB. Both are built for linux/amd64 and linux/arm64. Pin a version rather than latest for a study that will run for months, since latest moves under you.
Set POTATO_SECRET_KEY on anything that outlives a single run. Without it the Flask session signing key is random per process, so every restart logs every annotator out. POTATO_CONFIG picks a config path inside the container, PORT sets the listen port (7860 by default), and GUNICORN_TIMEOUT sets the worker timeout in seconds.
The image runs as uid 1000, and a bind mount keeps the host's ownership, so on Linux the container often cannot write to a directory you created. The server checks this at startup and refuses to run rather than dying partway through boot. Either hand the directory to the container user with sudo chown -R 1000:1000 myproject, or run as yourself with docker run --user "$(id -u):$(id -g)", which also leaves the annotations owned by you. Docker Desktop on macOS and Windows ignores ownership on bind mounts, so a setup that works on a Mac can still fail on a Linux server.
Installing on a VM
On a machine you administer directly, install Potato into a virtual environment and let systemd own the process.
sudo apt update
sudo apt install python3 python3-venv python3-pip nginx certbot python3-certbot-nginx
sudo mkdir -p /opt/potato/projects
cd /opt/potato
python3 -m venv venv
source venv/bin/activate
pip install potato-annotation gunicorn
potato --versionRun the app under gunicorn with one worker and several threads. The application factory lives at potato.flask_server:create_app and takes the config path:
gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
--chdir /opt/potato/projects/my_project \
"potato.flask_server:create_app('config.yaml')"Create /etc/systemd/system/potato.service so the server comes back after a reboot or a crash:
[Unit]
Description=Potato Annotation Server
After=network.target
[Service]
Type=simple
User=potato
Group=potato
WorkingDirectory=/opt/potato/projects/my_project
Environment="PATH=/opt/potato/venv/bin"
EnvironmentFile=/opt/potato/.env
ExecStart=/opt/potato/venv/bin/gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
"potato.flask_server:create_app('config.yaml')"
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetEnable and start it:
sudo systemctl enable potato
sudo systemctl start potato
sudo systemctl status potatoThe EnvironmentFile holds the secrets that should not be in the config:
# /opt/potato/.env
POTATO_SECRET_KEY=a-long-random-value
OPENAI_API_KEY=sk-...Generate the secret key once with openssl rand -hex 32 and keep it for the life of the study. Changing it invalidates every session and logs all annotators out.
nginx and TLS
Potato binds to localhost and nginx terminates TLS in front of it. The upload limit matters for image and audio projects, where a default 1 MB body limit rejects media the annotator never sees fail.
server {
listen 443 ssl;
http2 on;
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}Enable the site and get a certificate:
sudo ln -s /etc/nginx/sites-available/potato /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d annotation.example.com
sudo systemctl enable certbot.timerClose everything else at the firewall, since the Potato port must not be reachable directly once a proxy is in front of it:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enableServing several projects under one hostname
Institutions often expose several Potato servers through one public HTTPS endpoint by mapping paths to local ports. The annotation UI requests assets and saves through root-relative URLs, so a server mounted at /app1 needs to know its prefix or the CSS 404s and autosaves fail with "annotations not saved". Potato reads the prefix from the WSGI SCRIPT_NAME, and there are two ways to set it.
Use POTATO_PROXY_FIX=1 when you control the proxy and it can send X-Forwarded-Prefix. Use POTATO_URL_PREFIX=/app1 when the proxy configuration is not yours to change. The proxy must still strip the prefix before forwarding, so Flask keeps receiving unprefixed paths:
location /app1/ {
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app1;
}ProxyFix trusts forwarded headers, so enable POTATO_PROXY_FIX only when the app is reachable exclusively through the trusted proxy. If the internal port is also directly reachable, a client can spoof the prefix and poison the URLs Potato generates.
The config a production project needs
Four settings separate a production config from a local one: a stable signing key, a password requirement, an explicit session lifetime, and an output directory you have a backup of.
annotation_task_name: "Production Annotation Task"
task_description: "Classify each passage."
host: 127.0.0.1
port: 8000
# Sessions survive a restart only when the signing key is stable.
# Set it here, or leave it out and supply POTATO_SECRET_KEY in the environment.
require_password: true
persist_sessions: true
session_timeout_minutes: 480
data_files:
- data/items.jsonl
item_properties:
id_key: id
text_key: text
output_annotation_dir: annotation_output/production/
export_annotation_format: jsonl
user_config:
allow_all_users: false
users:
- annotator1
- annotator2
annotation_schemes:
- annotation_type: radio
name: sentiment
description: "What is the sentiment expressed in this text?"
labels:
- name: positive
- name: neutral
- name: negative
label_requirement:
required: truesession_timeout_minutes bounds idle time rather than shift length, because the clock restarts on every request. The default of 480 suits annotators working a full day; lower it when the machine is shared. Set allow_all_users: false and list your annotators whenever the server is reachable from the public internet, so a stranger who finds the URL cannot register and start writing into your dataset.
Health checks and logs
/health is an unauthenticated liveness probe for containers and load balancers, and it deliberately reports almost nothing, because anything it says is public:
curl http://localhost:8000/health
# {"status": "ok"} once the state managers exist
# {"status": "starting"} 503 while the server is still coming upA container healthcheck and a deploy provider's post-create poll both key on that transition. Task names, item counts, and annotator counts come from /admin/health instead, which needs the admin API key.
Logging goes through the process, so systemd captures it:
sudo journalctl -u potato -fRaise the level from the config with verbose: true, or turn on the halves of the debug logging you need with debug_log, which takes all, ui, server, or none. Leave both off during a real study, since debug logging is verbose enough to fill a small disk over a long run.
What to back up
Three things in the project directory are not regenerable. The annotation output under output_annotation_dir is the study. project.sqlite holds memos, the codebook, cases, the search index, and the review workflow. datasets.sqlite holds the dataset records. Everything else can be rebuilt from your data files and config.
#!/bin/bash
# /opt/potato/backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/opt/potato/backups
tar -czf $BACKUP_DIR/annotations_$DATE.tar.gz \
/opt/potato/projects/*/annotation_output/ \
/opt/potato/projects/*/project.sqlite \
/opt/potato/projects/*/datasets.sqlite \
/opt/potato/projects/*/config.yaml
find $BACKUP_DIR -mtime +7 -deleteRun it nightly from crontab, and copy the archive off the machine:
0 2 * * * /opt/potato/backup.shSizing the machine
Memory is the resource that scales with the dataset, because the item pool is held in the process. Everything else has been made lazy, so the embedding and sentence-transformers stack is not imported at boot unless a feature needs it. On the reference dataset in the v2.6.0 release notes, resident memory fell from about 750 MB to about 365 MB and a 50,000-item boot from about 10 seconds to about 5.7 seconds.
Per-item cost is small and measurable. A vision item holding an image URL and two fields takes about 932 bytes, and a text item of roughly 540 unique characters about 1430 bytes. A million text items is therefore about 1.43 GB resident, which is the number to budget against.
For corpora where that matters, item payloads can live in a SQLite file with a small in-memory cache:
item_store:
backend: paged
cache_size: 2048Add this block to the config above. Read the measurement before turning it on. Paging saves 28% on vision items and 51% on text rather than an order of magnitude, and in exchange loading the corpus takes about 2.5 times as long. Below a million items it buys a slower server and no benefit.
Prefer a machine with more RAM over splitting a project across servers, because splitting is the multi-process failure described at the top of this guide. When one machine genuinely cannot hold the corpus, shard the work instead. Batch assignment gives each annotator a slice, per_annotator_quota caps how much any one person is assigned, and a data_directory with directory watching lets you add files while the server runs.
Troubleshooting
The port is already in use. Find the process and stop it rather than starting a second Potato on another port against the same project directory:
sudo lsof -i :8000Permission denied on the project directory. The service user needs to own it, and in Docker that user is uid 1000:
sudo chown -R potato:potato /opt/potatoAnnotators are logged out after every restart. POTATO_SECRET_KEY or the secret_key config value is missing, so the signing key is regenerated on each boot.
Assets 404 and autosaves fail behind a proxy. The server does not know its URL prefix. Set POTATO_PROXY_FIX=1 or POTATO_URL_PREFIX, and check that the proxy strips the prefix before forwarding.
Further reading
- Production Setup and Reverse Proxy on this site
- Running Potato in Docker for the image reference
- Scaling and Large Datasets for the measurements behind the sizing advice
- Running annotation studies on Prolific once the server is reachable