我的服务器现在是一部手机了。
My server is a phone now

原始链接: https://seg6.space/posts/phone-server/

厌倦了为难以胜任高资源需求任务的 VPS 付费,作者将一台闲置的 CMF Phone 1 改造成了个人服务器。在最初尝试安装自定义 Linux 发行版(postmarketOS)导致设备无法使用后,作者转而采用了一种更务实的策略:保留原生 Android 系统,并将其作为宿主环境。 通过利用 **Termux** 提供 Shell 工具,并利用 **root 权限**来构建原生的 `chroot` 环境,这台手机现在可以运行一套基于 Linux 的 Web 应用,其中包括一个远程浏览器后端(Surf)。为了管理服务器,作者实现了一套强大的、由 Ansible 管理的工作流,将应用程序视为不可变的制品(immutable artifacts)。 关键技术选择包括: * **基础设施:** 采用分层方法,由 Android/Termux 管理硬件和入口流量,而 `chroot` 环境负责处理应用程序负载。 * **连接性:** 使用 Tailscale 进行安全管理,并使用 Cloudflare Tunnels 进行公共入口访问,确保服务器在不同网络间切换时依然可达。 * **弹性:** 守护进程链确保服务能自动重启,而手机内置电池则充当了天然的 UPS。 最终,这台安静、自带电池且功能强大的服务器,成功用现有硬件替代了昂贵的云基础设施。

关于“**My server is a phone now**”(seg6.space)一文的 Hacker News 讨论引发了技术探讨与语言分析的结合。 **语言上的困惑:** 许多读者指出该标题在语义上存在歧义。母语使用者往往将其理解为“我的服务器现在具备了电话/通话功能”,而文章实际描述的是“我的手机现在被用作服务器”。用户建议使用更清晰的标题,如“My phone runs a server now”(我的手机现在运行着服务器)或“My server lives in a phone”(我的服务器寄宿在手机里)。 **技术可行性:** 该讨论帖为有兴趣进行类似项目的读者提供了全面的指南。主要技术要点包括: * **挑战:** 手机电池在 24/7 全天候充电时存在严重的火灾隐患;许多用户建议将充电上限限制在 80%,或使用智能插座来管理电源循环。 * **软件:** 虽然作者探索了 PostmarketOS,但最终结论认为利用安卓现有的硬件驱动更为实用。 * **替代方案:** 许多评论者认为,尽管该项目是一个有趣的实验,但对于 24/7 全天候服务器工作负载而言,专用的低功耗硬件(如无风扇 NUC 或闲置的办公电脑)能提供更好的性能、稳定性和寿命。
相关文章

原文

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

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.

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.

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.

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
  -> application on 127.0.0.1

There is no inbound router rule for those services. Cloudflared only needs an outbound connection, so if I move the phone to another network, the tunnel reconnects and the hostnames follow it. Tailscale does the same for administration. The phone’s battery can bridge the move, and the public services do not care which Wi-Fi happens to be underneath them.

The Surf remote browser backend needed a different path though. Its direct connection is latency sensitive, terminates its own TLS, and the old iPad that connects to it pins the server identity. At home, Cloudflare DDNS keeps a DNS only record pointed at the current public address and the router forwards one port to Surf. On the LAN, the iPad connects directly to the phone.

That still left roaming. A normal Cloudflare Tunnel terminates TLS at Cloudflare, which is exactly what Surf’s pinned connection does not want. The solution was to wrap the complete Surf TLS stream inside an ordinary WebSocket. Cloudflare sees and forwards the WebSocket, but the actual authenticated Surf connection remains encrypted end to end inside it.

This obviously adds latency, from outside home I usually see roughly another network round trip, and the iPad connection was around 60 ms when I first tested it. But it works through a tunnel that only needs outbound connectivity, on an operating system from 2012, without installing Tailscale on the iPad.

That was the moment the phone setup became slightly ridiculous in the best way. I left it plugged in at home, went to the office, SSHed from my MacBook through Tailscale into my workstation and the phone, and used the original iPad through the phone hosted Surf instance. The VPS was no longer, it felt liberating.

The machine has two layers. Android and Termux own the hardware, networking, ingress, and supervision. The Linux residents get the filesystem they expect and otherwise stay out of the host’s way.

layerresponsibilitypieces
Android / Termux hosthardware, networking, ingress, supervisionrunit, Tailscale, Caddy, Cloudflared, DDNS, operations dashboard
rooted Linux residentsapplication compatibility and workloadsSurf and Chrome, Finances, Screen Share, some other apps

The demanding workload is Surf, which brings a modern Chromium browser to old iPhones and iPads. The phone runs desktop Chrome (recent arm64 release of Chrome) and the Surf backend inside its Debian runtime, an iPad mini receives H.264 video and audio while sending touch, keyboard, tab, and navigation commands back. Surf is why I cared so much about syscall overhead and latency in the first place.

Alongside it, the phone hosts my personal finance tracker. It records recurring income and expenses, one time transactions, monthly spending limits, and projects my balance forward day by day. Unlike the replaceable application artifacts, its SQLite database contains state I care about, so it has automated off device backups and a tested restore path.

None of those names are baked into some grand “phone server framework.” A new resident is another immutable artifact, a runit definition, an explicit data contract, a health check, and optionally a Caddy route.

The latest addition is observability. I can still SSH in, inspect runit, tail logs, check public routes, and query Android directly, but I no longer have to do that merely to see what’s going on with the machine at a glance: One native service collects CPU usage for all eight cores, memory, storage, uptime, battery, thermals, local and public reachability, and every discovered runit resident. It keeps bounded history and serves an embedded Vue interface at https://dash.cmf, reachable only over the LAN or tailnet.

dashboard

The log view discovers the same service directories at runtime, so adding another resident doesn’t require teaching the UI its name. It is much nicer than SSHing in just to remember which thing was noisy.

If you already have a reasonably modern, rootable ARM64 phone sitting unused, this is much less ridiculous than it sounds. You get quiet hardware, low power consumption, flash storage, Wi-Fi, a built in display for recovery, and a battery that behaves like a tiny UPS. Stock Android already supports the hardware, while Termux and a rooted chroot are enough to run a surprising amount of normal Linux software.

I would not put irreplaceable data on one without automated off device backups, and I would not treat the chroots as hostile workload isolation. Rooting expands the trust boundary, Android remains an unusual server host, and software rendered desktop Chrome is not going to beat a dedicated workstation with a GPU.

But for a handful of personal services, especially when the alternative is paying indefinitely for a VPS that is either slow or annoyingly expensive, it is a genuinely useful option rather than only a stunt.

The phone is still a weird server. It shares one kernel, Android occasionally needs to be reminded not to “optimize” it, and a future Android update could always create a new surprise.

But it is quiet, battery backed, fast enough, reachable from anywhere, reproducible from Git, and already sitting in my house. I started this trying to save money on a VPS. I ended up with a rooted phone running my personal infrastructure, and somehow that is much more satisfying. :)

联系我们 contact @ memedata.com