A perfectly good paperweight
Or: what you can make a dead phone do if you refuse to accept that it’s dead.
There was a Xiaomi 12T Pro sitting in a drawer, doing nothing. Not broken. Obsolete in that specific way a phone becomes obsolete: the resale value had dropped to “good luck with that,” and it had already been replaced, so it stopped being my phone and became the phone in the drawer.
Throwing away a perfectly good piece of silicon felt wrong. Not using it felt worse. So: what if it ran a homelab.
It turns out that sentence is also, structurally, the entire Odyssey. More on that shortly.
The stupid options, tried first
- Termux
- A sandbox with a terminal stapled on. No real namespaces, no containers, forget it.
- proot
- Fakes a filesystem root. Half your syscalls are emulated and the other half just lie to you.
- Emulators
- Bless their hearts.
Then I found DroidSpaces: real Linux containers, real namespaces, actual hardware access. One problem. The kernel didn’t support it.
Of course it didn’t.
Rebuilding a mountain to move a pebble
CONFIG_PID_NS, along with the rest of the namespace and cgroup options a container runtime needs, was off in the shipped kernel config. Not hidden, not locked behind a permission. Off, the way a light switch is off. These are compile-time flags baked into the binary, and no amount of root access changes a value that was never there to begin with. The only fix is rebuilding the kernel from source, which on this device means rebuilding the entire OS, because none of the vendor modules come prebuilt either.
I’d already named the homelab after Odysseus, for what felt at the time like a throwaway joke: the whole project was obviously going to be a long way home through problems entirely of my own making. Finding out the hardware had already been mythologically pre-loaded, before I’d chosen anything, felt less like a coincidence and more like the universe filing a joke ahead of schedule.
- Source tree
- ~400 GB, full LineageOS checkout
- Peak build memory
- ~13 GB just for the build system
- Vendor modules
- Every one compiled from source, none prebuilt
- Cost of one flag
- A multi-hour rebuild of the entire OS
Getting there also involved a swapfile, Ubuntu’s own out-of-memory killer trying to assassinate the build on principle, and one forgotten git lfs install that quietly corrupted half the vendor blobs and waited several hours to mention it. None of that is worth dragging through in full here. It already has a home: the build toolkit is on GitHub, profanity fully intact, if you want the director’s cut with all the scar tissue still attached.
It worked, eventually. Full containers, actual namespaces, hardware access, on a 2022 flagship that was otherwise headed for a drawer of forgotten gadgets.
Four stops on the way home
Getting the kernel to boot was, comparatively, the easy part. Getting a working network out of an Android kernel that was never designed to route anything for anyone else took four separate fights, and by this point I was fully committed to the framing, so here they are as four stops on the way home, in order.
ip rule, sending that traffic to look up Android’s own table instead of the one every guide on the internet assumes exists.iptables-legacy, deliberately, every single time./sys/fs/bpf/, and that path lives on a filesystem wiped clean on every reboot. The programs survive in the kernel. The reference that lets anything else talk to them does not. Every boot runs its own small version of Penelope’s trick: what got carefully assembled the night before is quietly undone by morning, and has to be rewoven from scratch before the firewall can be told the programs exist at all.None of this is a bug, exactly. It’s all Android being extremely good at being Android, on a kernel that was never supposed to route a WireGuard tunnel out of somebody’s spare room.
And none of it is likely to be useful to another living soul, if we’re honest. The exact combination of this phone, this Android build, and these four specific decisions is about as reusable as a fingerprint. But that’s more or less the entire thesis of this post, isn’t it. A phone that looked like it was worth nothing turned out to be worth a homelab. A pile of Android routing trivia that looks like it’s worth nothing might turn out to be worth somebody else’s evening, on a project with nothing to do with phones, someday. Writing it down is cheap. Finding out it mattered is not something you get to decide in advance, which is exactly why the drawer full of things you’re “definitely never going to need again” is worth a second look before it goes in the bin.
Drawing the map
Once the routes actually worked, the shape of the thing turned out to be almost embarrassingly simple.
Internet
|
home router (NAT)
|
phone :: wlan0
192.168.1.0/24
|
+--------------+--------------+
| |
wg0 (WireGuard) every container
10.13.13.0/24 network_mode: host
| |
VPN clients DNS · media · proxy
(phone, laptop, tablet) all on the same IP
That network_mode: host line is doing more work than it looks like. The obvious choice, a Docker bridge network with its own subnet and port mapping, would have needed its own routing table and its own NAT rules, both of which would then be competing with Android’s tethering stack for the same job. Two systems, both convinced they’re in charge of the same traffic, is not a fight worth having on purpose. Host networking sidesteps it entirely: every container just listens on the phone’s one real IP, containers reach each other over 127.0.0.1, and there is exactly one thing on the box doing routing instead of two arguing about it.
The price is that every container shares one port space, so two things quietly fighting over the same port is now a spreadsheet problem instead of a networking one. A spreadsheet problem is a good trade.
Everything hears through one door
Pi-hole sits on port 53 and answers both. It doesn’t know or care whether a query arrived over Wi-Fi or through an encrypted tunnel from another continent. A query is a query. If the domain is on the blocklist, the answer is nothing, and the ad never gets far enough to load. My phone has stronger opinions about advertising than most adblockers I’ve paid for, and it formed all of them for free.
Locking what shouldn’t be open
Host networking means every container can, in principle, be reached on the phone’s one address. Most of them shouldn’t be reachable from anywhere except the two networks that are supposed to trust each other.
for PORT in 19999 5984 5006 3000; do
iptables-legacy -I INPUT 1 -p tcp --dport "$PORT" -j DROP
iptables-legacy -I INPUT 1 -p tcp --dport "$PORT" -s 10.13.13.0/24 -j ACCEPT # VPN
iptables-legacy -I INPUT 1 -p tcp --dport "$PORT" -s 192.168.1.0/24 -j ACCEPT # LAN
iptables-legacy -I INPUT 1 -p tcp --dport "$PORT" -s 127.0.0.0/8 -j ACCEPT # localhost
done
Rules get inserted in reverse order on purpose, so ACCEPT ends up ahead of DROP in the chain, the same trick the tetherctrl guard taught me two sections ago. Insert the exception before the rule it’s an exception to, or it never gets read.
What that leaves facing the actual internet is small on purpose: the WireGuard handshake port, and a reverse proxy in front of exactly one service that has its own login and its own reason to exist in public. Everything else, the dashboards, the metrics, the things with no login screen at all because they were never meant to need one, only ever sees traffic from the LAN or from inside the tunnel.
- Tenant
- 203.0.113.44
- Reason
- Five wrong passwords in ten minutes, aimed at the one thing facing the internet
- Sentence
- 24 hours, iptables-legacy, no appeal
Fail2ban watches the login failures and writes that eviction itself, straight into the same INPUT chain, no human involved. Three wrong passwords against the one public-facing login is enough. It does not care who’s asking, and it has never once apologised for its tone.
What a listening beast needs, in order to listen
The last problem wasn’t networking. It was attention span. Android throttles the CPU hard the moment the screen turns off, down to a few hundred megahertz on this device, which is sensible for a phone nobody is looking at and useless for a server that is meant to keep working at 3 a.m. A minimum frequency floor and a partial wake lock fix it. The screen is allowed to sleep. The phone is not.
A beast whose entire job is listening doesn’t get to doze off just because nobody’s watching the screen.
The same four tricks, every morning
None of the previous four sections happen once and stay fixed. They happen again, automatically, every single time the phone reboots, in order, before I’ve had the chance to notice anything went down.
Four fights, fought exactly once each, by hand, and then quietly automated into a script that refights all of them for me on every boot without complaint. That’s the actual payoff of doing this properly instead of doing it live in a terminal at midnight: you only have to win each war one time.
The lock on the tunnel
- Key exchange
- Curve25519
- Encryption
- ChaCha20-Poly1305
- Hashing
- BLAKE2s
- Handshake
- Noise protocol, IKpsk2
Session keys rotate every couple of minutes and never touch the disk, so recording the encrypted traffic today buys an attacker nothing later, even if the long-term key eventually leaks. The whole implementation is around four thousand lines. OpenVPN’s is well over a hundred thousand, mostly because it still has to negotiate which cipher to use, which is also the same mechanism that lets you negotiate your way into a weak one. WireGuard skips the negotiation. There is one way to do it, and it’s the strong way.
Ithaca, such as it is
Odysseus took ten years to get home. But the shape of the story holds regardless of the exact number: the destination was never really a place on a map. It was a working state. My stuff, under my control, doing what I actually need it to do, instead of sitting in a drawer being worth nothing to anyone, including me.
- DNS + ad blocking
- Pi-hole
- VPN
- WireGuard
- Reverse proxy + TLS
- Nginx Proxy Manager
- Password manager
- Vaultwarden
- Media server
- Jellyfin
- Downloads
- qBittorrent
- Audiobooks
- Audiobookshelf
- File browser
- Filebrowser
- Container management
- Dockge
- Budget tracking
- Actual Budget
- Metrics
- Netdata
- Brute-force defence
- Fail2ban
Twelve services. One phone. It used to hold six apps I never opened and an alarm clock I could have replaced with an actual alarm clock.
The exact public address, the exact ports, and the exact map of what’s reachable from where are staying off this page. Not because the setup is fragile, but because there is no upside to publishing a floor plan of your own front door.
One correction before I let you go: I said, a few sections back, that this took about eleven months. That was a lie, delivered in the same breath as a Greek epic and with a completely straight face, because “eleven months” sounds like the kind of number a serious odyssey should have. It took a little over two weeks. I have simply never fully recovered, and inflating two weeks of iptables-legacy into a decade-adjacent mythological ordeal felt more emotionally accurate than the calendar did. Call it artistic license. Call it two burst brain capillaries and a lingering grudge against tetherctrl. Both are true.
The phone got home. It just needed the drawer part removed first.
- MT
- shelf prefix, the same on every record
- 002
- entry number, typed in by hand
- 59F7
- first four characters of the title's MD5 hash