Skip to content

Run Potato in Docker

Serve a Potato project from the published Docker image, keep its annotations on your disk, and set the admin key and environment variables the container reads.

This page covers running a Potato annotation project in a container, from the first docker run to moving a live study onto a new release. The page is written for researchers and lab administrators who serve a study from their own machine or server and want Potato without a Python installation on the host. Potato publishes the image to GitHub Container Registry as ghcr.io/davidjurgens/potato, and the image holds the server and its dependencies and nothing else. Because your project folder mounts at /app when the container starts, one image serves every task.

potato deploy runs the same image on the cloud hosts the command sets up, and picks the tag that matches your installed Potato version. Running Potato Locally or in the Cloud covers that route, and Production Setup covers gunicorn, a reverse proxy, and TLS on a server you manage.

Getting the image

Installing Potato's numpy, pandas, scipy, and scikit-learn dependencies with pip runs out of memory on a 1 GB machine and takes five to ten minutes on a 2 GB one. The image arrives with those dependencies already installed:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0

The image comes in two variants, each with a moving tag, a tag per release, and a tag per commit:

TagContents
latest, 2.14.0Potato's core dependencies, the backup clients (huggingface_hub and boto3), and Authlib for SSO logins
latest-all, 2.14.0-allEverything above, plus the all extra with the AI SDKs behind ai_support, PDF and DOCX ingestion, parquet export, and the MySQL driver
sha-<short>, sha-<short>-allOne commit of the master branch

Both variants are built for linux/amd64 and linux/arm64, and the arm64 build runs natively on Apple Silicon. Neither variant includes the vision extra and its multi-gigabyte torch wheels. Browser-side segmentation works without the extra, because ONNX Runtime Web ships inside the Potato package.

Pin a version tag for any study that will run for more than a day. The latest tag is rebuilt from the master branch on every change, and two docker pull commands a week apart can fetch different code. A version tag such as 2.14.0 is pushed once at release time and is not rebuilt afterwards.

Building the image yourself

Build from a clone of the repository when you need an extra that neither published variant includes. The POTATO_EXTRAS build argument takes any extra named in Potato's setup.py, or several separated by commas:

bash
git clone https://github.com/davidjurgens/potato
cd potato
docker build -t potato .
docker build --build-arg POTATO_EXTRAS=all -t potato:all .
docker build --build-arg POTATO_EXTRAS=all,saml -t potato:all-saml .

The saml extra for SAML sign-in is not part of all. The Dockerfile installs the libxmlsec1 library that the extra compiles against only when POTATO_EXTRAS names saml. Run a local build by its tag, such as potato:all, wherever this page names ghcr.io/davidjurgens/potato:2.14.0.

Running a project

A project folder holds config.yaml and every file the config names. The examples on this page use a three-item sentiment task with the following layout:

text
myproject/
├── config.yaml
└── data/
    └── items.jsonl

The config keeps every path relative to the project folder:

yaml
annotation_task_name: "Sentiment pilot"
task_dir: .
data_files:
  - data/items.jsonl
item_properties:
  id_key: id
  text_key: text
output_annotation_dir: annotation_output/
user_config:
  allow_all_users: true
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What is the sentiment of this sentence?"
    labels: [positive, neutral, negative]

Each line of data/items.jsonl is one item:

json
{"id": "1", "text": "The battery lasts all day."}
{"id": "2", "text": "The screen cracked within a week."}
{"id": "3", "text": "It arrived on Tuesday."}

Start the server from the folder that contains myproject:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0

The task is then at http://localhost:8000. The -p 8000:7860 argument maps port 8000 on your machine to port 7860, where the server listens inside the container. The -v argument mounts the project folder at /app. The host side has to be a path such as $PWD/myproject, because Docker reads a bare name such as myproject as the name of a new, empty volume. docker logs -f potato follows the server log, and docker stop potato stops the server.

A project with several configs picks one with POTATO_CONFIG, a path relative to /app:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_CONFIG=configs/pilot.yaml \
  ghcr.io/davidjurgens/potato:2.14.0

Running a study that lasts weeks

A study that runs for weeks needs a session signing key that stays the same across restarts and updates. Without POTATO_SECRET_KEY, each start of the server picks a new random key and signs every annotator out. Create an env file once, outside the project folder, and pass the same file to every docker run:

bash
printf 'POTATO_SECRET_KEY=%s\nPOTATO_ADMIN_API_KEY=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > potato.env
chmod 600 potato.env
 
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

The admin key section explains the file's second line. --restart unless-stopped brings the container back after a crash or a reboot of the host.

Other commands in the image

Anything after the image name replaces the server. Check a config with the validator the image ships, or open a shell inside the container:

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

File ownership on Linux

