Engineering Aug 23, 2026 · 12 min read

Turning an old OnePlus 5T into a headless Linux server

A 2017 phone with a dead battery now runs postmarketOS, rootless Podman, nginx and a Cloudflare tunnel as a 1-watt home server. The wins, the traps, and the one-line kernel bug that nearly ended it.

TL;DR — I had a OnePlus 5T (8 GB RAM, 128 GB storage, 8-core Snapdragon 835) sitting in a drawer. It now runs postmarketOS with a mainline 6.1 kernel, no GUI, reachable only over SSH, serving containers through rootless Podman and exposed to the internet through a Cloudflare tunnel. Idle draw is about 0.97 W. Getting there took six sessions, a kernel driver patch, and a lot of walking over to the phone to hold the power button.

Why a phone?

The spec sheet is the argument: 8 GB RAM, 128 GB of flash, eight arm64 cores, a built-in UPS (the battery), Wi-Fi, and it draws less than a Raspberry Pi. I already run a bigger sibling — a OnePlus 7T Pro with a Debian chroot on top of Android — so the question was whether a real Linux, with a mainline kernel and systemd, could replace the chroot approach on an older device.

The OnePlus 5T ("dumpling", msm8998) has a community postmarketOS port. That's the starting point.

The shape of the final setup

Layer What it is
OS postmarketOS edge (Alpine-based), systemd, multi-user.target — no display manager
Kernel mainline 6.1 from the pmOS msm8998 fork, plus one ath10k patch of mine
Access SSH over Wi-Fi, key-only. USB is fastboot-only; macOS can't bind the pmOS USB gadget
Containers rootless Podman 5 + Quadlet systemd user units
Edge nginx + fail2ban locally, Cloudflare tunnel for anything public
Ops a Go dashboard on the box, a "black box" flight recorder on my Mac, and one idempotent provision script

The whole configuration — packages, service masks, unit files, sysctls, the patched driver, tunnel creds — lives in a single op5t-provision.sh with check (drift detection) and apply modes. After a reflash, one command brings the server back. That script exists because a reflash once erased two sessions of hand-applied work, and the only record was a prose checklist.

Step 1: install pmOS, then throw away the GUI

The default pmOS image boots Phosh, the GNOME-based phone shell. On my unit, with touch not working, the box would hard-hang 1–10 minutes after boot: Wi-Fi gone, buttons dead, screen black, journal just stops.

