# Deploying a Task

Putting a task somewhere annotators can reach it. It ships with the `potato-skill` skill:

```
/plugin marketplace add davidjurgens/potato-skill
/plugin install potato-skill@potato
```

---

`potato start` serves on one machine. Three ways past that, in increasing order
of commitment:

| Want | Use |
|---|---|
| A colleague on the same network | `potato start --host 0.0.0.0 -p 8000`, give them the LAN address |
| Someone to look at it for an hour | `potato share config.yaml` — temporary public HTTPS |
| Annotators working over days or weeks | `potato deploy up config.yaml --provider …` |
| Other labs running their own copy | `potato deploy button config.yaml --target …` |

`potato deploy` and `potato share` are listed under **other commands** in `potato --help`:
`potato deploy --help`, `potato share --help`.

## A temporary public URL

```bash
potato share config.yaml -p 8000 --backend cloudflared   # or tailscale, ngrok
```

Serves the task and opens a tunnel to it. The URL dies when the command does, so
it is for a demo or a pilot with three people, not for a study. It runs a
preflight first and asks you to confirm the exposure; `--yes` skips the
confirmation and `--skip-preflight` skips the assessment, which you should not do
on anything with real annotators behind it.

Same rule as `potato start`: it does not return. Background it.

`potato deploy up --provider tunnel` exits 2 with a `REFUSED:` line telling you
to run `potato share` instead. The tunnel target runs only under `share`.

## Hosted deployment

```bash
potato deploy providers                  # what targets exist, which have credentials
potato deploy check   config.yaml --provider aws        # preflight, changes nothing
potato deploy build   config.yaml --out ./bundle        # assemble, stop
potato deploy up      config.yaml --provider aws --dry-run   # the plan and the cost
potato deploy up      config.yaml --provider aws        # provision
potato deploy status  config.yaml
potato deploy logs    config.yaml -f
potato deploy pull    config.yaml --dest ./collected    # get the annotations back
potato deploy destroy config.yaml
potato deploy list    config.yaml                       # deployments recorded for this config
```

The lifecycle is the same on every target, with two gaps: `potato deploy logs`
does not work on `fly` (use `fly logs -a <app>`), and `aws-ecs` has logs but no
SSH, because there is no server to log in to.

The host runs the image for the Potato version you have installed: on 2.11.1,
`up --dry-run` plans `docker.pull ghcr.io/davidjurgens/potato:2.11.1`. Up to
2.10.3 the default was `:latest`, so two `up`s of one study could run different
releases; on those versions pass `--image ghcr.io/davidjurgens/potato:2.10.3`,
or whichever version you tested the study on. `--image` picks any other tag.

### The fifteen targets

`potato deploy providers` on 2.10.3:

```
aws            AWS Lightsail: one VM, flat $12/mo for 2 GB with IPv4 and disk; the recommended AWS target
aws-ec2        AWS EC2: a t4g.small VM with an Elastic IP, about $18/mo with its root disk
aws-ecs        AWS ECS Express Mode: managed HTTPS, no server, ~$45-70/mo; ephemeral disk, so it needs --backup
digitalocean   supports pull
fly            Fly.io: one Machine + volume on <app>.fly.dev, about $6/mo
heroku         Heroku container dyno from $7/mo; ephemeral disk, so it needs --backup (sustaining mode since 2026-02)
hetzner        Hetzner Cloud: a 4 GB VM for about €6/mo, the cheapest durable target
huggingface    ephemeral filesystem, supports pull
linode         Akamai/Linode: a 2 GB VM for $12/mo
local          local only, supports pull
openstack      OpenStack (Jetstream2, campus clouds): free with an allocation; real hostname on Jetstream2
railway        Railway: image + volume on *.up.railway.app, usage-billed (~$10-20/mo)
render         ephemeral filesystem, supports pull
tunnel         temporary public URL; stops when `potato share` does
vultr          Vultr: a 2 GB VPS for $10/mo
```

Quote a researcher the price `--dry-run` prints. It comes from the planner's
price table and includes the disk and the IPv4 address.

Three extras pull in the provider SDKs:

