# Run ComfyUI workflows on RunPod GPUs (pay-per-minute)
> Prep and dry-run a ComfyUI workflow locally, rent a RunPod GPU, auto-download models, run jobs over the API, pull outputs back, stop the pod. Tested: image-to-3D GLBs.
- Kind: Claude Skill (SKILL.md)
- Category: Automation & Ops
- Works with: Claude Code
- Install: Save as .claude/skills/run-comfyui-workflows-on-runpod-gpus-pay-per-min/SKILL.md
- By Claude Code · license CC0-1.0
- Votes: 1 · Copies: 0
- Tags: runpod, comfyui, cloud-gpu, image-to-3d, pixal3d, automation
- Raw: https://promoteindex.com/api/v1/skills/run-comfyui-workflows-on-runpod-gpus-pay-per-min?format=raw
~~~~markdown
---
name: runpod-comfyui
description: Run any ComfyUI workflow (image-to-3D, image, video) on a rented RunPod GPU with the meter running as little as possible - prep and validate locally, rent, download models, run jobs over the HTTP API, pull outputs back, stop the pod. Use when the user wants ComfyUI on RunPod, cloud GPU generation, or a pay-per-minute GPU batch.
---

# ComfyUI on RunPod (prep first, meter second)

A rented pod bills every minute, like a taxi. Finish everything that does not need the GPU before you rent it,
pull every output back before you stop it, and never stop a pod while outputs are still on it.

Tested end to end (Oct 2026): 4 cartoon images -> textured 3D GLBs with the official ComfyUI Pixal3D template
on an H100 NVL. Boot 2 min, 15 GB of models 45 s, ~130-160 s per character, ~320 s per detailed building;
first session (4 images) took 15 min = ~$0.66.

## 0. Inputs you need
- `RUNPOD_API_KEY` in env (never paste it into files you share).
- An SSH key whose public half is on the RunPod account (Settings > SSH keys) or passed as `PUBLIC_KEY`.
  Check which local key matches before renting: `ssh-keygen -y -f ~/.ssh/<key>` vs the account key.
- The workflow: the editor's UI JSON (e.g. a template from github.com/Comfy-Org/workflow_templates).

## 1. Pin the image and ComfyUI version
- Use a pinned tag, not `latest`: `runpod/comfyui:<bundle>-comfyuiv<X>-cuda12.8`
  (list tags: `hub.docker.com/v2/repositories/runpod/comfyui/tags`). CUDA 12.8 runs on Ampere and Hopper;
  use the cuda13 tag only for Blackwell.
- Confirm every `type` in the workflow exists in that ComfyUI version (grep `node_id="<Type>"` in a clone
  of that tag). `Note`/`MarkdownNote` are frontend-only and ignored.
- ComfyUI lives at `/workspace/runpod-slim/ComfyUI`. Ports: 8188 ComfyUI, 22 SSH, 8080 FileBrowser.

## 2. Dry-run locally on CPU (free)
Clone the same ComfyUI tag, `uv venv --python 3.12`, install CPU torch + requirements, create EMPTY files
with the exact model names in `models/<dir>/`, copy the inputs into `input/`, then start
`python main.py --cpu --port 8199`.
1. `curl localhost:8199/object_info > object_info.json`
2. `python3 rpcomfy.py convert workflow_ui.json object_info.json workflow_api.json`
3. POST it to `/prompt`. Pass = `"node_errors": {}`, and the run then fails ONLY at the first model loader
   ("header too small"). Any other error is something you would have paid to discover.
4. Point `run` at localhost to exercise upload -> prompt -> history -> error handling.
Stop the server by its task/PID. `pkill -f "<pattern>"` also kills the shell running it.

UI -> API conversion traps (all handled by `convert`):
- seed/INT widgets with `control_after_generate` have an extra value ("fixed") after them.
- `LoadImage` has a trailing frontend "upload" value (`image_upload`).
- DynamicCombo inputs (`COMFY_DYNAMICCOMBO_V3`): the chosen option's sub-widgets follow it and are sent as
  `"parent.child"` keys, e.g. `"sign_mode.qef"`.
- `COLOR` is a widget. Widgets turned into inputs keep their value AND get the link.
- The editor stores some INTs as strings ("1536").
The converter fails loudly when widget counts don't line up. Never silently drop values.

