PostgreSQL refused to start with
FATAL: could not create shared memory segment: Function not implemented. The Android kernel my "server" runs on simply doesn't implement System V shared memory. This is the story of how a ~200-line userspace shim, ported from Bionic to glibc, unblocked an entire self-hosted photo server running on a phone that used to live in a drawer — for €0.
The goal: quit Google Photos, spend nothing
I wanted out of Google Photos: a self-hosted service, full ownership of my data, no subscription, no commercial analysis of my library. The obvious candidate is Immich : the most polished open-source Google Photos alternative (timeline, albums, face recognition, semantic search, automatic mobile backup).
The constraint I set myself: zero budget. No Raspberry Pi, no NAS, no VPS. Only hardware I already owned.
What I had lying around:
- A Nothing Phone (1) (Snapdragon SM7325, Linux kernel 5.4, 12/256 GB) that I no longer used.
- A 500 GB external USB hard drive.
- A USB-C dock (USB hub + Ethernet + power-delivery input).
- My Linux desktop (for backups and heavy build work).
- A phone I actually use, as the source of photos to back up.
The bet: a smartphone is a complete, low-power ARM computer with a built-in battery - a free UPS. Turned into a server, it ticks a lot of boxes. As long as you can tame Android.
The whole "data center": one drawer phone, one dock, one disk.
The architecture, in one sentence
Android (rooted with Magisk) hosts a Debian chroot on the external disk, in which Immich runs compiled natively for ARM, supervised by supervisord and reachable remotely over a WireGuard mesh VPN.
A few structural decisions worth calling out:
-
chroot instead of a container. Docker fell over quickly — but not for the reason you'd first assume (details below). The kernel doesn't expose the
devicescgroup, and the cgroup v2 fallback is closed too. So I dropped the container path entirely and installed Immich natively in a Debian chroot. -
Everything on the external disk. The Debian rootfs, the database, the photos, the caches: all of it lives on the 500 GB disk (
/mnt/serverdata), never on the phone's internal storage. -
Mount by LABEL, never by
/dev/block/sdX. The device name slides on every reconnect (sdg→sdh…). Mounting by label (serverdata) is the only robust option. - supervisord as init. Android has no systemd, and neither does the chroot. supervisord runs and restarts the 6 services in a defined priority order.
Step zero: unlock the bootloader and root with Magisk
None of this works without root. The sensitive part is patching the boot.img of the exact firmware version installed — a mismatched boot.img gives you a bootloop. You read the version off the device, fetch the matching stock boot.img from the community firmware archive, patch it with the official Magisk APK (never from an app store — Magisk isn't there), and — crucially — test the patched image in RAM before writing it:
adb reboot bootloader
fastboot boot magisk_patched-XXXXX.img # temporary boot, touches no partition
If it boots and Magisk shows "Installed", the image is sane and you flash it for real. The Nothing Phone (1) is A/B with no dedicated recovery partition, so the patch goes on the boot partition, not init_boot.
Magisk vs KernelSU? Magisk (userspace, boot patch) works directly here. KernelSU would have been cleaner but needs a recent kernel (GKI ≥ 5.10 for native support); this phone is on kernel 5.4, which would have meant compiling and flashing a custom kernel. For a server, the stealth root that KernelSU offers is worthless. Magisk, no hesitation.
Keeping a phone alive as a server
Battery charge control (the "battguard" module)
A lithium battery held at 100 % permanently degrades fast and can swell — unacceptable for a device meant to run for years. The fix is an idle mode that keeps the charge around 57–60 % instead of 100 %.
The obvious first move is ACC (Advanced Charging Controller), the standard tool for this. Its auto-detection found a working switch on this phone (the charge_control_limit sysfs node, with battIdleMode=true) — exactly what I wanted. But its daemon flatly refused to apply the cutoff: the switch worked when I pushed it by hand (echo 7 > … → current dropped to −2 mA), but the daemon never triggered it automatically, despite restarts, workarounds, and hours of debugging. A dead end specific to this ROM.
So I replaced ACC with a dumb, robust homemade script. The phone exposes the charge_control_limit node; a small battguard.sh reads the battery percentage in a loop and writes to it:
- ≥ 60 % → write
7= stop charging; - ≤ 57 % → write
0= allow charging; - in between → hold state (hysteresis, to avoid flapping).
It's packaged as a Magisk module, whose service.sh Magisk runs automatically at every boot. That's what gives it persistence — no Android daemon to schedule, Magisk handles it.
One non-obvious detail: the firmware fights the script (it periodically resets the node to 0). The fix is to re-write the value on every loop iteration rather than once — the script "wins" statistically.
Idle mode, not a true bypass. This kernel exposes no hardware cutoff node (no
input_suspend, nocharging_enabled, verified). Above 60 % the phone therefore draws slightly from the battery (~−50 to −130 mA): capacity drifts down toward 57 %, charging resumes, cuts again at 60 %. In steady state it oscillates in the 57–60 % band, which is close to ideal for longevity.
Deep sleep (Doze) and USB autosuspend
Two classic phone-as-server traps:
-
Doze. Even plugged in, Android puts the SoC into deep sleep once the screen turns off, which freezes the entire chroot. The fix is a kernel wakelock: writing a token into
/sys/power/wake_lockkeeps the SoC awake until reboot. On mains power the energy cost is nil. -
USB autosuspend. Android suspends idle USB devices, which can make the disk drop. Disable it globally (
echo -1 > /sys/module/usbcore/parameters/autosuspend) and forcepower/control=onon each present device.
One boot script to rule them all
All server init lives in a single Magisk script run at boot (/data/adb/service.d/serverstack.sh), chaining: wakelock → USB autosuspend off → wait for and mount the disk by label → chroot bind-mounts (/proc, /sys, /dev) → resolv.conf → default route → sshd → launch supervisord inside the chroot.
Magisk gotcha: the mount namespace mode must be "Global", otherwise a process launched via
su(and therefore the chroot) can't see the disk mounted at boot. This is the source of an entire class of "the disk is mounted but invisible" bugs.
Why not Docker: the two cgroup walls
The "Docker is impossible" story deserves to be accurate, because a technical reader will go check. The real chronology:
-
dockerdcrashed at startup with an unambiguous error:failed to start daemon: Devices cgroup isn't mounted. The immediate trigger is thedevicescgroup, not PID namespaces. - Trying to mount it,
/proc/config.gzreveals the controller isn't compiled:
# CONFIG_CGROUP_DEVICE is not set
# CONFIG_CGROUP_PIDS is not set
- The cgroup v2 fallback (where
devicesgoes through eBPF) is closed too:cgroup.controllersis empty. Android only exposes a shell cgroup2 hierarchy; the real controllers stay in its private v1 mounts.
So Docker dies to two stacked cgroup walls: CONFIG_CGROUP_DEVICE absent (v1 path), and an empty v2 controller set (eBPF path). CONFIG_PID_NS, often cited as the culprit, played no role — it was trivially worked around with pid: host.
I also tried Linux Deploy (the classic app to install a distro in a chroot). It failed at bootstrap every time — debootstrap bailing early with tar extraction errors, from an app unmaintained since 2020. So I built the chroot by hand.
Building the chroot by hand
Far more reliable than Linux Deploy, and instructive:
- Grab a prebuilt Debian bookworm arm64 rootfs (the official LXC images from linuxcontainers.org).
- Extract it on the Linux PC with GNU tar (correct permission and symlink handling, where Android's tar failed), straight onto the external disk mounted on the PC.
- Fix
resolv.conf(a broken symlink in the image), set upqemu-aarch64-static+ binfmt to chroot into ARM from x86, install base packages (openssh-server,sudo,adduser), create the user. - Move the disk back to the phone and chroot in from Android.
The core: PostgreSQL with no System V IPC
This is the part I'm proudest of.
The wall
Immich needs PostgreSQL. I install PostgreSQL 18 for arm64 (the architecture is not the problem), and at the very first initdb:
FATAL: could not create shared memory segment: Function not implemented
DETAIL: Failed system call was shmget(...)
Diagnosis: this kernel is built without CONFIG_SYSVIPC. The entire System V shared-memory mechanism (shmget/shmat/shmdt/shmctl) that PostgreSQL uses for its shared memory does not exist. ipcs confirms it: the IPC tables are empty and inert.
The classic PostgreSQL config workarounds (dynamic_shared_memory_type=mmap, etc.) do nothing here, because it's the main shared memory (not just the dynamic kind) that relies on SysV, and that isn't configurable away on a kernel that doesn't implement it at all.
The fix: port a userspace shim to glibc
The Termux community has exactly this problem on Android, and wrote libandroid-shmem: a library that emulates the SysV shm calls in userspace, on top of /dev/ashmem (Android's anonymous shared memory, which does exist and is accessible even to the postgres user).
The catch: that shim is written for Android's Bionic libc, while my chroot runs glibc. So I ported it: add #define _GNU_SOURCE, adjust a few includes and signatures, provide the missing ashmem.h/shm.h headers, and compile.
The result is a glibc libandroid-shmem.so, installed in /usr/local/lib/, activated globally via /etc/ld.so.preload (it inserts itself ahead of libc and intercepts shmget & friends). On the PostgreSQL side, force SysV:
shared_memory_type = sysv
dynamic_shared_memory_type = sysv
And PostgreSQL starts. The shmget that didn't exist is now emulated by the shim and redirected to /dev/ashmem. This is the keystone of the whole project.
The keystone: a preloaded userspace shim turns a missing syscall into an ashmem-backed one.
Known limitation of the shim: it doesn't digest repeated hot restarts of PostgreSQL well (
could not map shared memory segmentafter several restarts, from orphaned ashmem segments). A full reboot always comes back clean. Hardening the ashmem-segment cleanup is on the future-work list.
PostgreSQL 18 + VectorChord
Immich uses vector search (for semantic search and face recognition). From PGDG: PostgreSQL 18, pgvector 0.8.4 and VectorChord 1.1.1 (arm64 .deb), with shared_preload_libraries='vchord'. Database immich, role immich, extensions vchord and vector.
Compiling Immich natively for ARM
Docker being out, Immich has to be built by hand. Leaning on the community "immich-native" approaches, the highlights:
-
Node 24 + corepack/pnpm,
jellyfin-ffmpeg7for transcoding. - Mandatory build order: server → open-api/typescript-sdk → web → plugins. The TypeScript SDK must be generated before the web frontend, or the front build fails.
-
extism-jsdowngraded from v1.6.0 to v1.3.0: v1.6 wants glibc 2.39, but bookworm ships 2.34. -
Machine learning: a Python 3.13 venv created via
uv, which has to live in a path readable by theimmichuser. -
Hard-coded paths: Immich bakes
/buildinto itsdist; you rewrite it (sed) to the real app path. -
GeoNames (geocoding) dropped into
$APP/geodata.
Everything lives under /var/lib/immich/ (itself on the external disk), as the system user immich.
Remote access: Tailscale
To drive the server and reach Immich from anywhere, Tailscale (WireGuard mesh VPN, stable IP).
The main blocker wasn't Tailscale itself but the fact that the chroot has no default route: you have to add ip route add default via <gateway> dev eth0. Other adjustments: recreate the TUN device (mknod /dev/net/tun c 10 200), and work around the chroot's broken IPv6 with GODEBUG=netdns=go+ipv4. It's made persistent by a script launched by supervisord (priority 5) that sets the route, creates the TUN, and starts the daemon. The server has a fixed Tailscale IP with key expiry disabled so it never logs itself out.
There was also a subtle DNS conflict on the client phone: enabling Tailscale broke half the network because Android's Private DNS (an ad-blocking DoT resolver) and Tailscale's MagicDNS both wanted to own DNS. The clean fix: disable Android Private DNS, and declare the ad-blocking resolver as a custom nameserver in the Tailscale console (with "Override DNS servers"). One system owns DNS, filtering is preserved, network works.
The most painful lesson: storage reliability
The number-one fragility of the whole build is the disk.
It's ext4, mounted by label. Several times it dropped — because the dock's built-in USB cable slid out on its own, or the dock got bumped. A hot disconnect while PostgreSQL is writing = ext4 corruption, and the kernel remounts the filesystem read-only as a safety measure. Cascade: PostgreSQL can't write → dies → all of Immich dies.
The remedy, battle-tested more than once:
- Spot the cause (mount went
ro, visible inmount | grep serverdata). - Kill everything holding the disk (supervisord and its children), unmount the chroot bind-mounts, then unmount the disk.
- Run
e2fsck -f -y /dev/block/sdX(the device often slid names in the meantime). fsck replays the ext4 journal and cleans orphan inodes — no real data loss (the orphans are zero-length temp files). - Reboot: the boot script remounts the disk clean (
rw) and restarts the stack.
Golden rules that follow: never unplug, move, or cut power to the disk or dock while the phone is running (clean shutdown procedure mandatory); a solid cable that doesn't slide is a critical component, not a detail. An SSD-over-USB would be more robust to jolts — the only purchase I'm even considering.
Backups: a 3-2-1 strategy
A photo server without backups is worthless. 3-2-1: three copies, two media, one off-site.
-
Tier 1 — on the server. A script does a
pg_dumpof the database, gzipped, with 14-day rotation, run daily by cron (itself supervised by supervisord so it survives reboots). Backing up the PostgreSQL database is vital: albums, tags, and face recognition all live there — restoring the files alone would lose the entire organization. -
Tier 2 — on the Linux PC. A script pulls, over Tailscale via
rsync, both the SQL dumps (--ignore-existing, to keep a longer history than the server's 14 days) and the originals folder (--delete, exact mirror). Driven by a user systemd timer (5 min after boot, then every 12 h, with catch-up if the PC was off). This "pull" architecture decouples the tiers: the server backs itself up even with the PC off, and the PC catches up at its own pace. - Tier 3 — off-site. Still to do: an encrypted copy to remote storage (rclone → Backblaze B2 / rsync.net), nearly free at this volume. Protects against the disaster (theft, fire) that would take the phone and the PC together.
The real-world epilogue
The sections above are the build. What follows is what "operating" it actually looks like — the messy parts you only hit once it's live.
Migrating 100+ GB from Google Photos, without duplicates
The migration source was Google Takeout: three big zips (50 + 50 + 9 GB). The wrong way to do this is to unzip and drag files into the app — you'd lose every date, GPS coordinate and album, because that metadata lives in JSON sidecars, not the JPEGs.
The right tool is immich-go. It reads zips directly (no need to extract 100 GB anywhere), reads the Takeout JSON sidecars to restore real dates/GPS and recreate albums, and — critically — deduplicates against what's already on the server by file checksum. So re-importing the photos my phone had already uploaded creates no duplicates.
Two things that saved me:
-
Feed all the zips at once. Takeout splits arbitrarily, so a photo's
.jsoncan land in a different archive than the photo. Process them separately and immich-go can't match the metadata. - A dry run first. immich-go simulates the whole thing — how many to import, how many skipped as duplicates, how many albums it would create — writing nothing:
immich-go upload from-google-photos \
--server=http://<LOCAL_IP>:2283 \
--api-key=<API_KEY> \
--concurrent-tasks 4 \
--dry-run \
*.zip
I dropped --concurrent-tasks from the default 16 to 4: the default hammers a weak phone that has to hash files, write to a fragile HDD, and feed PostgreSQL all at once. And the whole thing is idempotent — if it dies mid-way, you rerun the exact same command and dedup skips everything already uploaded. Which mattered, because…
The 3 a.m. war story: overnight crash and ext4 recovery
I woke up to the server offline. The all-night import had pinned the CPU (uploading + generating thumbnails + ML on ~15,000 photos), and at some point the phone had shut down.
The debugging is a nice illustration of the architecture's failure modes. Symptoms from my laptop:
ping -c3 <LOCAL_IP> # OK — Android is alive on the LAN
nc -vz <LOCAL_IP> 22 # Connection refused — sshd is down
nc -vz <LOCAL_IP> 2283 # Connection refused — Immich is down
Android up, but the whole chroot stack down. Because sshd and Tailscale both live inside the chroot, a chroot that didn't start means no way in over the network — a real "how do I even get a shell" moment (Android 11+ wireless debugging over ADB is the answer, since the single USB-C port is busy hosting the disk).
The root cause was exactly the storage fragility from earlier: the unclean shutdown left ext4 dirty, so the boot script couldn't mount the disk cleanly, so the chroot never came up. The fix was the well-worn ritual (e2fsck the disk, then restart PostgreSQL and immich-server) and it came back.
Back from the dead: all six services RUNNING again after the fsck. (Redact the IPs in the SSH banner before publishing.)
Lesson filed away: a massive import is a sustained-write, high-heat, high-drain session — precisely the conditions that trip every one of this build's weak points at once. Do it in daylight, watch it, and give the battery headroom.
Making local uploads actually fast
The mobile app was uploading at a miserable ~300 kB/s even though the phone and the server sit on the same Wi-Fi. The culprit was routing: the app reached the server via its Tailscale IP, and tailscale ping showed a "direct" connection that actually hairpinned through the router's public IP — the traffic climbed up to the fiber and back down, capped by my upload bandwidth instead of the LAN.
The fix is two-part:
- Pin the server's local IP with a static DHCP lease (bind the dock's Ethernet MAC to a fixed address on the router), so it never changes.
-
Point the app at the LAN directly. Immich's mobile app has automatic endpoint switching: on the home Wi-Fi SSID it uses
http://<LOCAL_IP>:2283(LAN speed), and falls back to the Tailscale URL everywhere else. On Android this needs the Location permission — that's how the app reads the current SSID; without it, it silently falls back to the external URL and never switches.
Latency stayed the same (~3 ms local vs ~13 ms hairpinned), but throughput was no longer strangled by fiber upload.
What this is worth, honestly
What Immich on this build does well: automatic mobile backup, timeline, albums, face recognition and semantic search, maps, memories, sharing — and above all, total data ownership, zero recurring cost, zero commercial analysis.
The payoff: years of photos, self-hosted, on a phone that used to live in a drawer.
What you have to accept: reliability rests on an old phone, a consumer disk, and an experimental shim. Google Photos doesn't lose your photos; this build, mishandled, can. Hence the vital importance of backups, and migrating gradually — pull the history via Takeout, import, verify, run in parallel for weeks, and only cut Google once trust is established.
But the real payoff isn't just financial. It's a dense stack of systems learning packed into one project: bootloader unlock and Magisk root, Magisk modules, chroots and mount namespaces, kernel power management (wakelocks, charge control), System V shared memory and its userspace emulation, native compilation of a large Node/Python app on ARM, a mesh VPN, a real backup strategy, and ext4 debugging under fire. Few projects pack this many layers.
The three obstacles, and their solutions
| Obstacle | Root cause | Solution |
|---|---|---|
| Docker impossible |
devices cgroup unmountable (CONFIG_CGROUP_DEVICE absent) and empty cgroup v2 controller set — PID_NS, often blamed, was worked around with pid: host
|
native install in a chroot |
| PostgreSQL won't start | kernel without CONFIG_SYSVIPC
|
glibc port of the libandroid-shmem shim (SysV emulation over /dev/ashmem, via ld.so.preload) |
| Repeated ext4 corruption | hot disk disconnects | mount by label + e2fsck + clean-shutdown discipline + a reliable cable |
Credits & license
The shared-memory shim is a glibc port of libandroid-shmem from the Termux community, which emulates System V shared memory over /dev/ashmem. All credit for the original work goes to its authors; my contribution is only the glibc port. If you reuse it, respect the original project's license and attribution.
Thanks also to the Immich team and the community "immich-native" build scripts, and to the maintainers of the community firmware archive that made a safe, version-matched root possible.
If you try something similar on different hardware: your battery-control node, your kernel's missing features, and your Android quirks will differ from mine. Treat this as a map, not a manual.








Top comments (0)