```bash
pip install 'potato-annotation[deploy]'            # every target not named below
pip install 'potato-annotation[deploy-aws]'        # aws, aws-ec2, aws-ecs (boto3)
pip install 'potato-annotation[deploy-openstack]'  # openstack (openstacksdk)
```

`check` blocks a target whose extra is missing, and names the one to install:

```
ERROR   [D021] the openstack target needs openstack, which is not installed
        -> pip install 'potato-annotation[deploy-openstack]'
```

## Choosing a target

The targets differ on who can reach the task, whether the annotations survive a
restart, and what it costs. The rest is provisioning detail. Costs are the
`--dry-run` figures at the default size.

| Target | What it is | Annotations survive a restart | Cost | Also needs |
|---|---|---|---|---|
| `local` | Docker on this machine, `127.0.0.1` only | yes | free | Docker running |
| `tunnel` | public until the command stops | yes, local disk | free | a tunnel binary |
| `aws` | Lightsail VM | yes, on its disk; `--volume-gb` to outlive it | $12/mo | the `deploy-aws` extra |
| `aws-ec2` | EC2 `t4g.small` | yes, as above | $18.31/mo | `deploy-aws`; `--subnet` if the region has no default VPC |
| `aws-ecs` | ECS Express, a managed container | **no** — only what the backup holds | $47/mo | `deploy-aws`, and `--backup` |
| `hetzner` | VM, 2 vCPU / 4 GB | yes | €5.99/mo, billed in euros | — |
| `vultr` | VM, 1 vCPU / 2 GB | yes | $10/mo | your IP on the API key's access list |
| `linode` | VM, 1 vCPU / 2 GB | yes | $12/mo | — |
| `digitalocean` | VM, 2 vCPU / 2 GB | yes | $18/mo default, $6 smallest | — |
| `openstack` | VM on Jetstream2 or a campus cloud | yes | your allocation's service units | `deploy-openstack`, a `clouds.yaml` |
| `fly` | one Machine and a 1 GB volume | yes, on the volume | $5.85/mo | `--backup` if the project is over 512 KB |
| `railway` | one service and a volume | yes, on the volume | usage-billed, $10–20/mo | `--backup`, always |
| `heroku` | one Basic dyno | **no**; the dyno's disk is wiped at least daily | $7/mo | `--backup`, or `--demo` |
| `render` (free) | web service; stops after 15 minutes idle | **no** | free | `--backup`, always |
| `render --plan starter --volume-gb 1` | as above, with a disk | yes, on the disk | $7/mo + $0.25/GB | `--backup`, always |
| `huggingface` | a Docker Space | **no**; the backup is always on | free to run | a write token, **and a paid HF plan to create the Space** |

The lines that catch people out:

- **`render`, `railway` and `aws-ecs` refuse `up` without `--backup`, and
  `--demo` does not change that.** These hosts run the published image and have
  no way to receive files, so the project is uploaded to the backup's storage
  and downloaded when the container starts. No backup means nowhere to put the
  project. `fly` refuses the same way once the bundle is over 512 KB. Heroku
  builds its own image, so `--demo` alone is accepted there.
- **A free Render instance takes no disk.** `--volume-gb` without `--plan
  starter` or higher is refused. Without a disk the backup is the only copy of
  the annotations.
- **A Docker Space needs a paid HuggingFace plan** (PRO personally, Team or
  Enterprise for an org). Restarting an existing Space does not. A free account
  also runs at most three Spaces, and one over the limit sits `PAUSED` and never
  wakes for a visitor.
- **The $6 DigitalOcean droplet is too small.** `s-1vcpu-1gb` gets a warning in
  the plan: the image alone is ~840 MB and Potato's working set is
  numpy/pandas/scipy. `s-2vcpu-2gb` at $18 is the default for that reason.
- **A VM without `--domain` gets a certificate for its IP address**, and those
  are valid for about six days rather than 90. They renew on their own;
  `potato deploy status` reports a renewal that fails. `openstack --cloud
  jetstream2` is the exception, because Jetstream2 gives every instance a DNS
  name.
- **`fly` and `railway` delete the volume on `destroy`.** `--keep-data` cannot
  keep a Fly volume. Pull first.

