- Published on
It Was Not the CPU That Was Slow but the Syscall Path — Lessons From Turning a Phone Into a Server
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — an experiment that began with cancelling a VPS
- The first failure — trading away the drivers to gain Linux
- Using Termux as the host control plane
- The list of fights with Android power management
- PRoot and chroot — the cost lands on syscalls, not on the CPU
- A compatibility layer is not an isolation boundary
- Ingress — a public service made from a single outbound connection
- Reproducibility — Git, not shell history
- References
Introduction — an experiment that began with cancelling a VPS
On 4 August 2026, a post titled my server is a phone now went up on seg6.space. It is a record of moving personal services that had been running on a Hetzner VPS onto a CMF Phone 1 that had been sitting in a drawer.
The phone specs are 8 ARM cores, 8GB of RAM, 128GB of flash, Wi-Fi 6, a 5G modem, and a built-in battery. That last item matters more than it seems, because from a server standpoint it is a small uninterruptible power supply.
The reason this post is worth reading is not the result of "I ran a server on a phone" but that the author failed twice and extracted a precise lesson from those failures. The second failure in particular applies to anyone who works with Linux containers and compatibility layers.
The first failure — trading away the drivers to gain Linux
The cleanest-looking approach is to flash an ordinary Linux distribution. The CMF Phone 1 has a postmarketOS device port, it boots, and the device page has plenty of green marks.
What the author looked at less carefully were the items marked broken: Wi-Fi, Bluetooth, hardware acceleration. The result was a postmarketOS splash screen and a black display, and at that point there was neither a server nor a phone.
Recovery was not easy either. The flashing tool required Windows, so it meant installing Windows in QEMU and wrestling with USB passthrough and MediaTek drivers, and eventually restoring the factory image from an actual Windows machine. There was a stretch in the middle of soft-brick with nothing but a black screen.
The lesson the author draws is precise. Android already has working drivers for every piece of this hardware. Wi-Fi, power management, battery, GPU, modem, and all sorts of vendor-specific details. Throwing all of that away in order to get a more familiar userspace was a bad trade.
What was needed was not for the phone to become an ordinary Linux machine but for Linux applications to run reliably while Android continued to do the hardware-related work.
Using Termux as the host control plane
The second attempt keeps stock Android and makes Termux the host environment.
Termux provides OpenSSH, runit, Caddy, cloudflared, package management, and a sufficiently normal set of Unix tools. Termux:Boot starts the supervisor and SSH after a reboot, and Tailscale gives a stable private address. So from any machine on the tailnet you just connect.
ssh cmf
The structure the author points out here matters. Termux is not a virtual machine. The processes run directly on the Android Linux kernel. It is just that the userspace is Bionic-based and therefore different enough from ordinary Debian that you cannot simply drop existing Linux application images into it.
That separation turns out to be a useful structure. Termux stays a small host control plane, and each application brings its own Linux filesystem, the one it expects. Service lifecycles are managed by runit.
The list of fights with Android power management
When you use a phone as a server, the most annoying opponent is not the CPU but battery optimization. In the words of the author, Android battery management is excellent at what it is meant to do, and terrible for a device pretending to be a server.
The Android host profile that the Ansible build applies does the following.
- Install a persistent wake lock
- Disable light idle and deep idle
- Exempt Termux, Termux:Boot, and Tailscale from background restrictions
- Disable the child process limiter
- Prevent Wi-Fi suspend
- Set Tailscale as an always-on VPN
But more important than the individual settings, the author stresses, is the recovery chain.
Android boot
-> Tailscale always-on VPN
-> Termux:Boot
-> runit
-> resident services
-> local and public health checks
With this chain, the phone comes back from a reboot on its own instead of waiting for a human to notice. Self-recovery capability comes not from the sum of individual tunings but from the design of the boot path. This applies not just to a phone but to any server.
PRoot and chroot — the cost lands on syscalls, not on the CPU
This is the heart of the post.
Most of the applications the author runs were already distributed as Linux ARM64 OCI images. Using proot-distro, those images could be run inside Debian without modifying the applications.
What PRoot does is this. It intercepts filesystem and process related operations in userspace so that an ordinary Termux process believes it lives inside a Debian root filesystem. A big advantage is that it needs neither root privileges nor a special kernel.
Ordinary web services ran well this way. Each application got a verified root filesystem, one loopback port, and a runit service definition, and Caddy routes hostnames to those ports.
There was one exception: a remote browser workload called Surf. Process startup, opening libraries, path traversal, reading the browser profile, moving capture data — all of it passed through the PRoot userspace translation layer. The sentence from the author is exact: CPU was available, but Chrome could not reach it efficiently.
That sentence is worth chewing on. The monitoring dashboard shows idle CPU. Memory is available too. And yet the application is slow. Because the bottleneck is not the amount of resource but the path to reaching the resource.
The fix was not to modify the code. The author rooted the phone — not in order to replace Android but in order to mount the same Debian filesystem properly and enter a real chroot. runit still manages lifecycles from Termux and the configuration is unchanged; only the workload now reaches the kernel through native syscalls instead of a translation layer. In the words of the author, the improvement was not subtle.
Generalized, it goes like this. The cost of a compatibility layer is proportional to syscall frequency, not to the amount of computation. A computation-heavy program looping over large arrays loses almost nothing on top of PRoot. A program that opens thousands of files and keeps spawning processes collapses on the same layer. Knowing in advance which side a given workload is on is the whole of this judgement.
A compatibility layer is not an isolation boundary
After the performance discussion there is a sentence the author nails down, and this part is worth quoting.
Of PRoot he writes that this is not a container boundary. Everything still shares the Android kernel, the network namespace, and the Termux UID. As an application compatibility layer it is extremely useful, but that is all it is.
He keeps the same attitude after moving to chroot. The deployment flow is this: the workstation resolves each ARM64 image to an exact digest and extracts the filesystem, Ansible verifies it, and it is installed on the phone. A small root helper creates a private mount namespace, binds the necessary paths, enters chroot, drops privileges, and runs the original image entrypoint. The phone needs neither Docker nor a compiler.
And he adds this: these are still not a security boundary but a compatibility environment, and the private mount namespace is mainly there to keep mounts and cleanup predictable.
That distinction holds outside the phone too. Because of the habit of calling containers isolation, we often mix up what a namespace gives with what a sandbox gives. A mount namespace divides the filesystem view. It does not divide the kernel. If you intend to put a hostile workload on the same kernel and hold it back with namespaces alone, that is a problem requiring a different design.
Ingress — a public service made from a single outbound connection
A home line has no static IP, and you do not want to open SSH or application ports on the router either. On top of that, the author wanted the server to stay alive even while carrying the phone out of the house.
HTTP services use Cloudflare Tunnel. cloudflared makes one connection outward from the phone, Cloudflare pushes requests into that channel by hostname, and Caddy routes to loopback services.
Internet -> Cloudflare Tunnel -> Caddy on 127.0.0.1 -> application on 127.0.0.1
There is no inbound rule on the router. So moving the phone to a different network reconnects the tunnel and the hostnames follow along unchanged. For administrative access, Tailscale does the same job.
One service needed a different path. The Surf backend is latency sensitive, terminates its own TLS, and the old iPad that connects to it pins the server identity. A normal Cloudflare Tunnel terminates TLS at Cloudflare, which for a pinned connection is an immediate failure.
The solution was wrapping the entire Surf TLS stream inside a plain WebSocket. Cloudflare sees a WebSocket and merely forwards it, while the actual authenticated connection stays end-to-end encrypted inside it. The price is latency. Outside the house it adds roughly one more network round trip, and the iPad connection was recorded at about 60 milliseconds when first measured.
This problem always arises when a TLS-terminating proxy meets a client that pins identity. Wrapping is the standard workaround for that conflict, and the cost is one round trip.
Reproducibility — Git, not shell history
Finally, a lesson from the operations perspective.
The author did not want "a pet server assembled out of commands you will have forgotten in a week." So the entire host state is managed with Ansible. Versions, service definitions, routes, power settings, secrets, and health checks all live in a single private repository.
Verification is attached at each stage of the deployment flow.
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
A release is pinned by digest or checksum, installed into a versioned directory, and placed behind an atomic current symlink. If the checksum or the health check fails, deployment stops, and rollback is reverting the pinned value and reapplying. Application data is kept separate from releases.
The handling of secrets is also worth noting. The phone has no Git checkout, so it has no secrets either. Ansible Vault values sit encrypted in the infrastructure repository, and the vault password is derived by having the 1Password SSH agent sign a fixed challenge. The private key stays inside 1Password and the phone has no need to access it. At deploy time Ansible renders only the runtime values each service needs into a private store in Termux.
Let me carry over the caveats the author attached himself, too. He said he would not put irreplaceable data here without automatic off-device backups, said he would not treat chroot as isolation for hostile workloads, and acknowledges both that rooting widens the trust boundary and that future Android updates may create new problems. The condition of a good experiment write-up is that these caveats are written at the same size as the conclusions.
References
- my server is a phone now — seg6.space, 2026-08-04 (the hardware specs, the settings list, and the latency figure were all carried over from this post)
- PRoot project
- Termux wiki — Termux:Boot
- Cloudflare Tunnel documentation
- Hacker News discussion thread