Skip to content
HN On Hacker News ↗

my server is a phone now

▲ 547 points 252 comments by seg6 2w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this entire text is AI.

100 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,631
PEAK AI % 100% · §1
Analyzed
Aug 8
backend: pangram/v3.3
Segments scanned
1 windows
avg 1631 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,631 words · 1 segments analyzed

Human AI-generated
§1 AI · 100%

For a while, my personal infrastructure lived on a small Hetzner VPS. It ran a few web apps, a remote browser called Surf, Caddy, and the usual supporting cast. Nothing particularly serious. It worked, I just didn’t like paying for it. One of the apps I run, Surf, made the compromise difficult to ignore. The cheapest shared machines were fine until Chrome had real work to do, at which point they felt starved. Dedicated CPU machines fix that, but cost enough each month to make a personal browser feel like a questionable financial commitment. Buying another machine wasn’t an appealing escape hatch either. DRAM prices have gone completely stupid, so putting together a new box with a comfortable amount of memory felt especially ill timed. I looked at used mini PCs and briefly considered turning my desktop into a server whenever I wasn’t using it. Then I remembered the CMF Phone 1 I already own. Eight ARM cores, 8 GB of RAM, 128 GB of flash, Wi-Fi 6, a 5G modem, and a built in battery backup, that is all attached to an SoC that I feel is overqualified for sitting in a drawer. And I had already paid for it. After dusting it off and playing with it for a while, I decided to turn the phone into the server. Today it runs Surf and its managed Chrome instance, my personal finance tracker, a screen sharing service, and a handful of smaller web apps. They survive reboots, deploy from Git, and remain reachable when the phone moves between networks, so this is now the machine that actually replaced the VPS. the first bad idea: replacing android The cleanest version of this idea seemed to be flashing a normal Linux distribution. The CMF Phone 1 has a postmarketOS device port, it boots, and the device page has enough green boxes to make a reckless person optimistic. I ended up being that person. What I paid less attention to was everything marked broken: Wi-Fi, Bluetooth, hardware acceleration, and most of the other things that make the phone useful as a small server. I got as far as the postmarketOS splash screen and a black display. At that point I had neither a server nor a phone. Recovering stock Nothing OS turned into its own side quest. The flashing utility needed Windows, so I installed Windows in QEMU, fought USB passthrough and MediaTek drivers, watched the flashing tool hang, then eventually moved the process to an actual Windows installation and restored the factory images. There was a moment in the middle of this where the phone was soft bircked and only showed a black screen and I genuinely thought I had converted a perfectly good device into a paperweight. It came back, and lesson learned: Android already has working drivers for every piece of this hardware. Wi-Fi, power management, the battery, the GPU, the modem, and every weird vendor detail already work. Throwing all of that away in pursuit of a more conventional userspace was the wrong trade. I didn’t actually need the phone to become a normal Linux machine. I needed it to run Linux applications reliably while Android continued doing the hardware specific work it is good at. termux is the host operating system The second attempt kept stock Android and treated Termux as the host environment. Termux gives me OpenSSH, runit, Caddy, Cloudflared, package management, and normal enough Unix tooling. Termux:Boot starts the supervisor and SSH after a reboot. Tailscale gives the phone a stable private address, so from any machine on my tailnet I can just run: ssh cmf Termux is not a virtual machine. Its processes still execute against Android’s Linux kernel, but its Bionic based userspace is different enough from an ordinary Debian installation that existing Linux application images cannot simply be dropped into it. That split ended up being useful, though: Termux could remain the small host control plane while each application brought the Linux filesystem it expected. The actual services are supervised by runit. Android’s battery management is very good at its normal job and very bad for a device pretending to be a server, so I had my Ansible build also apply an Android host profile: it installs a persistent wake lock, disables light and deep idle, exempts Termux, Termux:Boot, and Tailscale from background restrictions, disables the child process limiter, prevents Wi-Fi suspension, and configures Tailscale as the always on VPN. The recovery chain matters more than any individual setting. Android boots, always on VPN brings Tailscale back, Termux:Boot starts runit, runit starts every resident service, and health checks verify the local and public paths. The phone can reboot without waiting for me to notice. Android boot -> Tailscale always-on VPN -> Termux:Boot -> runit -> resident services -> local and public health checks This isn’t a conventional Linux server. There is no systemd, no normal Docker daemon, and no reason to pretend otherwise. But it is a Linux kernel with a very capable userland sitting on top of it, and that turns out to be enough. the second bad idea: proot Most of my applications already shipped as Linux ARM64 OCI images. proot-distro made those surprisingly easy to run under Debian without changing the applications themselves. PRoot intercepts filesystem and process operations in userspace and makes a regular Termux process believe it lives inside a Debian root filesystem. It isn’t a container boundary. Everything still shares Android’s kernel, network namespace, and Termux UID. But as an application compatibility layer, it is extremely useful because it needs neither root nor a special kernel. The ordinary web services initially ran fine this way. Each of my applications got a verified root filesystem, a loopback port, and a runit service. Caddy ran directly in Termux and routed hostnames to those ports. The performance/latency sensitive Surf browser workload was the exception. Starting processes, opening libraries, walking paths, reading browser profiles, and shuffling capture data all crossed PRoot’s userspace translation layer. There was CPU available, but Chrome could not reach it efficiently. So I rooted the phone, not to replace Android, but to mount the same Debian filesystem properly and enter it with a real chroot. Runit still owned the lifecycle from Termux, configuration still came from the same place, and application data still lived in Termux storage. The workload simply reached Android’s kernel through native syscalls instead of PRoot. The improvement was not subtle! Once that path was solid, leaving the smaller residents under PRoot stopped making much sense. They now run the same way. My workstation resolves each ARM64 image to an exact digest and exports its filesystem; Ansible verifies and installs it on the phone. A small root helper creates a private mount namespace, binds the required paths, enters the filesystem with chroot, drops privileges, and starts the original image entrypoint. Neither Docker nor a compiler needs to exist on the phone. These are still compatibility environments rather than security boundaries: the residents share Android’s kernel and network stack, while the private mount namespaces mainly keep mounts and cleanup predictable. I also spent far too long trying to bridge Debian’s graphics stack to the phone’s Mali GPU through VirGL and Android Vulkan. I got hardware compositing checkmarks alongside corrupt pages and worse performance… The boring software rendered path turned out to perform better. infrastructure! not a pile of shell history By this point the phone could run everything, but I didn’t want a pet server assembled from commands I would forget in a week. I moved the entire host into an Ansible managed state: versions, service definitions, routes, power settings, secrets, and health checks all live in one private repository. The deployment flow is roughly: release or OCI image -> checksum/digest pinned in Git -> Ansible over SSH -> versioned files on the phone -> atomic current symlink -> runit service -> local health check -> public edge check Releases are pinned by digest or checksum and installed into versioned directories behind an atomic current symlink. A failed checksum or health check stops the deployment, rollback means reverting the pin and applying again. Application data lives separately from releases. After the small manual bootstrap: install the three Android apps, root the phone, grant Termux superuser access, and authorize SSH, the same repository owns the rest. From my workstation, bringing the host to the declared state is deliberately simple: make phone make phone-status make phone-edge-check Applying it again does not replace unchanged runtime files or restart healthy residents. More importantly, the configuration is useful if this phone dies: another rootable ARM64 phone can be brought toward the same state without reconstructing a shell history. Secrets are not stored on the phone’s Git checkout, because there is no Git checkout. Ansible Vault values live encrypted in the infrastructure repository. The vault password is derived by asking my 1Password SSH agent to sign a fixed challenge, so the private key stays in 1Password and the phone never needs access to it. During deployment, Ansible renders only the runtime values each service needs into Termux’s private storage. getting traffic to a phone behind home internet The next problem to tackle was ingress. My home connection does not come with the kind of static server setup a VPS gives you, and I don’t want to expose SSH or a collection of random application ports through the router. I also wanted the phone to remain a phone in one important sense: I should be able to unplug it, take it somewhere else, connect it to the internet, and still have my server. The HTTP applications use a Cloudflare Tunnel. Cloudflared makes one outbound connection from the phone, Cloudflare sends each hostname through it, and Caddy routes the request to the correct loopback service. Internet -> Cloudflare Tunnel -> Caddy on 127.0.0.1