Where each one fits, from Potato's own install guide: an AWS account →
`aws`; a US academic with an ACCESS allocation → `openstack --cloud jetstream2`,
which costs no money; the cheapest durable VM → `hetzner`; nobody to look after
a server → `fly` or `railway`. Heroku has been in sustaining-engineering mode
since February 2026. It works, but use it only if the lab already has an
account.

Four questions decide it, and they are the researcher's to answer, not yours:

| Ask | Because |
|---|---|
| How long does the study run? | An afternoon is `potato share`. Weeks of annotators is `deploy up`. |
| Whose account and whose money? | Every public target except free Render bills someone, and `openstack` draws down an allocation. Do not create a billable resource on an assumption. |
| Would losing the annotations end the study, or just cost a morning? | This is the whole ephemeral/durable choice, and it is much cheaper to answer now. |
| Is anything in the data or the survey answers identifiable? | Decides whether a public URL with open sign-up is acceptable at all. |

## Backups and restore

Any target can mirror what Potato collects to storage the researcher owns. On
`aws-ecs`, `heroku`, `render` and `huggingface` that copy is the only one that
outlives a restart. On a VM it is a second copy for no extra cost.

```bash
potato deploy up config.yaml --provider heroku --backup hf --hf-token $HF_TOKEN
potato deploy up config.yaml --provider heroku --backup s3 --s3-bucket my-bucket
potato deploy up config.yaml --provider fly --backup s3 --s3-bucket my-bucket \
    --s3-endpoint https://<account>.r2.cloudflarestorage.com   # R2, B2, MinIO
potato deploy up config.yaml --provider railway --backup hf,s3 --hf-token $HF_TOKEN \
    --s3-bucket my-bucket --backup-minutes 10
```

| Flag | Default |
|---|---|
| `--backup hf\|s3\|hf,s3` | none. Without it, `--hf-token` alone means `--backup hf` and `--s3-bucket` alone means `--backup s3` |
| `--hf-token` | `HF_TOKEN`. Needs write access |
| `--hf-backup-repo` | `<account>/<name>-annotations`, created private |
| `--s3-bucket` | none; the bucket must already exist |
| `--s3-prefix` | `potato/<name>` |
| `--s3-region`, `--s3-endpoint` | boto3's defaults; the endpoint is for S3-compatible stores |
| `--backup-minutes` | 5 |

S3 credentials come from `POTATO_S3_ACCESS_KEY_ID` and
`POTATO_S3_SECRET_ACCESS_KEY` and end up in the server's environment, where
anyone with a shell on the host can read them. Use a key that reaches only this
bucket.

**What it copies:** the annotation output directory file by file (every
`user_state.json`, plus `user_config.json`, which holds the accounts), and
snapshots of `project.sqlite` and `datasets.sqlite` taken with SQLite's backup
API rather than copied as live WAL files. Only changed files are uploaded each
cycle.

**Restore is automatic.** When the server starts and its output directory holds
no `user_state.json`, it downloads the latest backup before it loads anything.
It never overwrites: a disk that already holds annotations is treated as the
authoritative copy. The log says which happened:

```
Restored project.sqlite from backup
RESTORED 5 file(s) from the s3://probe-bucket/potato/probe backup into an empty task. This is expected after a restart on a host without a persistent disk.
Loaded user data for 1 users
```

That is from a local run against an S3-compatible server: three items
annotated, the task directory deleted, the server started again. The annotator
logged in with the original password and landed on the fourth item.

`potato deploy up` writes the `backup:` block into the bundled config, never
into yours. On a server you run yourself, add it by hand:

```yaml
backup:
  schedule_minutes: 5
  restore_on_boot: true
  sinks:
    - type: huggingface
      repo_id: lab/pilot-annotations
    - type: s3
      bucket: my-bucket
      prefix: potato/pilot
      region: us-east-1
```

The older `huggingface_backup:` block still works and is read as one
HuggingFace sink, **with `restore_on_boot` off**, so a config that relied on it
backs up but does not restore. Move it to `backup:` if the host's disk does not
last.

