Surfrasurfra
Tutorial

Migrate a RunPod Serverless worker to your own cloud.

Keep your model code, your container, and the JSON your clients send. Change about six lines of the handler, rebuild the image, and run it on GPUs in your own Salad, RunPod, Vast.ai, Yotta, or DigitalOcean accounts, with RunPod still in the list if you want it.

Before you start

What changes and what stays.

RunPod Serverless runs your handler on RunPod's workers and bills you its serverless rate. Surfra runs the same kind of handler on machines it starts in your own cloud accounts, which bill you directly at their own prices.

Stays the sameChanges
Model codeEverything that loads and runs the modelNothing
HandlerOne function that takes JSON and returns JSONThe import, the decorator, and how input and errors are read
ContainerBase image, dependencies, weightsInstall surfra instead of runpod, and the start command
ClientsThe request body, {"input": {...}}The URL, the API key, and the status names
Where it runsRunPod can stay, as one row in the listOther clouds and your own GPUs can sit next to it
Step 1

Change the handler.

This is a real RunPod worker, a Hugging Face sentiment classifier, next to the Surfra version. Both load the model once per worker at import time.

handler.py (RunPod)
import os

import runpod
from transformers import pipeline

# Loaded once per worker, before any job arrives.
classifier = pipeline("text-classification", model=os.environ.get("MODEL_ID", "distilbert-base-uncased-finetuned-sst-2-english"))


def handler(job):
    texts = job["input"].get("texts")
    if not texts:
        return {"error": "send a list of texts"}
    runpod.serverless.progress_update(job, f"classifying {len(texts)} texts")
    return {"labels": classifier(texts)}


runpod.serverless.start({"handler": handler})
app.py (Surfra)
import os

import surfra
from transformers import pipeline

# Loaded once per worker, before any request arrives.
classifier = pipeline("text-classification", model=os.environ.get("MODEL_ID", "distilbert-base-uncased-finetuned-sst-2-english"))


@surfra.handler()
def handler(request: surfra.Request):
    texts = request.input.get("texts")
    if not texts:
        raise ValueError("send a list of texts")
    surfra.progress(0.5, f"classifying {len(texts)} texts")
    return {"labels": classifier(texts)}
RunPodSurfra
import runpodimport surfra
def handler(job)@surfra.handler() on def handler(request: surfra.Request)
job["input"]request.input
runpod.serverless.progress_update(job, msg)surfra.progress(fraction, msg), a fraction from 0 to 1 and a message
Return {"error": ...} to fail the jobraise an exception to fail the request
runpod.serverless.start({"handler": handler})Delete it. surfra worker finds the decorated function.
Watch for

The two differences that bite.

Errors are raised, not returned

RunPod treats a returned dict with an error key as a failed job. Surfra treats any returned value as the result, so a returned error would look like success. Raise instead. A raised error is retried, up to three attempts in total, so check input before doing expensive work.

One result per request

A RunPod generator handler can stream partial output. A Surfra handler returns one result. Use surfra.progress for updates while it runs, which callers see on the request.

Optional: a warmup function

Module-level loading works as it does on RunPod. If you would rather load in a function, decorate it with @surfra.warmup and Surfra calls it once per worker before the first request.

Step 2

Change the image.

Keep the base image and the dependencies. Swap the runpod package for surfra and start the container with surfra worker. The surfra package installs from its release on GitHub.

Dockerfile (RunPod)
FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime
RUN pip install --no-cache-dir runpod transformers
COPY handler.py /handler.py
CMD ["python", "-u", "/handler.py"]
Dockerfile (Surfra)
FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime
RUN pip install --no-cache-dir transformers \
    https://github.com/surfra-io/surfra-python-sdk/releases/download/v0.2.2/surfra-0.2.2-py3-none-any.whl
WORKDIR /app
COPY app.py .
CMD ["surfra", "worker"]

