Why bother
I had a drawer phone with a working battery and a dead purpose. It has an eight core ARM processor, 64GB of storage and a UPS welded to the motherboard. That is a better spec sheet than plenty of things people pay monthly for.
The real reason is that hosting something yourself forces you to understand the parts you normally get to ignore. Process supervision, DNS, TLS termination, ingress, deployment, what happens when the machine reboots. A hosting provider hides all of that. A phone hides none of it, because the phone is actively trying to kill your processes to save battery.
The build
-
A Linux environment on Android
Termux gives you a package manager and a real shell without rooting the device. Install it from F-Droid, not the Play Store, where the build has been stale for years.
pkg update && pkg upgrade pkg install openssh nginx
-
Remote access over SSH
Android blocks ports below 1024 for unprivileged apps, so sshd listens on 8022 rather than 22. That single constraint shapes every other decision on this page.
passwd # set a password, temporarily sshd # silence means it started ip addr show wlan0
From my laptop, with the local IP that returned:
ssh -p 8022 192.168.0.168
-
Keys, then no passwords at all
A password on a network-reachable SSH daemon is a matter of time. Keys first, then turn password auth off entirely so there is nothing to guess.
ssh-keygen -t ed25519 ssh-copy-id -p 8022 192.168.0.168
Then in
$PREFIX/etc/ssh/sshd_config, setPasswordAuthentication noand restart:pkill sshd && sshd
-
Serving the site
nginx on Termux defaults to 8080, which is already above the privileged range. Point the root at a directory in the Termux home and it works without further coaxing.
mkdir -p ~/www nginx curl -I localhost:8080
A
200 OKat this point means the hard part is done. Everything after this is about reaching that port from outside the house. -
Deploying
No CI, no git hooks. The site is one static file, so deployment is one copy over the SSH connection that already exists.
scp -P 8022 index.html 192.168.0.168:www/
Uppercase
-Pfor scp, lowercase for ssh. The two tools disagree on this and always will. -
Reaching it from the internet
Port forwarding was never an option. It puts a residential IP in public DNS and points the internet at a device I am not going to patch every Tuesday. A Cloudflare Tunnel inverts the direction: the phone makes an outbound connection and holds it open.
pkg install tur-repo && pkg install cloudflared cloudflared tunnel login cloudflared tunnel create phone-site cloudflared tunnel route dns phone-site vinayak-agarwal.uk
The tunnel's own config, at
~/.cloudflared/config.yml, maps the hostname onto the local nginx port:tunnel: phone-site credentials-file: /data/data/com.termux/files/home/.cloudflared/<id>.json ingress: - hostname: vinayak-agarwal.uk service: http://localhost:8080 - service: http_status:404
The last rule has no hostname, so it catches everything. It has to stay last, because matching runs top to bottom.
-
Surviving Android itself
This is the stage that separates a demo from a server, and it took longer than everything above it combined. Android dozes background processes, and Termux is background the moment the screen turns off.
termux-wake-lockis what stops the site going dark at three in the morning.The documented answer is Termux:Boot, which runs anything in
~/.termux/boot/at startup. On this handset it never fires at all, for reasons covered below, so process supervision had to come from somewhere else.What actually works is a watchdog on a cron schedule. Every five minutes it checks each service and restarts what is missing, logging every intervention:
*/5 * * * * $HOME/watchdog.sh # and inside watchdog.sh: alive=0 [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null && alive=1 if [ "$alive" -eq 0 ]; then echo "$(date '+%F %T') restarting tunnel" >> $LOG nohup cloudflared tunnel run phone-site >> $HOME/tunnel.log 2>&1 & echo $! > "$PIDFILE" fi
Writing the PID to a file and testing it with
kill -0is deliberate. Signal zero sends nothing and only asks whether the process exists, which turned out to be the one liveness check on this platform I could trust. Reboots are handled separately by a Termux:Widget shortcut on the home screen, and the watchdog restorescronditself so each mechanism covers the other's gap.
What broke
The tutorial version of this project is forty minutes. The real version was not, and the gap between them is the interesting part.
-
A watchdog that could never fire
pgrep -f "cloudflared tunnel run" → exit 0, with the tunnel deadThe watchdog checked whether the tunnel was alive before restarting it. The check passed every single time, including when nothing was running, so the restart branch was unreachable and the watchdog had never once done its job. The reason is that
pgrep -fscans full command lines, and the string it was searching for was sitting in its own command line. It was finding itself.The obvious fix made it worse.
pgrep -x cloudflaredreturns nothing for a processpslists plainly, so the two tools disagreed about reality. I only found the real state by cross-checking them against each other. Both flags were unusable, which is why the working version tracks a PID it wrote down itself rather than asking the system to find one. -
Nine tunnels to the same hostname
ps aux | grep cloudflared → 9 live processes, 45MB eachWhile the liveness check was broken, a cron entry had been launching a fresh tunnel every five minutes. Cloudflare load balances across duplicate connections, so nothing looked wrong from outside. The site stayed up, quietly consuming 400MB on a device with four gigabytes.
Two lessons. A guard that silently fails open produces a worse failure than no guard at all, because the symptom is resource exhaustion hours later rather than an error at the point of the mistake. And putting shell logic directly in a crontab line is a bad idea: the backgrounding operator interacted with the conditional in a way that never short-circuited. All the logic now lives in a script where the shell behaves predictably.
-
A boot broadcast that never arrives
The whole startup design rested on Termux:Boot running a script at power-on. It never ran a single line of it. Not a permissions problem, not line endings, not battery optimisation. The manufacturer's Android layer simply does not deliver the boot broadcast to the app on this handset, and no combination of settings changed that.
The diagnostic that settled it was one line writing a timestamp to a file as the very first thing the script did. An empty file distinguishes "never ran" from "ran and was killed", and those two have completely different fixes. I had been treating them as the same problem for some time.
-
A redirect that downgraded HTTPS
Location: http://vinayak-agarwal.uk:8080/phone-server/Requesting a directory without a trailing slash makes nginx redirect to add one, and it built that redirect from its own listening port rather than the hostname the request arrived with. Visitors got bounced from the tunnel onto plain HTTP on a port that is not publicly reachable, with a browser warning attached.
The fix is
absolute_redirect off, which emits a relativeLocationso the browser keeps the scheme and host it already had. This is a general hazard of terminating TLS somewhere other than the origin: the origin does not know how it is being reached, so any URL it constructs from its own configuration will be wrong. -
A placeholder that looked like a path
Tunnel credentials file '.../<TUNNEL-ID>.json' doesn't exist or is not a fileI copied the config template and left the placeholder in. The error was clear in hindsight, but it reads like a missing file rather than an unsubstituted variable, so I went looking for the credentials before I went looking for the typo.
cloudflared tunnel ingress validatecatches this before you run anything. -
Deploying the site onto itself
rsync: link_stat "/data/data/com.termux/files/home/index.html" failedI was SSHed into the phone and ran the deploy command there, so it looked for the file on the phone and was about to copy it to the phone. Easy to do when two shells look identical. The prompt is the only thing telling you which machine you are on, which is an argument for making them look different.
-
Everything tied to a session dies with it
I hit this twice. First with the tunnel running in the foreground, where closing the laptop lid took the website down even though nothing on the phone had crashed. Then again with a status endpoint driven by a
while trueloop started by hand, which stopped writing the moment that shell ended and left a JSON file on the site quietly reporting fifteen hour old numbers as if they were current.The second one is the more dangerous shape of the bug, because a stale endpoint looks healthy. Anything expected to outlive a terminal now runs from cron, which restarts after crashes and does not care whether anyone is logged in.
-
Windows does not ship rsync
'rsync' is not recognized as an internal or external commandEvery guide assumes a Unix client. The built-in OpenSSH client on Windows covers
sshandscpbut nothing else, and for a single static file scp is the right tool anyway.
Security posture
An old phone on a home network is a bad thing to point the internet at carelessly. The design assumes the device will eventually be compromised and limits what that would cost.
Nothing dials in
No forwarded ports on the router. The only inbound path is the tunnel, which the phone itself opened.
SSH stays local
Port 8022 is reachable on the LAN only, with password authentication disabled and key auth required.
Static files only
No database, no server-side language, no user input. There is no application logic to exploit, only bytes on disk.
Nothing of value on the device
The phone holds this website and nothing else. If it were taken over tomorrow the loss would be a public HTML file.
The origin is hidden
DNS resolves to Cloudflare, never to my home IP, so the phone is not directly addressable or scannable.
Filtering sits in front
Rate limiting and DDoS absorption happen at the edge, well before anything reaches a handset with 4GB of RAM.
Honest limitations
- Keeping a lithium battery pinned at 100% degrades it. On a phone I was not using this is an acceptable trade, but it is a real cost rather than a free lunch.
- Android will still kill Termux under memory pressure. The wake lock and the watchdog make recovery automatic, not unnecessary.
- Recent Android versions deny unprivileged reads of
/proc/loadavgand/proc/meminfo, so the usual system metrics are unavailable. Monitoring here measures service behaviour instead: local response time, free disk, round trip latency to the edge. - Residential upstream bandwidth is the ceiling. Static files behind a caching edge make that a non-issue here, and would not survive anything heavier.
- This is a single point of failure sitting on a shelf. It is a demonstration, not an argument for hosting production systems this way.
What I took from it
Most of my background is in models and data pipelines, where the infrastructure is something another team owns. Building this end to end put me on the other side of that boundary, and a few things stuck.
Constraints produce better designs than freedom does. I could not open a port, so I learned how outbound tunnelling works, and the result is more secure than the approach I would have taken with no restrictions at all. The privileged port limit forced the same kind of thinking one layer down.
The harder lesson was that a check which silently passes is worse than no check. The broken watchdog and the frozen status endpoint were the same failure wearing different clothes: both reported health while doing nothing, and both went unnoticed for hours because the signal I was reading was the thing that had failed. Anything that monitors now records what it did, with a timestamp, so absence of activity is visible rather than indistinguishable from stability.
It also rhymes more than I expected with the network fault work I do day to day. Both are exercises in reasoning about a system you cannot see directly, working out which layer failed from the shape of the symptom. A 502 through the tunnel means cloudflared is fine and nginx is not. That is the same deduction as isolating a fault to the last mile rather than the exchange, and in both cases the instrument is sometimes the thing that is lying to you.