On an S3-only backup, the project link the container downloads at start is a
presigned URL valid for at most seven days. A host with a disk downloads it once
per deploy. A host without one (`render` free, `aws-ecs`) downloads it on every
restart, so a week later a restart fails until you run `up` again. The plan
warns about this; use `--backup hf` on those hosts.

## Walking someone through a first deploy

Six steps. The first three cost nothing and touch no provider, so run all three
before asking anyone to make an account.

**1. Find out what is already configured.**

```bash
potato deploy providers            # targets, and which have a credential
potato deploy providers --verify   # ask each one whether the token really works
```

Credentials are found without being configured for Potato: a HuggingFace token in
the `huggingface_hub` cache, a single-context `~/.config/doctl/config.yaml`, the
key `heroku login` writes to `~/.netrc`, an AWS profile or SSO session, and a
`clouds.yaml` are all picked up. Often the answer to "which provider" is "the one
they are already signed in to".

`--verify` matters because an expired, read-only or newline-terminated token
looks exactly like a good one until `up` is several resources deep.

**2. Get a token, if the chosen target needs one.**

Nothing is ever written to disk. The token is resolved per invocation from the
environment, so a leaked project directory is not a leaked cloud account.

| Provider | Credential | Notes |
|---|---|---|
| `aws`, `aws-ec2`, `aws-ecs` | the AWS chain: `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, `~/.aws/`, SSO | `--aws-profile lab` picks a profile and is remembered for `status`, `pull` and `destroy`; `--region` defaults to `us-east-1` |
| `digitalocean` | `DIGITALOCEAN_TOKEN` | read **and** write scope |
| `fly` | `FLY_API_TOKEN` | `fly tokens create org`, or a personal token |
| `heroku` | `HEROKU_API_KEY`, or `heroku login` | |
| `hetzner` | `HCLOUD_TOKEN` | project token with read and write |
| `huggingface` | `HF_TOKEN` | write access |
| `linode` | `LINODE_TOKEN` | read/write on Linodes, Firewalls and Volumes |
| `openstack` | `clouds.yaml` plus `--cloud <entry>`, or `OS_CLOUD` | Jetstream2: an application credential from Horizon, saved to `~/.config/openstack/clouds.yaml` |
| `railway` | `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN` | |
| `render` | `RENDER_API_KEY` | |
| `tunnel` | `NGROK_AUTHTOKEN` | ngrok only; cloudflared quick tunnels need no account |
| `vultr` | `VULTR_API_KEY` | every request returns 401 until your IP is on the key's access list |
| `local` | — | |

For the token-based providers, `POTATO_DEPLOY_TOKEN_<PROVIDER>` overrides the
variables above, and `--token` overrides that. `potato
deploy up` without a token prints where to get one for the provider you asked
for, so you can also just run it and read the error.

**3. Run the preflight and the dry run.**

```bash
potato deploy check  config.yaml --provider aws
potato deploy up     config.yaml --provider aws --dry-run
```

`check` reports what the deployment exposes (next section). `--dry-run` is the
other half: it builds the bundle, then prints the provisioning steps, the URL the
task will end up at, and **the monthly cost**, without credentials and without
touching the provider.

```
14 step(s):
   1. aws.identity           confirm the credentials with sts:GetCallerIdentity
   2. ssh.keygen             generate an ed25519 deploy key, delivered by cloud-init
   3. lightsail.bundles      check that bundle small_3_0 is still sold
   4. lightsail.instance     create potato-deploy-probe (small_3_0, ubuntu_24_04) in us-east-1a
   5. state.persist          record the instance name before anything else can fail
   6. wait.active            poll until the instance is running
   7. lightsail.static_ip    allocate potato-deploy-probe-ip and attach it, so the address and its certificate survive a stop/start
   8. lightsail.ports        open 22/80/443 only; never 8000
   9. wait.ssh               poll until the host accepts an SSH connection
  10. wait.cloud_init        wait for docker install and image pull to finish
  11. ssh.upload             upload the bundle to /opt/potato/app
  12. ssh.env                write /opt/potato/potato.env at mode 0600
  13. ssh.start              systemctl start potato and caddy
  14. wait.http              poll https://<instance-ipv4>/health