The server in the image runs as uid 1000, and a bind mount keeps the ownership the folder has on the host. On Linux, a folder you created belongs to your own uid, and a folder created by root belongs to root. Unless the folder's owner is uid 1000, the server cannot write annotations into the folder. The container checks the folder before the server starts and stops with ERROR: /app is not writable by uid 1000 instead of failing partway through startup. Either command below fixes the problem:

bash
sudo chown -R 1000:1000 myproject                  # hand the folder to the container user
docker run --user "$(id -u):$(id -g)" ...          # or run the container as yourself

With --user, the annotations and the admin key file Potato writes also belong to your own account. Docker Desktop on macOS and Windows ignores ownership on bind mounts, and a project that runs on a Mac can still fail on a Linux server. POTATO_ALLOW_READONLY_APP=1 skips the check. Potato still writes annotations, potato.log and its SQLite databases inside the project, so set the variable only when those paths are writable through separate mounts.

Some hosts start every container as root, whatever the image specifies. When the container starts as root, the container gives /app to the image's potato user and starts the server as that user. Set POTATO_RUN_AS_ROOT=1 to keep root instead.

Keeping the annotations

Potato writes everything a study collects into the project folder. Annotations go under output_annotation_dir, in one directory per annotator holding that annotator's user_state.json. Two SQLite databases sit beside config.yaml. project.sqlite holds memos, the codebook, cases, the search index, and the review workflow, and datasets.sqlite holds evaluation datasets and their versions. Neither database can be rebuilt from the data files. With a bind mount, all of these files are on your disk and stay there when the container is removed.

output_annotation_dir has to resolve inside the project folder. A path such as /data/annotations stops the server at startup with the message output_annotation_dir: Path '/data/annotations' resolves to '/data/annotations' which is outside the project directory '/app'. Keep a relative path such as annotation_output/. The image's /data volume lies outside the project too, and Potato writes nothing there.

Copy a database out of a running container with SQLite's backup command, never as a plain file. project.sqlite runs in WAL mode while the server writes to the database, and a plain copy can be corrupt or miss recent work without any warning. The image includes the sqlite3 tool for the snapshot:

bash
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project.sqlite

Most managed container hosts wipe the container's filesystem when the container restarts. On such a host, keep the project on persistent storage mounted at /app, or add a backup block to the config. The block copies the annotations and database snapshots to an S3 bucket or a Hugging Face dataset on a schedule, and restores them when the server starts with an empty disk. Every variant of the image includes the clients the block needs.

Environment variables

The container reads these variables at startup:

VariableDefaultPurpose
POTATO_CONFIGconfig.yamlPath of the config inside the container, relative to /app
PORT7860Port the server listens on inside the container
GUNICORN_WORKERS1Worker processes; any other value stops the container at startup
GUNICORN_THREADS8Threads in the one worker, and the number of requests served at once
GUNICORN_TIMEOUT120Seconds a worker may stay silent before gunicorn restarts the worker
POTATO_SECRET_KEYrandom at each startSigns session cookies; secret_key in the config takes precedence
POTATO_ADMIN_API_KEYgenerated fileThe admin API key; admin_api_key in the config takes precedence
POTATO_GENERATED_TEMPLATES_DIRchosen at startupWhere Potato writes the task templates it builds at startup
POTATO_BUNDLE_URLunsetURL of a .tar.gz of the project to download into /app at startup
POTATO_BUNDLE_SHA256unsetChecksum the downloaded project must match
POTATO_BUNDLE_TOKENunsetSent as Authorization: Bearer with the download
POTATO_RUN_AS_ROOTunset1 keeps the server running as root when the container starts as root
POTATO_ALLOW_MULTIWORKERunset1 lets GUNICORN_WORKERS exceed 1, which loses annotations in a real study
POTATO_ALLOW_READONLY_APPunset1 skips the startup check that /app is writable

A container that changes PORT needs the same port on the right of -p, as in -p 8000:9000 for PORT=9000.

Worker and thread counts

The container refuses to start with more than one gunicorn worker. Potato keeps its item pool, its assignment queue, and every annotator's state in the memory of one process. A second worker would hold its own copy of all three and assign items the first worker had already assigned. Each save rewrites user_state.json in full, and whichever worker saved last would discard the other worker's annotations with no error. Raise GUNICORN_THREADS for more concurrent requests instead, because threads share one copy of the state.

Generated templates

Potato builds a template for each task's annotation page at startup. Outside a container, the template goes into the installed package whenever the package directory is writable. The image installs Potato as root and runs the server as uid 1000, and the server therefore cannot write to the package directory. Potato writes the templates instead to a directory under /tmp that only the server's user can read or write, and the startup log names the directory. Potato rebuilds the templates from the config at every start, and losing the directory costs nothing. Set POTATO_GENERATED_TEMPLATES_DIR to put the templates somewhere else, such as a tmpfs mount.

Downloading the project at startup