I chased suspend first — masked sleep.target, set IdleAction=ignore, tried /sys/power/autosleep (which doesn't exist on this kernel). Still froze. Then a touchscreen driver patch that produced 42 kernel warnings at boot looked like the smoking gun. Removed it, disabled the driver in the kernel config. Still froze.

The persistent journal of a frozen boot finally told the story: a user graphical session being created and torn down every ~40 seconds. greetd was relaunching Phosh, Phosh failed to come up without a usable display stack, died, and greetd tried again — and that crash-loop thrashes the Adreno GPU and display pipeline in a way that hard-hangs msm8998.

The fix was to stop pretending it's a phone:

systemctl set-default multi-user.target
ln -sf /dev/null /etc/systemd/system/greetd.service   # mask, by hand

Zero GUI processes, zero crash-loop cycles, and stable uptime roughly doubled immediately. I later also powered the OLED down (bl_power) — it had been lit at max brightness 24/7 rendering a login prompt nobody reads.

One trap worth knowing: pmbootstrap config ui none silently switches the init system from systemd to OpenRC. Every systemd service enable in your custom package becomes a dead file, and you boot with no Wi-Fi and no SSH. If you build headless, also run pmbootstrap config systemd always.

Step 2: build your own image, not a generic one

Once you've decided what the device is for, stop patching the stock image by hand and build one that boots into the state you want. With pmOS this is two files and one script — and it's the part that lets me recover from any disaster with a reflash instead of a weekend.

The build machine. pmbootstrap needs Linux, so on the Mac it runs inside a Lima VM (aarch64, 4 CPU, 8 GB) with a virtiofs-shared folder that both sides see at the same path. Build scripts, patches, logs and the finished images all live in that folder.

1. A custom package for the rootfs. pmOS images are just Alpine packages, so "my configuration" is a tiny APKBUILD placed in the local pmaports/temp/ directory and passed to the installer with --add. Mine is called dumpling-wifi and it installs nothing but files:

pkgname=dumpling-wifi
pkgver=8
depends="networkmanager networkmanager-wifi openssh-server"
arch="noarch"

package() {
	# Wi-Fi connections pre-seeded so the box joins the network on first boot
	install -dm700 "$pkgdir"/etc/NetworkManager/system-connections
	cat > "$pkgdir"/etc/NetworkManager/system-connections/Ripple.nmconnection <<'CONN'
	[connection]
	id=Ripple
	type=wifi
	autoconnect=true
	...
	CONN
	# Wi-Fi power-save off (ath10k stability)
	# Baked SSH public key + AuthorizedKeysFile drop-in
	# default.target -> multi-user.target, greetd masked   (headless)
	# sleep/suspend targets masked, logind HandlePowerKey=ignore
	# journald Storage=persistent so a freeze leaves evidence
}

Everything a fresh flash needs in order to be reachable — Wi-Fi credentials, the SSH key, no GUI, no suspend, a persistent journal — is in that package. After a reflash I never touch the screen; the phone joins Wi-Fi and I SSH in.

2. A kernel aport with my changes. The device kernel is also a package (linux-postmarketos-qcom-msm8998). I copied its APKBUILD, pinned the msm8998-mainline fork to a 6.1 commit, added my patch to source=, and expressed every config decision as scripts/config calls in prepare() so they're reviewable:

_commit="b9550ca03daff5fd7664d10a045bd4ab073f81d4"   # msm8998-mainline, branch mainline/6.1
source="https://gitlab.com/msm8998-mainline/linux/-/archive/$_commit/linux-$_commit.tar.gz
	ath10k-dont-latch-rx-confused-on-partial-amsdu.patch"

prepare() {
	default_prepare
	scripts/config --disable CONFIG_WATCHDOG          # enabling it hangs boot on this device
	scripts/config --disable CONFIG_QCOM_WDT
	scripts/config --disable CONFIG_RMI4_CORE         # touch: not a server feature
	scripts/config --disable CONFIG_RMI4_I2C
	scripts/config --enable  CONFIG_QCOM_TSENS        # thermal sensors
	scripts/config --enable  CONFIG_NVMEM_QCOM_QFPROM
	scripts/config --disable CONFIG_QCOM_CPR3         # cpufreq stack: killed r3 three times
	scripts/config --disable CONFIG_ARM_QCOM_CPUFREQ_HW
	scripts/config --enable  CONFIG_NET_SCH_FQ_CODEL  # server-ish networking defaults
	scripts/config --enable  CONFIG_TCP_CONG_BBR
	scripts/config --enable  CONFIG_PSTORE_RAM        # ramoops: keep the last oops across a reset
	scripts/config --enable  CONFIG_PSTORE_CONSOLE
}

Each line is a decision I learned the hard way, and the build refuses to proceed if the resulting config disagrees with it.

3. One script that builds, verifies, and exports. This is the whole pipeline, trimmed:

pmbootstrap checksum linux-postmarketos-qcom-msm8998
pmbootstrap build    linux-postmarketos-qcom-msm8998        # kernel + dtb, ~4 min with ccache
cp dumpling-wifi-APKBUILD "$PMAPORTS/temp/dumpling-wifi/APKBUILD"
pmbootstrap -y install --password '…' --add dumpling-wifi   # rootfs image

# Verify before exporting — a wrong image costs a physical trip to the phone
CFG=$CHROOT/boot/config-*
for sym in CONFIG_QCOM_TSENS CONFIG_NET_SCH_FQ_CODEL CONFIG_TCP_CONG_BBR; do
  grep -q "^$sym=y" $CFG || { echo "missing $sym"; exit 1; }
done
for sym in CONFIG_QCOM_CPR3 CONFIG_ARM_QCOM_CPUFREQ_HW; do
  grep -qE "^$sym=[ym]" $CFG && { echo "$sym must be OFF"; exit 1; }
done
# Byte-marker check that the patched ath10k module actually made it into the rootfs
xz -dc $CHROOT/lib/modules/6.1.0/kernel/drivers/net/wireless/ath/ath10k/ath10k_core.ko.xz \
  | grep -q "partial amsdu across indications" || exit 1

pmbootstrap export                                          # boot.img → /tmp/postmarketOS-export
cp …/oneplus-dumpling.img flash/oneplus-dumpling-6.1-r4-stable.img
cp /tmp/postmarketOS-export/boot.img flash/boot-6.1-r4-stable.img

The verification block is the important bit. Every image is named by revision (6.1-r4-stable) and the previous known-good pair stays in flash/ as a rollback.

4. Flash. Power off, hold Volume Up, plug in USB — the phone is in fastboot. Two partitions matter:

fastboot flash boot     flash/boot-6.1-r4-stable.img              # kernel + dtb + initramfs, ~1 s
fastboot flash userdata flash/oneplus-dumpling-6.1-r4-stable.img  # rootfs, ~90 s
fastboot reboot

Kernel-only changes need just the boot flash, which makes iterating on config fast. Two rules: always flash boot and userdata from the same build the first time (the initramfs in boot is what finds the freshly-generated rootfs UUID), and never interrupt the userdata write — a half-written rootfs boots to an fsck screen and needs a clean wipe. Kernel modules live on userdata, so if a boot-only flash introduces a new =m option it simply won't exist on the device — build anything you need from a boot-only flash as =y.

The pattern generalises to any pmOS device: your "OS" is a kernel aport plus a config package, the build script verifies its own output, and the flash directory holds versioned, paired images. Everything below was done on top of that foundation.

Step 3: the Wi-Fi "drops" — three sessions, one latch

With the GUI gone the box still died, but now it died under network load. Copy a 15 MB file to it and the link was dead in one second. The symptoms cascaded — CTRL-EVENT-SCAN-FAILED, then ath10k firmware crashed, sometimes the modem DSP fatal — and since every failure looked the same, I spent a lot of time treating them as one bug. They weren't. There were three:

  1. The power key. logind's default HandlePowerKey=poweroff ended at least two healthy boots — the phone was getting nudged. HandlePowerKey=ignore in a logind drop-in.
  2. systemctl daemon-reload crashes PID 1. systemd 261 on this build hits an assertion in its hashmap code when it rebuilds the unit table; the box dies under a second after any reload. So: never daemon-reload, enable or mask. Write unit files, symlink them into multi-user.target.wants/ by hand, and reboot.
  3. The actual Wi-Fi bug, which is in the ath10k driver itself.

In ath10k_htt_rx_in_ord_ind() there's this:

case -EAGAIN:
        fallthrough;
default:
        /* Should not happen. */
        ath10k_warn(ar, "failed to extract amsdu: %d\n", ret);
        htt->rx_confused = true;      /* one-way latch: RX dead until reset */
        __skb_queue_purge(&list);
        return -EIO;

-EAGAIN just means an A-MSDU frame spans two in-order indications — the chain ended without a LAST_MSDU marker. That's a normal, recoverable condition, and on the WCN3990 radio in this phone it happens constantly under receive load — I counted 81 times in a single 300 MB transfer. Upstream treats the first occurrence as fatal and latches rx_confused, which permanently disables receive. Everything else — the scan failures, the firmware crash, the modem fatal — is downstream of that one latch.

I diffed the function across v6.1, v6.12 and v6.16: byte-identical. A kernel bump could never have fixed it, and I burned about four reflash cycles finding that out the hard way.

The patch is small: on -EAGAIN, drop the partial chain and return 0. The purge is mandatory — the partial chain was spliced back into list, so returning without it spins the caller forever.

Kernel Same 15.8 MB transfer
6.0 stock link dead in 1 s
6.1 stock link dead in 55 s
6.1 + patch done in 6 s, sha256 matches

A 300 MB transfer at ~55 Mbps now completes clean. The patched module is checked by byte-marker in the provision script and restored if it's ever missing, because it's the single most easily-lost piece of the build.

Step 4: make it cheap to run

The box "felt slow" and drew more than it should at idle. Measured over 90-second windows:

Metric Before After
systemd PID 1 CPU 9 % 2 %
process forks 266/min 52/min
load average 0.89 0.05
running services 27 17
idle draw ~1.17 A 248 mA (~0.97 W)

Two causes. First, the phone-shaped services — ModemManager, bluetooth, avahi, cellbroadcastd, mmsd-tng, gnome-keyring, call-audio-idle-suspend-workaround, systemd-oomd (respawning PSI monitor threads 21 times a minute on a box with 7.5 GB free) — all masked via /dev/null symlinks.

Second, embarrassingly, my own dashboard: a Go function was forking systemctl show once per service every 10 seconds, computing an uptime, and then returning "" on every code path. About 120 wasted processes a minute, each a D-Bus round trip PID 1 had to answer. One batched call fixed both the dashboard's CPU and most of systemd's.

The remaining lever I haven't pulled: there's no cpufreq driver in this kernel build, so all eight cores sit at whatever frequency the bootloader left. cpuidle works, so they sleep — they just never clock down. I tried building the CPR/cpufreq stack into a later kernel and it killed the box three times under load transitions, so the current "stable" image deliberately leaves it out.

Step 5: containers — Docker no, Podman yes

Docker installs cleanly, pulls images, and then cannot start a single container. Two kernel options are missing: CONFIG_CGROUP_BPF (runc enforces the device cgroup via eBPF on cgroup v2) and CONFIG_NFT_FIB_IPV4/NFT_COMPAT (no way to express "destination is local", so no port publishing under either firewall backend).

Rootless Podman sidesteps both without touching the kernel. An unprivileged runtime can't set a device cgroup so it skips the BPF path, and rootless networking goes through pasta in userspace, so netfilter is never involved. Port publishing, DNS, outbound, native overlay storage — all work. Idle cost is zero processes because Podman is daemonless, which keeps the power work intact.

The deploy pipeline: build on the Mac (Apple Silicon is native arm64, and the phone has no cpufreq so building there is slow), docker save | gzip (305 MB → 74 MB), scp, podman load, drop a Quadlet .container file in ~/.config/containers/systemd/, then systemctl --user daemon-reload && start.

That last line matters: user-scope daemon-reload is safe. Only the system-scope one crashes PID 1. So every app is a systemd user unit, with loginctl enable-linger so containers outlive the SSH session. No reboot, no PID 1 risk.

Step 6: exposing it — and the 502 that cost real time

A Cloudflare tunnel runs on the box as another user unit and points hostnames at local ports. The first app I put through it returned 502 about half the time while being perfectly healthy on 127.0.0.1.

Rootless Podman's pasta shows :::3100 in ss but publishes IPv4 only and resets IPv6 connections. cloudflared resolves localhost to ::1 first. So localhost:3100 in the ingress config is a connection reset; 127.0.0.1:3100 works. Always write the IPv4 literal.

The second lesson: a tunnel with connectors on two hosts round-robins between them and does not health-check the origin. If the app runs on both boxes you get real HA (I ran one app on the 5T and the 7T Pro and watched requests alternate). If it runs on only one, half your requests 502. So each HA'd app gets its own dedicated tunnel; host-specific hostnames never share one.

nginx and fail2ban went in front of the local ports as well. Two Alpine/pmOS-specific traps there: sshd logs as sshd-session.pam so the stock fail2ban filter matches nothing (fixed with a _daemon override), and there's no py3-systemd so fail2ban can't read journald — a small service bridges the sshd journal into a log file it can poll.

The thing you can't fix: zero auto-recovery

In the entire project, the device has never once come back on its own from a hang. There's no serial console. macOS can't talk to the USB gadget. The hardware watchdog can't be used — building CONFIG_WATCHDOG + CONFIG_QCOM_WDT into the kernel hangs boot at a blinking dash. systemd's CrashAction is a no-op on this build. Even a clean systemctl reboot has wedged during modem teardown and needed a physical power-cycle.

That single fact shapes everything: never debug a boot failure blind, keep a known-good reachable image, harden live over SSH where a mistake can't break boot, and budget a walk to the phone for any reboot. To cope, a script on my Mac (op5t-blackbox.sh, kept alive by launchd) tails the journal continuously, logs vitals every minute, and on every drop saves the last 120 lines and auto-classifies the incident — AMSDU-EXTRACT-FAIL, POWER-KEY-PRESS, DAEMON-RELOAD, MODEM-DSP-FATAL, NO-MARKER and so on. Most of the diagnoses above came out of that recorder, not from the device.

A late discovery from that recorder: the nightly deaths I'd filed under "hang" were the PMIC logging a power-off fault — the 7-year-old battery is at about 35 % health and the charger couldn't keep up at the old draw. That's a battery problem, not a kernel problem, and the power work in step 4 is what made it survivable.

Was it worth it?

As a server: yes. It sits on a charger, pulls a watt, runs containers, serves a public dashboard through Cloudflare, and benchmarks at roughly 2.6–3.8× slower CPU than the 7T Pro — with twice the available RAM. As a project: the phone taught me more about systemd, ath10k and rootless container networking than any Raspberry Pi would have, because the Pi would have just worked.

If you try this on a Qualcomm phone, the short version: go headless on day one, never trust "should not happen" comments in Wi-Fi drivers, use rootless Podman, write 127.0.0.1 everywhere, and put everything you do into a script before the next reflash does it for you.