Result URL: https://<instance-ipv4>
Estimated cost: $12.00/month
WARNING: No --domain, so TLS uses a Let's Encrypt certificate for the instance's IP address. These are valid ~6 days rather than 90, so check `potato deploy status` if the study runs unattended.
WARNING: No --volume-gb. Annotations live on the instance's own disk, which is destroyed with the instance. Pull before you destroy.
```

When `up` would refuse, the plan is followed by a `REFUSED:` line naming the
reason and the flags that fix it, and the command exits 2 instead of 0. The dry run and the real `up` run the same check, before
any provider is called:

```
   2. bundle.publish         upload the project tarball to (nowhere; see REFUSED below); the container fetches it at start
...
REFUSED: Render runs the published image and fetches your project into it at start, so the project has to be uploaded somewhere first. It goes to the backup's storage, so --demo is not an option here: pass --backup hf --hf-token <token> (recommended: the link never expires) or --backup s3 --s3-bucket <bucket>. On the free plan the backup is also the only copy of the annotations, since the instance stops after 15 minutes idle and loses its filesystem.
```

Show the researcher this output before spending their money. It is the only
place the cost, the URL and the durability warnings appear together, and it
reaches no provider.

Building the bundle writes it under `.potato/bundle/<provider>/<name>/` in the
task directory. That is gitignored in Potato's own tree, but it is a real
directory of copied project files, so a dry run against four providers leaves
four copies behind.

**4. Deploy.**

```bash
potato deploy up config.yaml --provider aws --volume-gb 10
```

It prints the plan again and asks for confirmation; `--yes` skips the prompt and
is for scripts, not for the first time. `--force` proceeds despite preflight
*errors* and should never be used against a public host.

If it fails partway, the deployment is still recorded — `state.persist` comes
before anything expensive can fail — so `potato deploy status`, `logs` and
`destroy` all work on a half-built deployment. Do not retry by hand-deleting
resources in the provider's console.

**5. Prove it works**, with the steps under **After it is up** below. A bundle
that omitted a side file fails on the host having worked locally, so the
walk-it-as-an-annotator step is not optional just because it passed at home.

**6. Hand it over.** The URL, who can sign in, where the annotations live, how to
pull them, and how to take it down. Say which of these steps you actually ran and
which you only prepared.

## Flags by target

`potato deploy up --help` lists them all on one screen without saying which
target reads which. Grouped:

| Flag | Read by |
|---|---|
| `--size` | every target with a machine size: a Lightsail bundle (`small_3_0`), an EC2 type (`t4g.small`; `t3.small` for x86), a Heroku dyno (`basic`, `standard-2x`), a Fly memory size in MB, a server type, or an OpenStack flavor |
| `--volume-gb` | the VM targets, `fly` (default 1) and `render --plan starter`. Puts the task on a disk that outlives the instance |
| `--acme-email` | the VM targets: the Let's Encrypt contact. Defaults to `git config user.email` |
| `--domain` | the VM targets: point an A record at the IP first, and you get a 90-day certificate |
| `--aws-profile`, `--region` | the three AWS targets (`--region` also picks a Fly or VM region) |
| `--subnet` | `aws-ec2`, when the region has no default VPC |
| `--cloud`, `--network`, `--os-image` | `openstack` |
| `--heroku-registry` | `heroku`: build locally with Docker and push to Heroku's registry, for when the build on Heroku fails |
| `--plan` | `render` (`free`, `starter`, `standard`) |
| `--owner` | `huggingface` and `fly` (the organization) |

The VM targets are `aws`, `aws-ec2`, `digitalocean`, `hetzner`, `vultr`,
`linode` and `openstack`. All seven provision the same way: a deploy key made
for that deployment alone, a firewall open on 22/80/443 and never 8000, Docker,
Caddy in front, and Potato as a systemd service. `logs`, `pull` over SSH and
`--volume-gb` work on all of them.

Per-target traps:

- **`hetzner`**: the default `cx` types are sold only in the European locations.
  In `ash` or `hil`, pass a `cpx` type with `--size`, or Potato stops before
  creating anything and lists where your type is sold.
- **`openstack`**: `--cloud jetstream2` is a preset for the flavor
  (`m3.small`), the image (`Featured-Ubuntu24`) and a real hostname,
  `potato-<name>.<allocation>.projects.jetstream-cloud.org`. Any other
  `clouds.yaml` entry gets `m1.small`, `Ubuntu 24.04` and an IP certificate.
  An `m3.small` costs 2 service units an hour, about 17,500 a year if nobody
  destroys it. A project without an auto-allocated network needs `--network`.
- **`aws-ecs`**: Potato switches the service to stop the old task before starting
  the new one, so two tasks never restore and back up the same study at once.
  A redeploy takes the task offline for a minute or two. If AWS changes the
  setting back, the next `up` refuses; `destroy` and `up` again, pulling first.
  The two IAM roles it creates are shared and left in place by `destroy`.
- **`heroku`**: `--size eco` sleeps after 30 minutes idle and restores from the
  backup on every wake. `R14 (Memory quota exceeded)` in the logs means
  `--size standard-2x`.

## The preflight

`potato deploy check` is the part of this worth learning. It never touches a
provider, it takes a second, and it reports what the deployment exposes:

```
$ potato deploy check config.yaml --provider render