Build and push it to any registry your clouds can pull from. Private images work on Salad, Vast.ai, Yotta, and DigitalOcean with registry credentials saved in Surfra. RunPod rows need a public image. See RunPod on Surfra.

Step 3

Connect a cloud and deploy.

Connect at least one cloud account under Credentials in the dashboard, then deploy. The priority list is the order Surfra tries. Here Salad comes first and your own RunPod account is the fallback.

deploy.pyDeploy docs
import surfra

app = surfra.App("sentiment")
app.deploy(
    image="ghcr.io/you/sentiment:1",
    entrypoint="app.py",
    runtime={"engine": "python"},
    autoscale={
        "min_replicas": 0,
        "max_replicas": 4,
        "idle_timeout_sec": 300,
        "providers": [
            {"provider": "salad", "gpu": "rtx3090", "priority": 1},
            {"provider": "runpod", "gpu": "rtx4090", "priority": 2},
        ],
    },
)

With min_replicas at 0, nothing runs until the first request, and replicas stop after idle_timeout_sec of no work. That is the scale-to-zero behavior RunPod Serverless gives you, on machines your clouds bill directly.

Step 4

Point your clients at Surfra.

The request body is the same. The URL, the key, and the status names change.

client.pyCalling docs
import surfra

app = surfra.App("sentiment")
print(app.run({"texts": ["I love how simple this was.", "This is slow and frustrating."]}, timeout=600))
curl
# RunPod Serverless
curl -X POST https://api.runpod.ai/v2/$ENDPOINT_ID/run \
  -H "authorization: Bearer $RUNPOD_API_KEY" \
  -H "content-type: application/json" \
  -d '{"input": {"texts": ["I love how simple this was."]}}'

# Surfra: the same body, a different URL and key
curl -X POST https://api.surfra.io/v1/apps/sentiment/run \
  -H "Authorization: Bearer $SURFRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input": {"texts": ["I love how simple this was."]}}'
RunPodSurfra
POST /v2/{endpoint}/runPOST /v1/apps/{app}/run, which returns the transaction
GET /v2/{endpoint}/status/{id}GET /v1/transactions/{id}
COMPLETED, FAILEDsucceeded, failed
/runsyncapp.run(...) in Python, which waits for the output
How we tested this

Same input, same output.

We ran the RunPod handler with the RunPod Python SDK 1.12.0 in its local test mode, and the Surfra handler through the worker loop of Surfra 0.2.2, the release installed above. Both used distilbert-base-uncased-finetuned-sst-2-english on the same two sentences and returned the same result, to the last digit. A request with no texts came back failed with "send a list of texts" on Surfra, as intended.

output (both)
{"labels": [
  {"label": "POSITIVE", "score": 0.9998283386230469},
  {"label": "NEGATIVE", "score": 0.9996296167373657}
]}

We also built the Surfra Dockerfile on a CPU base, python:3.12-slim with the CPU build of PyTorch, which came to 1.25 GB, and started it: the model loaded and the handler registered inside the container. The CUDA image above differs only in its base.

These tests check the handler and the image, not a cloud. How fast a replica starts depends on the image size and the cloud: pulling a CUDA image with PyTorch is most of a cold start. Keeping one replica warm with min_replicas: 1 removes it for the first request.

Next

Keep going.

FAQ

Common questions

Do I have to leave RunPod?

No. Your RunPod account can be one row in the priority list, next to Salad, Vast.ai, or your own GPU. Surfra starts regular pods there, billed at RunPod's pod price.

Can I keep the RunPod endpoint URL?

Not yet. Clients change the base URL and the API key. The request body stays the same.

What about network volumes?

Surfra does not mount RunPod network volumes. Bake the weights into the image, or download them in a @surfra.warmup function from object storage any of your clouds can reach.

How long does the migration take?

The code change is the six lines above. Most of the time goes into rebuilding the image and connecting your first cloud account.

Run your RunPod worker on your own clouds.

Free on your own clouds, with no card. Team adds 30 days of logs and more seats.

Start free