Skip to content
October 12, 2026

Running a backend on a spare Android phone

A 2018 phone, Termux, Cloudflare Tunnel and a push-to-deploy webhook: how Vithos gets an always-on API without a monthly VPS bill.

InfrastructureNode.jsAndroid

Vithos needed a backend that stays up. A VPS is the obvious answer, but for a side project it is also a monthly bill for something that mostly idles. A spare 2018 Android phone was sitting in a drawer: it draws a few watts, has a battery that works as a built-in UPS, and can run a Node.js process under Termux. The catch is that it sits behind a home router, probably on CGNAT, and Android would very much like to stop background apps.

A public HTTPS URL without opening ports

The phone cannot accept inbound connections. Cloudflare Tunnel solves that by holding an outbound connection from the phone to Cloudflare's edge; the edge forwards requests for a public hostname to localhost on the phone. No port forwarding, no public IP, and it works behind CGNAT. The tunnel runs in a tmux session next to the API.

cloudflared tunnel run vithos-api

Push to deploy

Deploys are a GitHub Actions job. On a push to main, the workflow POSTs to a small webhook on the phone through the same tunnel. The webhook checks a shared secret with a constant-time comparison, runs the deploy script, and returns the script's exit status and output.

set -euo pipefail
git pull --ff-only
pnpm install --frozen-lockfile
pnpm build
tmux send-keys -t vithos-api C-c
tmux send-keys -t vithos-api "pnpm start" Enter

Because set -e stops on the first failure and the script builds before it restarts, a broken install or build leaves the running version untouched. The webhook returns a non-2xx when the script fails, so the CI job fails too, which is the part most toy deploy scripts get wrong.

The Go binary that Android killed

The first webhook was adnanh/webhook, a single Go binary that maps HTTP requests to commands. It crashed on the phone with SIGSYS. The Go runtime spawns processes through clone and fork paths that Android's syscall restrictions block, so the binary died as soon as it tried to run the deploy script. Rather than fight seccomp, I replaced it with a dependency-free Node.js server that lives in the repo.

Surviving reboots, Doze and everything else

  • tmux keeps the API, tunnel and webhook in named sessions, so SSH-ing in shows the same state every time.
  • Termux:Boot takes a wake lock and recreates the three sessions after a reboot.
  • SSH is only reachable over Tailscale, never through the public tunnel.
  • A /stats endpoint reports battery, storage, memory and load, and UptimeRobot polls the public health check.

What I'd tell someone trying it

It is not a production substitute. A single phone is a single point of failure, Doze and battery health are real, and one bad kernel update can take the whole thing down. But for a side project that needs a real, always-on HTTPS backend for roughly the cost of electricity, it has been surprisingly boring in the best way: the interesting failures happened once, during setup, and the deploy loop has been quiet since.

The best infrastructure is the kind you forget is running on a phone in a drawer.