DEV Community

Cover image for How I Built a Video Downloader Running on My Android Phone ($0/Month)
SDownloader
SDownloader

Posted on

How I Built a Video Downloader Running on My Android Phone ($0/Month)

A backend engineer's field notes on running a real, publicly-used web service entirely on a phone in a drawer — because every "proper" host either charged money or violated a ToS I actually wanted to keep.


The premise

I wanted to build SDOWNLOADER — a browser-based tool to download videos from Vimeo, Twitter/X, Twitch, Dailymotion, SoundCloud, TikTok, and Facebook. No app, no account, no watermark.

The catch: I had no server budget. Not "cheap VPS" budget — zero dollars. So the whole thing had to run on free tiers, with one very unconventional piece: the actual video-processing backend runs on an Android phone, in Termux, sitting on my desk.

This is the story of what broke, why, and how the final architecture holds together.

The architecture

Browser
  │  POST /api/metadata
  ▼
Cloudflare Worker    ← validates URL, rate-limits, inserts job
  │
  ▼
 Postgres    ← job queue (FOR UPDATE SKIP LOCKED)
  ▲
  │  polls directly (no server round-trip)
  │
  └── Cloudflare Worker ──HTTP──▶ Cloudflare Tunnel ──▶ nginx:8080 ──▶ Node.js
                                  (named, persistent)      (Alpine        (the actual
                                                             proot)        yt-dlp worker)
Enter fullscreen mode Exit fullscreen mode

Three Cloudflare Workers (100k free requests/day each), one free Postgres + job queue, one S3 bucket (free egress-friendly storage for the rare file that needs re-hosting), and a phone running Alpine Linux inside proot-distro inside Termux.

The phone is the backend. No VPS, no Render, no Railway. Just a device that happens to always be plugged in and on WiFi.

Problem 1: Android doesn't let you bind to port 80

Trivial once you know it, infuriating the first time you hit it: Android's kernel blocks unprivileged processes — including everything inside proot, since proot doesn't grant real root — from binding to ports below 1024. nginx -p 80 just... fails.

Fix: nginx listens on 8080. A Cloudflare Named Tunnel (cloudflared tunnel run) maps proc.yourdomain.comlocalhost:8080 from inside the phone's network, so nothing needs to be port-forwarded or exposed to the internet directly. The tunnel is outbound-only, which also means the phone never needs a static IP or open inbound port — good, because it's on residential mobile data/WiFi with neither.

Problem 2: proot-distro kills your whole process tree on exit

Every one-shot command like proot-distro login alpine -- some-command tears down everything spawned inside it the moment some-command exits — including background children you explicitly backgrounded with &. This makes running a persistent Node.js server "inside a temporary shell" a non-starter unless you understand this up front.

Fix: nohup proot-distro login alpine -- long-running-command & from the Termux side. nohup makes the whole proot session immune to hangup signals, so it survives the parent shell (and Termux itself) closing.

Problem 3: pm2's daemon mode doesn't survive proot

pm2 normally daemonizes itself and detaches — which doesn't work inside proot's constrained process model; the daemon dies with its parent. The fix is pm2-runtime, pm2's foreground/container mode, designed exactly for environments like Docker (or proot) where you want pm2's process supervision — auto-restart, memory limits, exponential backoff — without the daemonization. Wrap that in the nohup proot-distro login ... & pattern above, and it survives Termux being closed entirely.

Problem 4: musl libc doesn't retry DNS

This one cost me the most debugging time. Every time the processor restarted, the first postgres query would fail with EAI_AGAIN, then recover on the next attempt 60 seconds later. Looked like a flaky network. It wasn't.

Alpine uses musl libc instead of glibc. glibc silently retries a failed UDP DNS query a few times before giving up. musl does not — one failed query, one immediate EAI_AGAIN, no retry, ever, unless you explicitly configure options timeout:2 attempts:5 in resolv.conf. Combine that with a real startup race condition — Android hasn't always finished registering a freshly-spawned proot process for network access in the first few hundred milliseconds — and you get a DNS failure on every single restart, not just occasionally.

The fix has two layers:

  1. Bind-mount a Termux-controlled resolv.conf into the Alpine session on every start, with options timeout:2 attempts:5 forcing musl to actually retry.
  2. Run a tiny DNS warm-up loop before starting the Node process — poll nslookup against the real postgres hostname until it resolves, then exec into pm2-runtime. No more racing the OS.

Problem 5: some platforms block the network your phone is on

TikTok and Facebook return degraded or blocked responses to requests coming from IP ranges they've flagged — this affects some cloud/datacenter ranges more aggressively than residential ones, but neither is bulletproof, and the specifics change constantly on both sides.

Rather than fight an arms race on the phone itself, I moved just the metadata extraction step for those two platforms into a Cloudflare Worker. Workers execute from Cloudflare's own edge network, which behaves differently than either a residential connection or a typical datacenter block-list entry. The Worker fetches the platform's public metadata, normalizes it into a schema my downstream code already understands (mirroring yt-dlp's format shape), and hands it back to the phone — which then either redirects the user straight to a CDN URL, or downloads and re-hosts the file if the CDN link turns out to be session-bound.

This turned into a genuinely useful pattern beyond just this one problem: use the edge worker as your "clean room" fetcher for anything IP-sensitive, and keep your actual compute wherever's cheapest.

Problem 6: the "server" is also someone's phone

A phone has a battery and a data cap in ways a VPS doesn't. So the processor checks a small JSON status file — written every 30 seconds by a battery/WiFi monitor script — before starting any full download:

  • Battery under 20% and unplugged → downloads pause (metadata/stream-redirect jobs still work; only the heavier download-and-reupload path is gated).
  • On mobile data instead of WiFi → the max file size for a full download is cut roughly in half.

If the status file is missing or stale, the guard fails open, not closed — a monitoring hiccup should never be able to silently break the product for users.

What actually keeps this running

A watchdog script polls every 30 seconds: is nginx alive and answering? Is the Node processor alive? Is the tunnel actually passing traffic (not just "is the process running," but "does an end-to-end request through the tunnel succeed")? Any no, and it restarts just that component. It self-daemonizes with nohup so it survives the Termux app being swiped away, and a single flag file acts as the kill switch so stop actually means stop - including for the watchdog itself, which is trickier than it sounds when the watchdog's whole job is restarting things that go down.

The stack, for the curious

  • Frontend: SvelteKit, adapter-static, deployed to Cloudflare Pages
  • API layer: Cloudflare Workers (no cold starts, generous free tier)
  • Queue/DB: Postgres, atomic job claiming via FOR UPDATE SKIP LOCKED
  • Storage: S3 bucket for the minority of jobs that need re-hosting
  • Processor: Node.js + yt-dlp, running in Alpine Linux under proot-distro, under Termux, under Android
  • Ingress: Cloudflare Named Tunnel — outbound-only, no port forwarding
  • Monetization: a single ad-supported free tier, no subscriptions

Total infrastructure cost: $0/month. Total uptime dependency: my phone staying charged and connected, same as anyone's home router.

Why write this up

Mostly because "run your backend on a phone" sounded like a joke when I started, and turned out to be a legitimately solid free-tier architecture once each of the six problems above got solved properly instead of papered over. If you're bootstrapping something with genuinely zero budget, a phone is a surprisingly capable, always-on Linux box that most people already own.

If you want to see the result: sdownloader.online.


Questions about any specific piece — the DNS fix, the Worker-as-clean-fetcher pattern, the battery-aware queue — happy to go deeper in the comments.

Thank you! 😊

Top comments (0)