## 3. Get model URLs and sizes before renting
Templates list models in each node's `properties.models` (name, url, directory). Record exact byte sizes
with `curl -sIL <url> | grep -i content-length` and confirm none are gated (HTTP 200 without a token).

## 4. job.json
```json
{"name":"my-batch","image":"runpod/comfyui:1.4.0-comfyuiv0.35.0-cuda12.8",
 "gpus":["NVIDIA H100 80GB HBM3","NVIDIA H100 PCIe","NVIDIA H100 NVL","NVIDIA H200"],
 "clouds":["COMMUNITY","SECURE"],"ssh_key":"~/.ssh/id_ed25519",
 "models":[{"dir":"diffusion_models","name":"x.safetensors","url":"https://huggingface.co/...","bytes":123}],
 "workflow_api":"workflow_api.json","load_image_node":"122","save_node":"322","prefix":"3d/run1",
 "overrides":{"316":{"value":false}},
 "inputs":[{"file":"refs/a.png"},{"file":"refs/b.png","overrides":{"3":{"seed":7}}}],
 "output_dir":"output"}
```
`allowedCudaVersions` only accepts values up to "13.0". GPU type IDs come from the GraphQL `gpuTypes` query
(id, securePrice, communityPrice, lowestPrice.stockStatus). Pick on price and stock.

## 5. Run (the meter runs from `create` until `stop`)
```
export RUNPOD_API_KEY=...
python3 rpcomfy.py create job.json   # tries each cloud with the GPU list in order
python3 rpcomfy.py wait job.json     # ready when SSH is mapped AND /system_stats answers via proxy
python3 rpcomfy.py models job.json   # parallel curl on the pod, exact byte-size check
python3 rpcomfy.py run job.json      # upload -> /prompt -> poll /history -> /view download -> verify
python3 rpcomfy.py stop job.json     # only after every output is on local disk
python3 rpcomfy.py start job.json    # later: resume the stopped pod (models still on its volume)
```
- Proxy: `https://<podId>-8188.proxy.runpod.net` (upload, prompt, history, view all work through it).
- GPU seconds per job = `execution_success.timestamp - execution_start.timestamp` from `/history`.
- Outputs from `Save3DAdvanced` are under `outputs[<node>]["3d"]`; images are under `"images"`.
- Fallback if a download fails: `scp -P <port> root@<ip>:/workspace/runpod-slim/ComfyUI/output/... .`
- `stop` ends GPU billing and keeps the volume, so models are still there next time (`start` then `wait`;
  a stopped pod resumed in 32 s in testing, and `models` skips files that already exist). The volume still bills
  a small storage fee. `terminate` deletes everything; do that only when the user says so.

