# Run Potato in Docker

Source: https://www.potatoannotator.com/docs/deployment/docker

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](/docs/deployment/quick-deploy) covers that route, and [Production Setup](/docs/deployment/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:

| Tag | Contents |
|---|---|
| `latest`, `2.14.0` | Potato's core dependencies, the backup clients (`huggingface_hub` and `boto3`), and Authlib for SSO logins |
| `latest-all`, `2.14.0-all` | Everything 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>-all` | One 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](#the-admin-key-in-a-container) 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](/docs/deployment/production-setup#the-built-in-backup) 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:

| Variable | Default | Purpose |
|---|---|---|
| `POTATO_CONFIG` | `config.yaml` | Path of the config inside the container, relative to `/app` |
| `PORT` | `7860` | Port the server listens on inside the container |
| `GUNICORN_WORKERS` | `1` | Worker processes; any other value stops the container at startup |
| `GUNICORN_THREADS` | `8` | Threads in the one worker, and the number of requests served at once |
| `GUNICORN_TIMEOUT` | `120` | Seconds a worker may stay silent before gunicorn restarts the worker |
| `POTATO_SECRET_KEY` | random at each start | Signs session cookies; `secret_key` in the config takes precedence |
| `POTATO_ADMIN_API_KEY` | generated file | The admin API key; `admin_api_key` in the config takes precedence |
| `POTATO_GENERATED_TEMPLATES_DIR` | chosen at startup | Where Potato writes the task templates it builds at startup |
| `POTATO_BUNDLE_URL` | unset | URL of a `.tar.gz` of the project to download into `/app` at startup |
| `POTATO_BUNDLE_SHA256` | unset | Checksum the downloaded project must match |
| `POTATO_BUNDLE_TOKEN` | unset | Sent as `Authorization: Bearer` with the download |
| `POTATO_RUN_AS_ROOT` | unset | `1` keeps the server running as root when the container starts as root |
| `POTATO_ALLOW_MULTIWORKER` | unset | `1` lets `GUNICORN_WORKERS` exceed 1, which loses annotations in a real study |
| `POTATO_ALLOW_READONLY_APP` | unset | `1` 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](/docs/features/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](/docs/deployment/production-setup#docker-compose) 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](#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

- [Running Potato Locally or in the Cloud](/docs/deployment/quick-deploy) - `potato deploy` targets that run this image for you
- [Production Setup](/docs/deployment/production-setup) - gunicorn, nginx, TLS, sessions, and backups on your own server
- [Reverse Proxy](/docs/deployment/reverse-proxy) - serving Potato under a URL path prefix
- [Admin Dashboard](/docs/features/admin-dashboard) - what the admin key opens

For implementation details, see the [source documentation](https://github.com/davidjurgens/potato/blob/master/docs/deployment/docker.md).
