<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SDownloader</title>
    <description>The latest articles on DEV Community by SDownloader (@sdownloader_001).</description>
    <link>https://dev.to/sdownloader_001</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4033852%2Fa96ae7ec-5426-467b-a108-9b31bfa29222.jpg</url>
      <title>DEV Community: SDownloader</title>
      <link>https://dev.to/sdownloader_001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sdownloader_001"/>
    <language>en</language>
    <item>
      <title>How I Built a Video Downloader Running on My Android Phone ($0/Month)</title>
      <dc:creator>SDownloader</dc:creator>
      <pubDate>Fri, 17 Jul 2026 12:38:44 +0000</pubDate>
      <link>https://dev.to/sdownloader_001/how-i-built-a-video-downloader-running-on-my-android-phone-0month-38l</link>
      <guid>https://dev.to/sdownloader_001/how-i-built-a-video-downloader-running-on-my-android-phone-0month-38l</guid>
      <description>&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The premise
&lt;/h2&gt;

&lt;p&gt;I wanted to build &lt;a href="https://sdownloader.online" rel="noopener noreferrer"&gt;SDOWNLOADER&lt;/a&gt; — a browser-based tool to download videos from Vimeo, Twitter/X, Twitch, Dailymotion, SoundCloud, TikTok, and Facebook. No app, no account, no watermark.&lt;/p&gt;

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

&lt;p&gt;This is the story of what broke, why, and how the final architecture holds together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;proot-distro&lt;/code&gt; inside Termux.&lt;/p&gt;

&lt;p&gt;The phone &lt;strong&gt;is&lt;/strong&gt; the backend. No VPS, no Render, no Railway. Just a device that happens to always be plugged in and on WiFi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Android doesn't let you bind to port 80
&lt;/h2&gt;

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

&lt;p&gt;Fix: nginx listens on &lt;code&gt;8080&lt;/code&gt;. A Cloudflare Named Tunnel (&lt;code&gt;cloudflared tunnel run&lt;/code&gt;) maps &lt;code&gt;proc.yourdomain.com&lt;/code&gt; → &lt;code&gt;localhost:8080&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: &lt;code&gt;proot-distro&lt;/code&gt; kills your whole process tree on exit
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  Problem 3: pm2's daemon mode doesn't survive proot
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;pm2-runtime&lt;/code&gt;, 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 — &lt;strong&gt;without&lt;/strong&gt; the daemonization. Wrap that in the &lt;code&gt;nohup proot-distro login ... &amp;amp;&lt;/code&gt; pattern above, and it survives Termux being closed entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: musl libc doesn't retry DNS
&lt;/h2&gt;

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

&lt;p&gt;Alpine uses musl libc instead of glibc. glibc silently retries a failed UDP DNS query a few times before giving up. &lt;strong&gt;musl does not — one failed query, one immediate &lt;code&gt;EAI_AGAIN&lt;/code&gt;, no retry, ever&lt;/strong&gt;, unless you explicitly configure &lt;code&gt;options timeout:2 attempts:5&lt;/code&gt; in &lt;code&gt;resolv.conf&lt;/code&gt;. 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 &lt;em&gt;every single restart&lt;/em&gt;, not just occasionally.&lt;/p&gt;

&lt;p&gt;The fix has two layers:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Problem 5: some platforms block the network your phone is on
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc237x9aribwph7daa784.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc237x9aribwph7daa784.png" alt=" " width="720" height="880"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Rather than fight an arms race on the phone itself, I moved just the &lt;strong&gt;metadata extraction&lt;/strong&gt; 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 &lt;code&gt;yt-dlp&lt;/code&gt;'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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Problem 6: the "server" is also someone's phone
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  What actually keeps this running
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;nohup&lt;/code&gt; so it survives the Termux app being swiped away, and a single flag file acts as the kill switch so &lt;code&gt;stop&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, for the curious
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  Why write this up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see the result: &lt;a href="https://sdownloader.online" rel="noopener noreferrer"&gt;sdownloader.online&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thank you! 😊
&lt;/h2&gt;

</description>
      <category>termux</category>
      <category>android</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
