The rule I follow for every scripted GPU job is that the stop call sits in a finally block, because DeployGPU has no idle timer: a script that dies after launching leaves a GPU running, and billing, until someone stops it. This guide builds one script around that rule for periodic work such as batch inference or an evaluation sweep. It launches the cheapest VM for a GPU, copies a folder of work onto it, runs the job, copies the results back and stops the machine. Each run gets a fresh VM with a new IP address and an empty disk, an ephemeral GPU that costs money only while it exists. Every endpoint and field comes from the deployments, offers, account and SSH keys references, checked 1 October 2026.
The seven steps the script follows
- Read the public offer list for one GPU, cheapest first, and keep only
virtualoffers. Vast.ai containers show up asdedicated, and the API refuses them withprovider_not_supported. - Check that the balance covers the job's hours plus one. Paid time starts when provisioning starts, so the clock runs before the job does.
- Create the deployment once. A sold-out or cooling-down offer moves the script to the next offer, and a create call that gets no answer ends the script, because the API has no idempotency key.
- Poll until the status is
active, treating a 404 in the first seconds as not visible yet. - Retry SSH until it answers, since
activedoes not check SSH. - Copy the
jobfolder over, runrun.shwith a time limit and copyjob/resultsback. - Stop the deployment in a
finallyblock, after the copy.
Set up the API key, the SSH key and the job folder
Create an API key on the API keys page. Keys start with gpu_live_ and have no scopes or expiry, so use one for this script and keep it in a secret store. The script names one SSH key explicitly and runs ssh in batch mode, so use a key file that does not prompt for a passphrase. Its id comes from your key list:
curl -s https://api.deploygpu.ai/api/v1/users/me/ssh-keys -H "X-API-Key: $DEPLOYGPU_API_KEY"
export DEPLOYGPU_SSH_KEY_ID=ID_FROM_THE_LIST
export DEPLOYGPU_SSH_KEY_FILE=~/.ssh/id_ed25519
Next to the script, a job folder holds your work. run.sh starts it, and anything worth keeping goes into results:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p results
nvidia-smi > results/gpu.txt
python3 run.py > results/output.txt
The VM has the provider's Ubuntu image with NVIDIA drivers and Docker, and none of your packages, so install what the job needs at the top of run.sh.
The script, about 150 lines
"""Run one batch job on a rented GPU VM, copy the results back and always stop the VM.
Not yet run against the real API.
"""
import math
import os
import signal
import subprocess
import sys
import tempfile
import time
import requests
API = "https://api.deploygpu.ai/api/v1"
HEADERS = {"X-API-Key": os.environ["DEPLOYGPU_API_KEY"]}
KEY_FILE = os.environ["DEPLOYGPU_SSH_KEY_FILE"] # private half of an SSH key on your account
KEY_ID = os.environ["DEPLOYGPU_SSH_KEY_ID"] # its id from GET /api/v1/users/me/ssh-keys
GPU = "rtx-a6000" # a variant slug from GET /api/v1/gpu-families
JOB_HOURS = 2 # how long you expect the job to take
JOB_TIMEOUT_S = 3 * 3600 # the job is killed after this
DISK_GB = 100 # used only where the disk size is adjustable
# Offers call Massed Compute "massed-compute", the create call wants "masscompute".
PROVIDER = {"massed-compute": "masscompute"}
def vm_offers():
params = {"gpu": GPU, "max_gpu_count": 1, "sort_by": "priceHourly", "sort_order": "asc", "size": 20}
resp = requests.get(f"{API}/offers", params=params, timeout=30)
resp.raise_for_status()
# Vast.ai containers are "dedicated", and the API refuses them.
return [o for o in resp.json()["offers"] if o["deploymentType"] == "virtual" and o["isAvailable"]]
def check_balance(offer):
resp = requests.get(f"{API}/account/balance", headers=HEADERS, timeout=30)
resp.raise_for_status()
hours = math.ceil(JOB_HOURS) + 1 # paid time starts at provisioning and ends after the copy
if float(resp.json()["balance"]) < offer["priceHourly"] * hours:
sys.exit(f"Balance is below the price of {hours} hours on this offer")
def create(offer):
slug = offer["provider"]["slug"]
body = {"provider": PROVIDER.get(slug, slug), "offer_id": offer["id"], "ssh_key_ids": [KEY_ID]}
low, high = offer.get("storageMinGB"), offer.get("storageMaxGB")
if low is not None and high is not None and low != high:
body["disk_gb"] = min(max(DISK_GB, low), high)
try:
resp = requests.post(f"{API}/deployments", headers=HEADERS, json=body, timeout=60)
except requests.RequestException as exc:
# No answer, so a GPU may already be provisioning. There is no idempotency key:
# never send the create again until you have listed your deployments.
sys.exit(f"Create got no answer ({exc}). List your deployments before running again.")
if resp.status_code == 202:
return resp.json()["id"]
try:
code = resp.json()["error"]["code"]
except (ValueError, KeyError, TypeError):
code = None
if code in ("offer_unavailable", "offer_cooling_down"):
return None # nothing was created or charged, so try the next offer
sys.exit(f"Create failed: HTTP {resp.status_code} {code}")
def launch():
for offer in vm_offers():
check_balance(offer)
deployment_id = create(offer)
if deployment_id:
return deployment_id
sys.exit(f"No {GPU} offer accepted a create call right now")
def wait_active(deployment_id, limit_s=900):
start = time.monotonic()
while time.monotonic() - start < limit_s:
resp = requests.get(f"{API}/deployments/{deployment_id}", headers=HEADERS, timeout=30)
if resp.status_code == 404 and time.monotonic() - start < 30:
time.sleep(2) # right after the 202, a GET can 404 for about a second
continue
resp.raise_for_status()
deployment = resp.json()
if deployment["status"] == "active":
return deployment["connection"]
if deployment["status"] in ("failed", "interrupted", "stopping", "terminated"):
sys.exit(
f"{deployment['status']}: {deployment.get('failure_code')}, "
f"retryable={deployment.get('retryable')}, {deployment.get('suggested_action')}"
)
time.sleep(10)
sys.exit("Timed out waiting for active")
def stop(deployment_id):
for attempt in range(5):
try:
resp = requests.post(f"{API}/deployments/{deployment_id}/stop", headers=HEADERS, timeout=60)
if resp.status_code in (200, 400): # 400: already stopping, terminated or failed
print(f"Stop answered {resp.status_code} for {deployment_id}")
return
print(f"Stop answered {resp.status_code}, retrying")
except requests.RequestException as exc:
print(f"Stop failed ({exc}), retrying")
time.sleep(5 * (attempt + 1))
sys.exit(f"STOP FAILED: stop {deployment_id} in the dashboard now")
def main():
for sig in (signal.SIGTERM, signal.SIGHUP): # by default these skip finally: a CI cancel, a closed terminal
signal.signal(sig, lambda *_: sys.exit(1))
deployment_id = launch()
print(f"Launched {deployment_id}")
conn = None
try:
conn = wait_active(deployment_id)
known_hosts = os.path.join(tempfile.mkdtemp(), "known_hosts")
opts = [
"-i", KEY_FILE, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={known_hosts}",
]
target = f"{conn['ssh_user']}@{conn['public_ip']}"
ssh = ["ssh", "-p", str(conn["ssh_port"]), *opts, target]
scp = ["scp", "-P", str(conn["ssh_port"]), *opts]
for _ in range(30): # active does not mean SSH answers yet
try:
if subprocess.run([*ssh, "true"], capture_output=True, timeout=30).returncode == 0:
break
except subprocess.TimeoutExpired:
pass
time.sleep(10)
else:
sys.exit("SSH never answered")
subprocess.run([*scp, "-r", "job", f"{target}:"], check=True, timeout=600)
subprocess.run([*ssh, "cd job && bash run.sh"], check=True, timeout=JOB_TIMEOUT_S)
finally:
if conn: # copy the results out before the disk is deleted, even after a failed job
try:
subprocess.run([*scp, "-r", f"{target}:job/results", f"results-{deployment_id}"],
check=True, timeout=600)
except Exception as exc:
print(f"Results NOT copied: {exc}", file=sys.stderr)
stop(deployment_id)
if __name__ == "__main__":
main()
Three details carry the safety. The create call runs once per offer and never after a timeout, because a retried create can start and bill a second GPU. The finally block copies results before it stops, so a failed job still hands back what it wrote. And the signal handler matters, because Python's signal documentation lists only SIGPIPE and SIGINT as handled by default, so a SIGTERM from a scheduler's cancel or a SIGHUP from a closed terminal ends the script without running finally. SIGKILL cannot be caught at all, which is why the cost guardrails guide adds a sweeper that stops anything left running.
How the four providers differ
| Provider | provider in the create call | SSH user | Disk | Note |
|---|---|---|---|---|
| QuantaCloud | quantacloud | ubuntu | Fixed per offer | Accepts only RSA and Ed25519 keys |
| Massed Compute | masscompute, though offers say massed-compute | Ubuntu | Fixed per offer | The mapping is in the script |
| Hyperstack | hyperstack | ubuntu | Fixed per offer | Can report active before SSH answers |
| Verda | verda | root | Adjustable, up to 500 GB | disk_gb is used only here |
The script reads the SSH user, address and port from the deployment's connection object, so none of this is typed. A key type a provider rejects fails the deployment with SSH_KEY_MISSING or SSH_KEY_REJECTED.
Seven failures and what the script does
| What happens | What the script does |
|---|---|
400 offer_unavailable or 503 offer_cooling_down | Nothing was created or charged. It tries the next offer instead of waiting for the Retry-After header |
402 insufficient_balance | Stops. Add credits first |
| The create call gets no answer | Exits without retrying. List your deployments before running again |
The deployment ends failed | Prints failure_code, retryable and suggested_action. The reserved hour is refunded automatically |
| SSH refuses for several minutes | Gives up, then stops the VM |
| The job runs past its limit | The time limit raises an error, and the script still copies results and stops |
| A stop call fails | Retries 5 times, then exits with a message to stop it in the dashboard |
What a 40 minute run costs
Launching reserves one hour of credit, and paid time starts when provisioning starts. Say the whole run takes 40 minutes from create to stop, including 3 minutes of provisioning. You pay for 40 minutes: the other 20 of the reserved hour go back to your balance, settled to the second, when the script stops the VM. This is the list the script reads for rtx-a6000 right now, cheapest first:
| GPU | GPUs | Provider | Region | vCPU / RAM / Disk | Interconnect | Per GPU-hr | Per hour | Launch |
|---|---|---|---|---|---|---|---|---|
| RTX A6000 48 GB | 1x | QuantaCloud | Midwest, US | 6 / 48 GB / 256 GB | - | $0.48 | $0.48 | Launch |
| RTX A6000 48 GB | 1x | QuantaCloud | Midwest, US | 6 / 48 GB / 256 GB | - | $0.48 | $0.48 | Launch |
| RTX A6000 48 GB | 1x | Hyperstack | CA | 28 / 58 GB / 100 GB | - | $0.50 | $0.50 | Launch |
| RTX A6000 48 GB | 1x | QuantaCloud | Midwest, US | 6 / 24 GB / 256 GB | - | $0.55 | $0.55 | Launch |
| RTX A6000 48 GB | 1x | Massed Compute | Iowa, US | 6 / 32 GB / 256 GB | - | $0.55 | $0.55 | Launch |
Where to run it: never on the GPU VM
Run it from your own machine or a small always-on server, never from the GPU VM, because a key on the VM can do everything your account can, including launching more GPUs. An SSH drop can end a command run this way, so start long jobs under tmux or nohup as in the keep jobs running guide and poll for a marker file. The provisioning failures guide goes through every failure code, and the API page lists the limits.
Frequently asked questions
Is it safe to retry a create call that timed out?
Not until you have listed your deployments. There is no idempotency key, so a retried create can start and bill a second GPU. The script exits instead of retrying.
How long until the VM is ready for the job?
A plain VM usually reaches active in 1 to 3 minutes. SSH can answer a little later on some providers, so the script retries the connection.
What happens to the results if the job fails?
The script still copies job/results before it stops the VM, so partial output is kept. The disk is deleted at the stop.
Can the VM shut itself down when the job ends?
Do not use shutdown inside the VM. On QuantaCloud and Massed Compute a VM that looks powered off is treated as lost and deleted with its disk, and on Verda and Hyperstack it keeps billing. Stop it with the API from your own machine.
Can I launch a template or a Vast.ai container this way?
No. The API launches plain VMs on QuantaCloud, Massed Compute, Verda and Hyperstack. Templates and Vast.ai containers are dashboard only.