I have a dubbing pipeline that needs about 5 GB of VRAM and forty minutes to process a five-minute video. I do not have a GPU.
What I do have is Kaggle's free tier: one Tesla T4, 16 GB of VRAM, thirty hours a week, and a hard stop when the session ends. That's genuinely generous, and it comes with an awkward shape. Kaggle is built for notebooks — you sit there, you run cells, you look at outputs. My pipeline isn't a notebook. It's a web app with an upload button, and I wanted to open it from a browser, including my phone, without babysitting a notebook tab for forty minutes.
So the machine is free and remote, the app needs a public URL, and the session will die whether or not I'm finished with it. This is the rig I ended up with: a tunnel out of Kaggle, a public message channel for talking to a machine that can't be reached, and a kill switch so a runaway session doesn't eat my weekly quota.
01The three problems, in the order they bite
One: nothing on Kaggle is reachable from outside. The container has internet out. Nothing can reach in. If I start a web server on port 8501 inside that container, the port exists only inside the container. There is no address to give a browser.
Two: there's no interactive terminal. Kaggle will run a script headlessly, which is what I want — it means the job survives me closing the laptop. But then I have no way to ask it how it's doing. Its logs are inside a container I can't reach, which is problem one again wearing a different hat.
Three: the session ends when Kaggle decides. Not when I finish. Anything I want to keep has to be pulled out before then, and anything that hangs is burning a quota I can't top up.
Each has a small, unglamorous fix. Together they make a free T4 behave like a machine I own.
02Getting out: the tunnel
A tunnel inverts the direction of the connection. Instead of the outside world dialling in — which the container's network won't allow — a process inside the container dials out to a public relay and holds that connection open. The relay is now reachable from anywhere, and it forwards whatever arrives down the pipe that's already established. You have not opened a port. You have made an outbound call and left it off the hook.
Cloudflare runs one of these that needs no account and no configuration. Download the binary, point it at your local port, and it prints a public URL:
subprocess.Popen(
["./cloudflared", "tunnel", "--url", "http://localhost:8501"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
Somewhere in its output is a line like https://random-words-here.trycloudflare.com. That URL is now serving the Streamlit app running on the T4, and it works from any browser, including a phone. Two things worth knowing: the URL is random and temporary (it changes every run), and it is genuinely public — anyone with the URL reaches your app, with no password. For a throwaway session dubbing my own test videos that's an acceptable trade; for anything with real data it is not, and the fix is Cloudflare's authenticated tunnels.
03Getting messages back: a public channel
Now the app is reachable, but I still can't see the machine. The tunnel URL is printed in logs I have to open Kaggle to read, which defeats the point of running headlessly. The fix is a piece of infrastructure I keep being surprised more people don't use. ntfy.sh is a public message channel addressed by a name you invent. You POST to a URL, and anyone reading that URL gets the message. No account, no keys, no setup.
def send_log(message: str):
"""Broadcast a status message to your ntfy.sh channel."""
try:
requests.post(
f"https://ntfy.sh/{NTFY_CHANNEL}",
data=f"[DUBBING-GPU]: {message}".encode("utf-8"),
timeout=10,
)
except Exception:
pass
print(f"[LOG] {message}", flush=True)
Note the bare except. This is a status broadcast, not a critical path — if ntfy is down or the network hiccups, the pipeline must carry on regardless. It also still prints locally, so nothing is lost. A logger that can take down the job it's logging is worse than no logger. Then, on my laptop or phone, in one command:
curl -s ntfy.sh/YOUR_CHANNEL/raw
That streams the pipeline's progress live. The tunnel URL arrives that way too — the script scrapes it from cloudflared's output and broadcasts it, so the app announces its own address the moment it's ready. I never open Kaggle.
04Getting it to stop: the kill switch
The messages so far go one way. But ntfy channels are readable and writable by anyone who knows the name, which means the same channel is a control channel. The script subscribes to its own channel and watches for a keyword:
curl -d "SHUTDOWN_DUBBING" ntfy.sh/YOUR_CHANNEL
The script sees SHUTDOWN_DUBBING and exits cleanly. This matters more than it sounds. Kaggle's GPU quota is thirty hours a week and a hung session keeps consuming it. Before the kill switch, "the job wedged and I noticed three hours later" was a real and recurring cost. Now it's a curl command from wherever I am.
05The bug that took the longest
None of the above is what cost me the most time. This was. transformers version 4.35 changed how large models get loaded. The old way allocated all the memory, then filled it with weights. The new way first builds the model as a meta tensor — a description of the model with no memory behind it, just the shape and type of every parameter — and only then allocates and loads. It's much better: you never need the memory twice, and you can inspect a model too large to instantiate.
It also breaks any code that assumes a freshly constructed model contains real numbers. Move a meta tensor to the GPU and you don't get weights, you get an error, because there was never anything there to move. The vocoder inside IndicF5 did exactly that. The fix is one line of ordering — load it onto the CPU first, where the weights are actually materialised, and only then move it:
# Step 1: Load Vocoder to CPU first (avoid meta-tensor error with transformers >= 4.35)
The awkward part is where that line has to go. IndicF5 ships its own model.py, which Hugging Face downloads into a cache directory whose path contains the model's commit hash. So the file I need to patch doesn't exist until first load, and its location isn't known ahead of time. The script triggers the download, finds the file by pattern, and overwrites it with a patched copy:
patterns = glob.glob(os.path.expanduser(
"~/.cache/huggingface/modules/transformers_modules/ai4bharat/IndicF5/*/model.py"
))
That * is the commit hash, and it's the whole reason for the glob. I'll flag this because I got it wrong in a different project and wrote about it there: a glob that matches nothing fails silently. It returns an empty list, the loop body never runs, and everything downstream looks fine until it doesn't. Log what you matched, every time, and log it loudly when the answer is nothing.
06What the whole thing looks like
Seven steps, all in one headless script pushed with the Kaggle CLI:
kaggle kernels push -p . --accelerator NvidiaTeslaT4
| Step | What it does | Time |
|---|---|---|
| 0 | Read API keys from Kaggle Secrets | 5 s |
| 1 | Install system packages (ffmpeg, espeak-ng, fonts) | ~1 min |
| 2 | Install Python packages, then f5-tts, then IndicF5 | ~8 min |
| 3 | Write all pipeline source files into /kaggle/working | 10 s |
| 4 | Patch IndicF5's model.py | ~2 min |
| 5 | Launch Streamlit on port 8501 | — |
| 6 | Start the Cloudflare tunnel, broadcast the URL | ~30 s |
| 7 | Stream the channel, watching for the kill signal | — |
About twelve to fifteen minutes from push to a working public URL, most of it pip. Two details that only show up once you've run it a few times: secrets go in Kaggle's secrets store, not the script — they arrive as environment variables and never touch the source — and persistence is set to "files only", which keeps /kaggle/working between runs, so the dubbed output survives long enough to download even if the session ends unexpectedly.
07What I'd change
The pip step is eight of the fifteen minutes, repeated every run, installing identical packages. A Kaggle Dataset containing pre-built wheels would cut it to about two. I haven't done it, and every single run pays for that.
The tunnel URL should be authenticated. Right now the app's only protection is that its address is random and short-lived. That's security by nobody-guessing, which is not security. Named Cloudflare tunnels support real access control and I should move to them before this ever handles anything but my own test clips.
The kill switch only stops the script, not the Kaggle session — the container keeps running until Kaggle reclaims it, which means quota keeps draining after the job is dead. Calling the Kaggle API to cancel the kernel would close that gap.
The pattern generalises past dubbing, which is why I think it's worth writing down. Any GPU workload you want to drive interactively but can't afford to host is the same three problems: get a URL out, get status back, get a way to stop it. A tunnel, a public channel, and a keyword. None of the pieces are clever. Together they turn thirty free hours a week into something that behaves like infrastructure.
The transferable lesson
- Any un-hostable GPU workload reduces to three problems: get a URL out, get status back, get a way to stop it.
- An outbound tunnel beats trying to open an inbound port you're not allowed to open.
- A logger that can crash the job it logs is worse than no logger — broadcast on a bare
except. - A glob that matches nothing fails silently. Log what you matched, loudly when it's nothing.