WARNING [D003] Anyone who finds the URL can register and annotate.
        -> Set user_config.allow_all_users: false and list your annotators under user_config.users, or use authentication.method: oauth with at least one entry under authentication.providers.
WARNING [D011] The render filesystem is ephemeral: annotations are lost when the host restarts or redeploys.
        -> Pass `potato deploy up` --backup hf (with --hf-token) or --backup s3 --s3-bucket <name> so the data is mirrored off the host and restored after a restart, or add a backup: block to the config.
note    [D012] No secret_key is set, so one will be generated and injected as POTATO_SECRET_KEY. Sessions survive restarts only because of it.

Exposure:
  Reachable from the public internet: yes
  Sign-in: open — anyone with the URL can create an account
  Password required: yes
  Admin access: via the generated admin API key (injected as POTATO_ADMIN_API_KEY, not written into the bundle)
  Preflight: 0 error(s), 2 warning(s)

PASS — safe to deploy
```

Exit codes: **0 for PASS, 2 for BLOCKED.** Errors block; warnings do not.

The one that blocks most often:

```
ERROR   [D002] debug is true. With debug_phase it signs every visitor in as
        one shared debug_user and skips them to that phase, and it opens the
        admin pages without a key to requests from the server's own machine.
        It is for a server on a developer's machine.
        -> Remove `debug: true` from the config before deploying.
```

If you turned on `debug` to check the interface, take it out. It is not a
logging flag: it can sign every visitor in as the same user, and it lets the
host's own machine into the admin pages without a key.

Run `check` against the provider you actually intend to use. `local` reports no
exposure and no durability problem, so a clean `check` with the default provider
tells you nothing about hosting it.

Add `--private` when the host will not be public (behind a VPN, on an internal
network) so the assessment is not arguing about exposure you have already
handled.

## What ships

`potato deploy build` assembles a bundle and stops, which is the cheap way to see
what would be uploaded:

```
Bundle: ./bundle
  20 files, 152.6KB
  sha256 f70b22ad201b4e37
```

Two things to know about it:

- **It copies the whole task directory.** Screenshots in `shots/`, a stray
  `config_unsafe.yaml`, notes, scratch data — all of it goes. Clean the
  directory before building, and look at the file list rather than the count.
- **`annotation_output/` is stripped** to an empty `.gitkeep`. Annotations you
  collected while testing do not ship. Verified by putting a `user_state.json` in
  there and rebuilding.

The size matters on `fly`: a bundle up to 512 KB travels inside the Machine's
configuration and needs no storage, and a larger one needs `--backup`.

The bundle also carries the generated `layouts/`, so the host does not
regenerate them. Delete `layouts/` and rebuild if you changed schemes and the
deployed page looks stale.

Secrets are **not** written into the bundle. The secret key and admin API key are
generated and injected as `POTATO_SECRET_KEY` and `POTATO_ADMIN_API_KEY`
environment variables at deploy time. Pass your own with `--env KEY=VALUE` and
`--secret KEY=VALUE`.

## Deploy buttons

`potato deploy button` writes the files a host reads to offer a one-click deploy
of the task from its git repository, and prints the README badge. It is for
other people running their own copy in their own accounts, not for hosting the
study you run.

```bash
potato deploy button config.yaml --target heroku --backup hf \
    --hf-backup-repo lab/pilot-annotations --dry-run    # print the files, write nothing
