This website is served from a phone

Not a metaphor and not a virtual machine. An old Android handset sitting on a shelf, running nginx, answering every request you have made to this domain.

Battery
Temperature
Serving for
Memory free
Requests today
Open router ports
Zero

Reading the device now.

Your browser anywhere Cloudflare edge TLS ends here cloudflared on the phone nginx :8080 localhost only index.html ~/www outbound only the phone dials out to Cloudflare, so nothing dials in to the phone
The tunnel is the whole trick. The phone opens the connection to Cloudflare and keeps it alive, which means my home router has no forwarded ports, my residential IP never appears in DNS, and TLS is somebody else's problem.

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

  1. 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
  2. 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
  3. 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, set PasswordAuthentication no and restart:

    pkill sshd && sshd
  4. 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 OK at this point means the hard part is done. Everything after this is about reaching that port from outside the house.

  5. 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 -P for scp, lowercase for ssh. The two tools disagree on this and always will.

  6. 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.

  7. 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-lock is 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 -0 is 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 restores crond itself 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.

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

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.

The rest of my work

Machine learning, multi-agent planning and the data engineering underneath both.

Back to the portfolio