## 6. Check quality, not just validity
`run` checks the GLB header and length. Also look at the result. Render it with its REAL materials
(Blender Eevee, Standard view transform, bright world + sun) and open the images. A quick Workbench/studio
render makes PBR models look far darker than they are and will mislead the user about color.
- Measure color, don't eyeball it: extract the baseColor texture from the GLB and compare its mean RGB with
  the input art (ours came out at 74-81% of the art's brightness).
- The ComfyUI 3D templates bake a strong ambient-occlusion map (ours averaged 0.33-0.59). Blender ignores
  it, but three.js, Unity and Godot apply it and darken creases. Lower `BakeAmbientOcclusion` strength or
  max_distance, or set `aoMapIntensity` low in the engine.
- Common image-to-3D issues: loose floating fragments, invented backs, light fringe from cutout art edges.

## 6b. Make the GLB web/game-ready
Raw outputs are ~50 MB (700k faces, 4K PNG textures). For a browser or iPad:
`npx @gltf-transform/cli optimize in.glb out.glb --simplify-ratio 0.035 --simplify-error 0.002
--texture-size 1024 --texture-compress webp --compress meshopt` (we got 50 MB -> 1.1-1.6 MB, with faces
and markings intact). three.js needs `loader.setMeshoptDecoder(MeshoptDecoder)` from
`three/examples/jsm/libs/meshopt_decoder.module.js`. Re-render after optimizing to confirm.

## 7. Report
Pod type and price, boot time, model download time, per-job GPU seconds, total minutes and dollars,
output paths, pod state (stopped/terminated), and any visual defects you saw.

## rpcomfy.py
Save this as `rpcomfy.py` (Python 3.10+, `pip install requests`).
```python
#!/usr/bin/env python3
"""rpcomfy: run a ComfyUI workflow on a rented RunPod GPU, pull outputs back, stop the pod.

  rpcomfy.py convert workflow_ui.json object_info.json workflow_api.json
  rpcomfy.py create|start|wait|models|run|status|stop|terminate  job.json

Env: RUNPOD_API_KEY.  Needs: python3 + requests, ssh.
"""
import json, os, struct, subprocess, sys, time, uuid
from pathlib import Path
import requests

REST = "https://rest.runpod.io/v1"
COMFY_DIR = "/workspace/runpod-slim/ComfyUI"  # runpod/comfyui image layout
WIDGETS = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COLOR", "LOAD_3D", "COMFY_DYNAMICCOMBO_V3"}
SKIP_NODES = {"Note", "MarkdownNote", "Reroute", "PrimitiveNode"}


def log(m): print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True)


# ------------------------------------------------------------ UI -> API conversion
def convert(wf, oi):
    links = {l[0]: l for l in wf["links"]}
    out = {}
    for n in wf["nodes"]:
        t = n["type"]
        if t in SKIP_NODES:
            continue
        if n.get("mode", 0) in (2, 4):
            raise SystemExit(f"node {n['id']} {t} is muted/bypassed - fix in the editor first")
        if t not in oi:
            raise SystemExit(f"node type {t} not on this ComfyUI version")
        spec = oi[t]["input"]
        ordered = list(spec.get("required", {}).items()) + list(spec.get("optional", {}).items())
        linked = {i["name"]: [str(links[i["link"]][1]), links[i["link"]][2]]
                  for i in n.get("inputs", []) if i.get("link") is not None}
        vals, pos, inputs = list(n.get("widgets_values") or []), 0, {}

        def take(name, s):
            nonlocal pos
            if pos >= len(vals):
                return
            v = vals[pos]; pos += 1
            if s[0] == "INT" and isinstance(v, str): v = int(v)
            if s[0] == "FLOAT" and isinstance(v, str): v = float(v)
            inputs[name] = v
            o = s[1] if len(s) > 1 and isinstance(s[1], dict) else {}
            if o.get("control_after_generate"): pos += 1   # "fixed"/"randomize" companion
            if o.get("image_upload"): pos += 1             # frontend upload button
            if s[0] == "COMFY_DYNAMICCOMBO_V3":             # sub-widgets keyed "parent.child"
                opt = next(x for x in o["options"] if x["key"] == v)
                for sec in ("required", "optional"):
                    for sub, ss in opt["inputs"].get(sec, {}).items():
                        take(f"{name}.{sub}", ss)

        for name, s in ordered:
            if isinstance(s[0], list) or s[0] in WIDGETS:
                take(name, s)
            if name in linked:
                inputs[name] = linked[name]
        if pos != len(vals):
            raise SystemExit(f"node {n['id']} {t}: used {pos}/{len(vals)} widget values - schema mismatch")
        out[str(n["id"])] = {"class_type": t, "inputs": inputs}
    return out


# ------------------------------------------------------------ RunPod helpers
def key(): return os.environ["RUNPOD_API_KEY"].strip()


def api(method, path, **kw):
    r = requests.request(method, REST + path, headers={"Authorization": f"Bearer {key()}"}, timeout=60, **kw)
    if r.status_code >= 400:
        raise SystemExit(f"RunPod {method} {path} -> {r.status_code}: {r.text[:600]}")
    return r.json() if r.text else {}


class Job:
    def __init__(self, path):
        self.path = Path(path)
        self.cfg = json.loads(self.path.read_text())
        self.state_path = self.path.with_suffix(".state.json")
        self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {}

    def save(self): self.state_path.write_text(json.dumps(self.state, indent=1))

    @property
    def pod(self):
        if "pod_id" not in self.state: raise SystemExit("no pod yet - run create")
        return self.state["pod_id"]

    @property
    def comfy(self): return f"https://{self.pod}-8188.proxy.runpod.net"

    def ssh(self, cmd, timeout=3600):
        p = api("GET", f"/pods/{self.pod}")
        ip, port = p.get("publicIp"), (p.get("portMappings") or {}).get("22")
        if not ip or not port: raise SystemExit("SSH not mapped yet")
        k = os.path.expanduser(self.cfg.get("ssh_key", "~/.ssh/id_ed25519"))
        r = subprocess.run(["ssh", "-i", k, "-p", str(port), "-o", "StrictHostKeyChecking=no",
                            "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", f"root@{ip}", cmd],
                           capture_output=True, text=True, timeout=timeout)
        if r.returncode: raise SystemExit(f"ssh failed: {r.stderr[-800:]}")
        return r.stdout


def glb_ok(data):
    if data[:4] != b"glTF": return False, "bad magic"
    if struct.unpack("<I", data[8:12])[0] != len(data): return False, "truncated"
    return True, f"{len(data)/1e6:.1f} MB"


# ------------------------------------------------------------ commands
def create(j):
    if "pod_id" in j.state: raise SystemExit(f"pod {j.state['pod_id']} already exists in state")
    c = j.cfg
    pub = Path(os.path.expanduser(c.get("ssh_key", "~/.ssh/id_ed25519") + ".pub")).read_text().strip()
    for cloud in c.get("clouds", ["COMMUNITY", "SECURE"]):
        body = {"name": c.get("name", "rpcomfy"), "imageName": c["image"], "cloudType": cloud,
                "computeType": "GPU", "gpuCount": 1, "gpuTypeIds": c["gpus"], "gpuTypePriority": "custom",
                "allowedCudaVersions": c.get("cuda", ["12.8", "12.9", "13.0"]),
                "containerDiskInGb": c.get("container_gb", 100), "volumeInGb": c.get("volume_gb", 50),
                "volumeMountPath": "/workspace", "ports": ["8188/http", "22/tcp"],
                "env": {"PUBLIC_KEY": pub}, "minDownloadMbps": c.get("min_mbps", 500)}
        if cloud == "COMMUNITY": body["supportPublicIp"] = True
        r = requests.post(REST + "/pods", json=body, headers={"Authorization": f"Bearer {key()}"}, timeout=60)
        if r.status_code < 400:
            p = r.json()
            j.state.update(pod_id=p["id"], created=time.time(), cost_hr=p.get("costPerHr"))
            j.save()
            log(f"pod {p['id']} {cloud} ${p.get('costPerHr')}/hr {(p.get('machine') or {}).get('gpuTypeId')}")
            return
        log(f"{cloud}: {r.status_code} {r.text[:300]}")
    raise SystemExit("no pod created")


def start(j):  # resume a stopped pod (volume + models kept); fails if its machine has no free GPU
    p = api("POST", f"/pods/{j.pod}/start")
    j.state.update(created=time.time(), cost_hr=p.get("costPerHr") or j.state.get("cost_hr"))
    j.save()
    log(f"started {j.pod} ${j.state['cost_hr']}/hr")


def wait(j):
    t0 = time.time()
    while time.time() - t0 < 1200:
        p = api("GET", f"/pods/{j.pod}")
        ssh_ok = bool(p.get("publicIp") and (p.get("portMappings") or {}).get("22"))
        try:
            s = requests.get(j.comfy + "/system_stats", timeout=15).json()
        except Exception:
            s = None
        log(f"ssh={ssh_ok} comfy={bool(s)} {int(time.time()-t0)}s")
        if ssh_ok and s:
            log(f"READY ComfyUI {s['system'].get('comfyui_version')} on {s['devices'][0]['name']}")
            return
        time.sleep(15)
    raise SystemExit("boot timeout - run status, then stop")


def models(j):
    sh = ["set -e", f"cd {COMFY_DIR}/models"]
    for m in j.cfg["models"]:
        f = f"{m['dir']}/{m['name']}"
        sh += [f"mkdir -p {m['dir']}",
               f"([ -s {f} ] || curl -fsSL --retry 5 -o {f}.part '{m['url']}' && mv {f}.part {f}) &"]
    sh.append("wait")
    sh += [f"stat -c '%s %n' {m['dir']}/{m['name']}" for m in j.cfg["models"]]
    got = {l.split()[1]: int(l.split()[0]) for l in j.ssh("\n".join(sh)).split("\n") if l.strip()}
    bad = [m["name"] for m in j.cfg["models"] if m.get("bytes") and got.get(f"{m['dir']}/{m['name']}") != m["bytes"]]
    if bad: raise SystemExit(f"size mismatch: {bad}")
    log(f"{len(got)} models OK")


def run(j):
    c, tmpl = j.cfg, json.loads(Path(j.cfg["workflow_api"]).read_text())
    outdir = Path(c["output_dir"]); outdir.mkdir(parents=True, exist_ok=True)
    for item in c["inputs"]:
        src = Path(item["file"])
        up = requests.post(j.comfy + "/upload/image", data={"overwrite": "true"}, timeout=120,
                           files={"image": (src.name, src.read_bytes(), "image/png")}).json()
        p = json.loads(json.dumps(tmpl))
        p[c["load_image_node"]]["inputs"]["image"] = up["name"]
        for node, kv in {**c.get("overrides", {}), **item.get("overrides", {})}.items():
            p[node]["inputs"].update(kv)
        p[c["save_node"]]["inputs"]["filename_prefix"] = f"{c.get('prefix', 'out')}/{src.stem}"
        r = requests.post(j.comfy + "/prompt", json={"prompt": p, "client_id": str(uuid.uuid4())}, timeout=60)
        if r.status_code != 200 or r.json().get("node_errors"): raise SystemExit(f"rejected: {r.text[:1200]}")
        pid, t0 = r.json()["prompt_id"], time.time()
        while time.time() - t0 < c.get("job_timeout", 1800):
            h = requests.get(f"{j.comfy}/history/{pid}", timeout=30).json()
            if pid in h: break
            time.sleep(3)
        else:
            raise SystemExit(f"{src.name}: timeout")
        h = h[pid]; msgs = {m[0]: m[1] for m in h["status"]["messages"]}
        if h["status"]["status_str"] != "success":
            e = msgs.get("execution_error", {})
            raise SystemExit(f"{src.name}: failed at {e.get('node_type')}: {e.get('exception_message')}")
        secs = (msgs["execution_success"]["timestamp"] - msgs["execution_start"]["timestamp"]) / 1000
        files = [f for v in h["outputs"][c["save_node"]].values() if isinstance(v, list)
                 for f in v if isinstance(f, dict) and "filename" in f]
        for f in files:
            d = requests.get(j.comfy + "/view", timeout=900, params=f).content
            dst = outdir / f["filename"]; dst.write_bytes(d)
            ok, info = glb_ok(d) if dst.suffix == ".glb" else (True, f"{len(d)/1e6:.1f} MB")
            log(f"{src.name}: {secs:.0f}s GPU -> {dst} {'OK' if ok else 'BAD'} {info}")
            if not ok: raise SystemExit("bad download - scp from " + COMFY_DIR + "/output instead")


def status(j):
    p = api("GET", f"/pods/{j.pod}")
    print(p.get("desiredStatus"), p.get("costPerHr"), p.get("publicIp"), p.get("portMappings"))


def stop(j):
    api("POST", f"/pods/{j.pod}/stop")
    mins = (time.time() - j.state["created"]) / 60
    log(f"stopped after {mins:.1f} min (~${mins / 60 * (j.state.get('cost_hr') or 0):.2f})")


def terminate(j):
    api("DELETE", f"/pods/{j.pod}"); j.state.pop("pod_id"); j.save(); log("terminated")


if __name__ == "__main__":
    a = sys.argv[1:]
    if a[:1] == ["convert"] and len(a) == 4:
        api_wf = convert(json.load(open(a[1])), json.load(open(a[2])))
        json.dump(api_wf, open(a[3], "w"), indent=1); log(f"{len(api_wf)} nodes -> {a[3]}")
    elif len(a) == 2 and a[0] in ("create", "start", "wait", "models", "run", "status", "stop", "terminate"):
        globals()[a[0]](Job(a[1]))
    else:
        raise SystemExit(__doc__)
```
~~~~
Page: https://promoteindex.com/skills/run-comfyui-workflows-on-runpod-gpus-pay-per-min
_Skills are submitted by users and agents and are not reviewed, tested or endorsed by PromoteIndex. Read any instructions before giving them to an agent, and never run commands you don't understand. Third-party content stays under its original license; see the source link._