```

| `--target` | Writes | Needs |
|---|---|---|
| `heroku` | `app.json`, `heroku.yml`, `Dockerfile.potato` | `--backup` |
| `render` | `render.yaml`, `Dockerfile.potato` | `--backup` |
| `aws` | `potato-lightsail.cfn.yaml`, a CloudFormation stack for Lightsail | `--backup`, a public repository, and the template uploaded to S3 and passed back as `--template-url` |
| `railway` | nothing; it prints the steps for publishing a template from the Railway dashboard | — |

Without `--backup`, the heroku, render and aws targets exit 1 before writing
anything. All of them also write `potato.deploy.yaml` beside your config, a copy
with `debug: false`, `persist_sessions: true` and the backup block, and
`.dockerignore` if the repository has none. Your `config.yaml` is not changed.
No secrets are written.

Read the warnings it prints before you commit. It lists anything that should
not reach a public repository and is not gitignored (`.potato`, which holds the
admin key, plus `annotation_output` and `project.sqlite`). With no `origin`
remote, the badge carries a `<repo-url>` placeholder for you to fill in.
Existing files are kept unless you pass `--force`.

## Behind a reverse proxy

"We'll put it at example.edu/annotate" is a request for a path prefix, and Flask
has to be told about it or every asset URL comes out rooted at `/`. Two
switches, both environment variables rather than config keys:

```bash
# the proxy sets headers (nginx: proxy_set_header X-Forwarded-Prefix /annotate)
POTATO_PROXY_FIX=1 potato start config.yaml