Hosts that only run an image, such as Render, Fly, Railway, and ECS, have no folder to mount. With POTATO_BUNDLE_URL set, the container downloads a .tar.gz of the project, checks the download against POTATO_BUNDLE_SHA256, and unpacks the project into /app before the server starts. The container writes the checksum to /app/.potato-bundle-sha, and a restart on a persistent disk skips a download whose checksum matches. In a bundle built by potato deploy, a new version of the project never replaces the annotation output or the SQLite databases already on the disk. potato deploy up sets these variables for you.

The admin key in a container

The admin dashboard at /admin and the admin API take one shared key, sent as the X-API-Key header or typed into the admin login page. Potato reads the key from the top-level admin_api_key config key first, then from the POTATO_ADMIN_API_KEY environment variable. In a container, the environment variable keeps the key out of config.yaml and out of any repository that holds the project. The potato.env file in the section on long studies sets the variable, and the admin key is then whatever that file holds:

bash
grep POTATO_ADMIN_API_KEY potato.env

When neither the config nor the environment sets a key, Potato generates a key the first time someone opens /admin or calls an admin endpoint. Potato writes the generated key to admin_api_key.txt in the task directory. In the container, the task directory is /app, the mounted project folder. The file is readable only by the server's user, and Potato reads the same file after a restart. The server log names the file and never prints the key. Read the key through the container:

bash
docker exec potato cat /app/admin_api_key.txt

On a Linux host where you handed the project folder to uid 1000, your own account cannot read admin_api_key.txt without sudo. A container started with --user "$(id -u):$(id -g)" writes the file as your own account instead. See the Admin Dashboard page for what the key opens.

Debug mode in a container

Keep debug: false in any container that serves a study. With debug: true in the config, Potato changes how two features behave:

  • Admin pages without a key. The admin dashboard and the admin API skip the key for a request from a loopback address that carries no X-Forwarded-For, X-Real-IP, or Forwarded header. On Docker's default bridge network, a request to the published port reaches the container from the bridge's address and still needs the key. With --network host, a request from the host machine is a loopback request and needs no key. A reverse proxy in the same network namespace that adds no forwarding header makes every request the proxy passes on look local.
  • Shared debug account. With debug_phase also set, every visitor is signed in as one shared account, debug_user, and skipped to that phase. The account is not an admin, but every visitor shares the account's answers.

The server logs a warning at startup whenever debug is on. A command run inside the container with docker exec is a loopback request. In debug mode, docker exec potato curl -s http://127.0.0.1:7860/admin/health therefore returns the admin health report without a key.

Health checks

GET /health needs no authentication and reports only whether the server is up. The route returns {"status": "ok"} once the server is ready and status 503 while the server is still loading data, and a load balancer or an orchestrator can poll the route as its readiness check. The image's own HEALTHCHECK polls /health every 30 seconds and allows a 90-second start period for large tasks, and docker ps shows the result. GET /admin/health reports the task name and the state of the item and annotator managers, and the route needs the admin key in the X-API-Key header.

Docker Compose

The Potato repository ships no Compose file. Production Setup shows a Compose file that runs this image behind nginx, with the project folder mounted at /app and the session and admin keys read from the environment.

Updating to a new version

Updating Potato replaces the container and leaves the project folder, with every annotation and database, in place. Before switching, pull the new release, check the config with the new release's validator, and take a snapshot of the project database. Replace 2.14.0 with the release you are moving to:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0
docker run --rm -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 potato validate config.yaml --strict
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project-before-update.sqlite

Then replace the running container with a container on the new tag, using the same mount and the same env file:

bash
docker stop potato
docker rm potato
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

Because potato.env keeps POTATO_SECRET_KEY the same, annotators stay signed in across the switch, and the admin key does not change.

Troubleshooting

ERROR: config file not found: config.yaml. The project did not mount where the server looked. The error lists the contents of /app for comparison with your -v argument. An empty listing usually means a bare name such as -v myproject:/app. Docker reads a bare name as a new, empty volume rather than your folder.

ERROR: /app is not writable by uid 1000. The server cannot write to the project folder. Hand the folder to uid 1000 or run the container with --user. File ownership on Linux gives both commands.

output_annotation_dir ... is outside the project directory '/app'. The config points the annotation output outside the mounted folder. Use a relative path such as annotation_output/.

Everyone is signed out after a restart. No signing key is set, and the server picked a new random key at startup. Set POTATO_SECRET_KEY in an env file and pass the file to every docker run.

The container exits with an error naming GUNICORN_WORKERS. The worker count is above 1. Set GUNICORN_WORKERS=1, or remove the variable, and raise GUNICORN_THREADS instead.

The admin login page rejects every key. The config's admin_api_key takes precedence over POTATO_ADMIN_API_KEY, and both take precedence over admin_api_key.txt. Check the config first, then docker exec potato printenv POTATO_ADMIN_API_KEY.

Further reading

For implementation details, see the source documentation.