Practical guides

Run batch GPU jobs from a script

Launch a GPU VM from a script, copy the job in, run it, copy the results out and stop the machine, with a full Python example and failure handling.

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

  1. Read the public offer list for one GPU, cheapest first, and keep only virtual offers. Vast.ai containers show up as dedicated, and the API refuses them with provider_not_supported.
  2. 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.
  3. 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.
  4. Poll until the status is active, treating a 404 in the first seconds as not visible yet.
  5. Retry SSH until it answers, since active does not check SSH.
  6. Copy the job folder over, run run.sh with a time limit and copy job/results back.
  7. Stop the deployment in a finally block, 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:

Code example
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:

Code example
#!/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

Code example
"""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

Providerprovider in the create callSSH userDiskNote
QuantaCloudquantacloudubuntuFixed per offerAccepts only RSA and Ed25519 keys
Massed Computemasscompute, though offers say massed-computeUbuntuFixed per offerThe mapping is in the script
HyperstackhyperstackubuntuFixed per offerCan report active before SSH answers
VerdaverdarootAdjustable, up to 500 GBdisk_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 happensWhat the script does
400 offer_unavailable or 503 offer_cooling_downNothing was created or charged. It tries the next offer instead of waiting for the Retry-After header
402 insufficient_balanceStops. Add credits first
The create call gets no answerExits without retrying. List your deployments before running again
The deployment ends failedPrints failure_code, retryable and suggested_action. The reserved hour is refunded automatically
SSH refuses for several minutesGives up, then stops the VM
The job runs past its limitThe time limit raises an error, and the script still copies results and stops
A stop call failsRetries 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:

GPUGPUsProviderRegionvCPU / RAM / DiskInterconnectPer GPU-hrPer hourLaunch
RTX A6000 48 GB1xQuantaCloudMidwest, US6 / 48 GB / 256 GB-$0.48$0.48Launch
RTX A6000 48 GB1xQuantaCloudMidwest, US6 / 48 GB / 256 GB-$0.48$0.48Launch
RTX A6000 48 GB1xHyperstackCA28 / 58 GB / 100 GB-$0.50$0.50Launch
RTX A6000 48 GB1xQuantaCloudMidwest, US6 / 24 GB / 256 GB-$0.55$0.55Launch
RTX A6000 48 GB1xMassed ComputeIowa, US6 / 32 GB / 256 GB-$0.55$0.55Launch
12 RTX A6000 VM offers available, checked 2026-10-14 19:06 UTC. Showing the 5 cheapest. Prices include any platform fee. The deploy page shows the full breakdown before you pay. See all in the marketplace.

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.