Skip to content

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

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

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

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.

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:

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.

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.

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.

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.

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:

# 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

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.