# the proxy cannot; name the prefix yourself
POTATO_URL_PREFIX=/annotate potato start config.yaml
```

Both converge on the WSGI `SCRIPT_NAME`. `POTATO_PROXY_FIX` also reads
`POTATO_PROXY_FIX_X_FOR`, `_X_PROTO`, `_X_HOST` and `_X_PREFIX`, for saying how
many proxies deep the server sits. Potato's own
`docs/deployment/reverse-proxy.md` is the upstream page.

Driven on 2.8.2-26 behind a proxy mounting a task at `/annotate`: under either
switch the annotation page renders styled and `url_prefix` reaches the page as
`/annotate`. Walk the whole sign-in on the **proxied** URL before you hand it
out, and do it in a browser: a prefix that reaches the stylesheet has not
necessarily reached every form on the page.

## Before you expose anything

The preflight will say all of this, but decide it before you type `up`:

| Question | Where it is answered |
|---|---|
| Who can sign in? | `user_config.allow_all_users: false` plus `user_config.users`, or `authentication.method: oauth`. The default lets anyone with the URL create an account |
| Is `debug` off? | It must be. It can sign everyone in as one user and opens admin to the host without a key |
| Do sessions survive a restart? | `secret_key`, or let deploy generate one |
| Where do annotations live if the host restarts? | A volume (`--volume-gb`), or `--backup` with restore on boot |
| Who holds the admin key? | Generated per deployment; `potato deploy status` and the provider's env |
| Is anything identifiable in the data or the survey answers? | `export_include_phase_data` defaults to false for a reason |

## Getting the data back

```bash
potato deploy pull config.yaml --dest ./collected
```

Every provider supports pull. Do this **before** `destroy` — `destroy` refuses to
run without a prior successful pull unless you pass `--force`, which is a
deliberate guard rather than an annoyance. `--keep-data` on destroy keeps the
provider-side volume where the provider allows it; on `fly` it cannot.
`destroy` never touches the backup dataset or bucket.

`pull --allow-empty` records a pull that returned nothing, for when you know the
task collected nothing and want `destroy` to proceed.

## After it is up

Deploying is not verifying. The same standard as `running-a-task.md` applies, and
the failure modes are worse remotely because you cannot read the log by looking
sideways:

1. `potato deploy status config.yaml`. It should say provisioned and running.
2. `potato deploy logs config.yaml --lines 200` (`fly logs -a <app>` on Fly) —
   the **startup log**, with the same lines you grep locally: `Loaded N training
   instances`, `Loaded N attention check items`, any phase errors, and on a host
   with a backup, `Backup: … every N minute(s)`. A feature that loaded locally
   can fail on the host if a side file was excluded from the bundle.
3. Open the URL and walk the study as an annotator: register, consent, one item,
   navigate away and back.
4. Run `potato deploy pull` once, early, and check the files are what you
   expect. Finding out that pull does not work after four weeks of annotation is
   the expensive version of this mistake. With a backup, open the dataset or
   bucket too and check a `user_state.json` has arrived.

Then tell the researcher the URL, who can sign in, where the annotations live,
how to pull them, and how to take it down. A deployment nobody can pull from or
destroy is worse than a local server.

## What I have and have not verified

Run on 2.11.1 (`cf0873a0`; the retagged `68880e8c` changes only wording), from a checkout, with Docker stopped: `up
--dry-run` on `local`, `render` and `railway` plans the 2.11.1 image, which is
published on ghcr.io; `check` with `debug: true` blocks on D002 with exit 2,
quoted above; `check` on `openstack` without its SDK still blocks on D021.

Run on 2.11.0 (`eb284819`), from a checkout, with Docker stopped:

- `up --dry-run` on the fourteen targets other than `tunnel`. The three whose plans
  name the image, `local`, `render` and `railway`, plan the 2.11.0 image and
  none plans `latest`. The image tag `2.11.0` is published on ghcr.io;
- `check` on the eleven VM and container targets. `openstack`, whose SDK was not
  installed, blocked on D021 with exit 2 and `ok: false` in `--json`; the
  others passed.

Run on 2.10.3, installed from PyPI, with `--image
ghcr.io/davidjurgens/potato:2.10.3`:

- the whole lifecycle on `local`: `up`, `status` (`healthy True`), `logs`, a
  three-item annotator walk, `pull` (one annotator, `project.sqlite`, and the
  three answers in `user_state.json`) and `destroy`, which removed the
  container;
- the provider list and credential detection, from `potato deploy providers`.

Run on the re-cut 2.10.2 tag (`1a9235b1`). The only deploy change between it
and 2.10.3 is the button command's HuggingFace account lookup, which was run
again on the 2.10.3 release candidate:

- `--dry-run` for all fifteen targets, which reaches no provider. That covers
  the plans, the costs above, the jetstream2 preset's flavor and hostname, and
  the `REFUSED:` exit 2 for heroku, aws-ecs, render and railway without a
  backup, for fly with a 1 MB bundle, for render with `--volume-gb` on the free
  plan, and for tunnel;
- `check` output and exit codes, the `debug` block, and what `build` includes
  and strips;
- the button command for all four targets with `--dry-run`, including its exit
  1 without `--backup`.

Run on 2.10.1 (`6b281231`) and not repeated:

- the subcommands and flags, from `--help`. `tests/test_deploy_flags.py` still
  checks every flag this page names against the parser;
- backup and restore on boot, end to end, with an S3 sink pointed at a local
  S3-compatible server. That run is quoted in **Backups and restore**.

Read from the code but not run: that a real `up` reaches the same refusal check
as the dry run, before calling the provider (`cmd_up` checks it ahead of the
`--dry-run` branch, after the credential check); that the legacy
`huggingface_backup:` block maps to a sink with `restore_on_boot` off; and that
every other target takes the same default image as the three dry runs that
print it (they all read `DEFAULT_IMAGE` in `potato/deploy/image.py`). The prices are the planner's own tables, not a quote from anyone's
billing page.

Not verified here: an actual `up` against any cloud provider, and therefore the
provisioning, DNS, TLS, the HuggingFace sink and pull behaviour of a live host.
Provisioning costs money and creates real resources, so do it deliberately and
with the researcher's knowledge. In a handover, say which of these steps you
ran and which you only prepared.
