<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Raphael</title>
    <description>The latest articles on DEV Community by Raphael (@raphplt).</description>
    <link>https://dev.to/raphplt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012378%2F77045a45-d208-40c6-95d0-e9d376e6502b.jpg</url>
      <title>DEV Community: Raphael</title>
      <link>https://dev.to/raphplt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/raphplt"/>
    <language>en</language>
    <item>
      <title>I ran PostgreSQL on a kernel with no System V IPC, and turned an old phone into a 24/7 photo server</title>
      <dc:creator>Raphael</dc:creator>
      <pubDate>Tue, 07 Jul 2026 07:47:33 +0000</pubDate>
      <link>https://dev.to/raphplt/i-ran-postgresql-on-a-kernel-with-no-system-v-ipc-and-turned-an-old-phone-into-a-247-photo-server-50ja</link>
      <guid>https://dev.to/raphplt/i-ran-postgresql-on-a-kernel-with-no-system-v-ipc-and-turned-an-old-phone-into-a-247-photo-server-50ja</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;PostgreSQL refused to start with &lt;code&gt;FATAL: could not create shared memory segment: Function not implemented&lt;/code&gt;. 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The goal: quit Google Photos, spend nothing
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://immich.app/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt; : the most polished open-source Google Photos alternative (timeline, albums, face recognition, semantic search, automatic mobile backup).&lt;/p&gt;

&lt;p&gt;The constraint I set myself: &lt;strong&gt;zero budget.&lt;/strong&gt; No Raspberry Pi, no NAS, no VPS. Only hardware I already owned.&lt;/p&gt;

&lt;p&gt;What I had lying around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Nothing Phone (1)&lt;/strong&gt; (Snapdragon SM7325, Linux kernel 5.4, 12/256 GB) that I no longer used.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;500 GB external USB hard drive.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;USB-C dock&lt;/strong&gt; (USB hub + Ethernet + power-delivery input).&lt;/li&gt;
&lt;li&gt;My &lt;strong&gt;Linux desktop&lt;/strong&gt; (for backups and heavy build work).&lt;/li&gt;
&lt;li&gt;A phone I actually use, as the source of photos to back up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa24rzld72t0oz45rgza2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa24rzld72t0oz45rgza2.jpg" alt=" " width="800" height="602"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The whole "data center": one drawer phone, one dock, one disk.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, in one sentence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Android (rooted with Magisk) hosts a Debian chroot on the external disk, in which Immich runs compiled natively for ARM, supervised by &lt;code&gt;supervisord&lt;/code&gt; and reachable remotely over a WireGuard mesh VPN.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fues2toctltag7maq4rzi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fues2toctltag7maq4rzi.png" alt=" " width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few structural decisions worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;chroot instead of a container.&lt;/strong&gt; Docker fell over quickly — but not for the reason you'd first assume (details below). The kernel doesn't expose the &lt;code&gt;devices&lt;/code&gt; cgroup, and the cgroup v2 fallback is closed too. So I dropped the container path entirely and installed Immich &lt;strong&gt;natively&lt;/strong&gt; in a Debian chroot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything on the external disk.&lt;/strong&gt; The Debian rootfs, the database, the photos, the caches: all of it lives on the 500 GB disk (&lt;code&gt;/mnt/serverdata&lt;/code&gt;), never on the phone's internal storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mount by LABEL, never by &lt;code&gt;/dev/block/sdX&lt;/code&gt;.&lt;/strong&gt; The device name slides on every reconnect (&lt;code&gt;sdg&lt;/code&gt; → &lt;code&gt;sdh&lt;/code&gt;…). Mounting by label (&lt;code&gt;serverdata&lt;/code&gt;) is the only robust option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;supervisord as init.&lt;/strong&gt; Android has no systemd, and neither does the chroot. supervisord runs and restarts the 6 services in a defined priority order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step zero: unlock the bootloader and root with Magisk
&lt;/h2&gt;

&lt;p&gt;None of this works without root. The sensitive part is patching the &lt;code&gt;boot.img&lt;/code&gt; of the &lt;strong&gt;exact firmware version installed&lt;/strong&gt; — a mismatched &lt;code&gt;boot.img&lt;/code&gt; gives you a bootloop. You read the version off the device, fetch the matching stock &lt;code&gt;boot.img&lt;/code&gt; from the community firmware archive, patch it with the &lt;strong&gt;official Magisk APK&lt;/strong&gt; (never from an app store — Magisk isn't there), and — crucially — test the patched image in RAM before writing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb reboot bootloader
fastboot boot magisk_patched-XXXXX.img   &lt;span class="c"&gt;# temporary boot, touches no partition&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;boot&lt;/code&gt; partition, not &lt;code&gt;init_boot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magisk vs KernelSU?&lt;/strong&gt; 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 &lt;strong&gt;5.4&lt;/strong&gt;, which would have meant compiling and flashing a custom kernel. For a server, the stealth root that KernelSU offers is worthless. Magisk, no hesitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping a phone alive as a server
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Battery charge control (the "battguard" module)
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;idle mode&lt;/strong&gt; that keeps the charge around 57–60 % instead of 100 %.&lt;/p&gt;

&lt;p&gt;The obvious first move is &lt;strong&gt;ACC (Advanced Charging Controller)&lt;/strong&gt;, the standard tool for this. Its auto-detection found a working switch on this phone (the &lt;code&gt;charge_control_limit&lt;/code&gt; sysfs node, with &lt;code&gt;battIdleMode=true&lt;/code&gt;) — exactly what I wanted. &lt;strong&gt;But its daemon flatly refused to apply the cutoff&lt;/strong&gt;: the switch worked when I pushed it by hand (&lt;code&gt;echo 7 &amp;gt; …&lt;/code&gt; → 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.&lt;/p&gt;

&lt;p&gt;So I replaced ACC with a dumb, robust homemade script. The phone exposes the &lt;code&gt;charge_control_limit&lt;/code&gt; node; a small &lt;code&gt;battguard.sh&lt;/code&gt; reads the battery percentage in a loop and writes to it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;≥ 60 % → write &lt;code&gt;7&lt;/code&gt; = stop charging;&lt;/li&gt;
&lt;li&gt;≤ 57 % → write &lt;code&gt;0&lt;/code&gt; = allow charging;&lt;/li&gt;
&lt;li&gt;in between → hold state (hysteresis, to avoid flapping).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's packaged as a &lt;strong&gt;Magisk module&lt;/strong&gt;, whose &lt;code&gt;service.sh&lt;/code&gt; Magisk runs automatically at every boot. That's what gives it persistence — no Android daemon to schedule, Magisk handles it.&lt;/p&gt;

&lt;p&gt;One non-obvious detail: the firmware fights the script (it periodically resets the node to &lt;code&gt;0&lt;/code&gt;). The fix is to &lt;strong&gt;re-write the value on every loop iteration&lt;/strong&gt; rather than once — the script "wins" statistically.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Idle mode, not a true bypass.&lt;/strong&gt; This kernel exposes no hardware cutoff node (no &lt;code&gt;input_suspend&lt;/code&gt;, no &lt;code&gt;charging_enabled&lt;/code&gt;, 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Deep sleep (Doze) and USB autosuspend
&lt;/h3&gt;

&lt;p&gt;Two classic phone-as-server traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Doze.&lt;/strong&gt; Even plugged in, Android puts the SoC into deep sleep once the screen turns off, which &lt;strong&gt;freezes the entire chroot.&lt;/strong&gt; The fix is a &lt;strong&gt;kernel wakelock&lt;/strong&gt;: writing a token into &lt;code&gt;/sys/power/wake_lock&lt;/code&gt; keeps the SoC awake until reboot. On mains power the energy cost is nil.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USB autosuspend.&lt;/strong&gt; Android suspends idle USB devices, which can make the disk drop. Disable it globally (&lt;code&gt;echo -1 &amp;gt; /sys/module/usbcore/parameters/autosuspend&lt;/code&gt;) and force &lt;code&gt;power/control=on&lt;/code&gt; on each present device.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One boot script to rule them all
&lt;/h3&gt;

&lt;p&gt;All server init lives in a single Magisk script run at boot (&lt;code&gt;/data/adb/service.d/serverstack.sh&lt;/code&gt;), chaining: wakelock → USB autosuspend off → wait for and mount the disk by label → chroot bind-mounts (&lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;/sys&lt;/code&gt;, &lt;code&gt;/dev&lt;/code&gt;) → &lt;code&gt;resolv.conf&lt;/code&gt; → default route → &lt;code&gt;sshd&lt;/code&gt; → launch supervisord inside the chroot.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Magisk gotcha:&lt;/strong&gt; the mount namespace mode must be &lt;strong&gt;"Global"&lt;/strong&gt;, otherwise a process launched via &lt;code&gt;su&lt;/code&gt; (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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why not Docker: the two cgroup walls
&lt;/h2&gt;

&lt;p&gt;The "Docker is impossible" story deserves to be accurate, because a technical reader will go check. The real chronology:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;dockerd&lt;/code&gt; crashed at startup with an &lt;strong&gt;unambiguous&lt;/strong&gt; error: &lt;code&gt;failed to start daemon: Devices cgroup isn't mounted&lt;/code&gt;. The immediate trigger is the &lt;strong&gt;&lt;code&gt;devices&lt;/code&gt;&lt;/strong&gt; cgroup, not PID namespaces.&lt;/li&gt;
&lt;li&gt;Trying to mount it, &lt;code&gt;/proc/config.gz&lt;/code&gt; reveals the controller isn't compiled:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;   &lt;span class="c"&gt;# CONFIG_CGROUP_DEVICE is not set
&lt;/span&gt;   &lt;span class="c"&gt;# CONFIG_CGROUP_PIDS is not set
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;cgroup v2&lt;/strong&gt; fallback (where &lt;code&gt;devices&lt;/code&gt; goes through eBPF) is closed too: &lt;code&gt;cgroup.controllers&lt;/code&gt; is &lt;strong&gt;empty&lt;/strong&gt;. Android only exposes a shell cgroup2 hierarchy; the real controllers stay in its private v1 mounts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So Docker dies to &lt;strong&gt;two stacked cgroup walls&lt;/strong&gt;: &lt;code&gt;CONFIG_CGROUP_DEVICE&lt;/code&gt; absent (v1 path), and an empty v2 controller set (eBPF path). &lt;code&gt;CONFIG_PID_NS&lt;/code&gt;, often cited as the culprit, played &lt;strong&gt;no&lt;/strong&gt; role — it was trivially worked around with &lt;code&gt;pid: host&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I also tried &lt;strong&gt;Linux Deploy&lt;/strong&gt; (the classic app to install a distro in a chroot). It failed at bootstrap every time — &lt;code&gt;debootstrap&lt;/code&gt; bailing early with &lt;code&gt;tar&lt;/code&gt; extraction errors, from an app unmaintained since 2020. So I built the chroot by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the chroot by hand
&lt;/h2&gt;

&lt;p&gt;Far more reliable than Linux Deploy, and instructive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grab a &lt;strong&gt;prebuilt Debian bookworm arm64 rootfs&lt;/strong&gt; (the official LXC images from linuxcontainers.org).&lt;/li&gt;
&lt;li&gt;Extract it &lt;strong&gt;on the Linux PC with GNU tar&lt;/strong&gt; (correct permission and symlink handling, where Android's tar failed), straight onto the external disk mounted on the PC.&lt;/li&gt;
&lt;li&gt;Fix &lt;code&gt;resolv.conf&lt;/code&gt; (a broken symlink in the image), set up &lt;code&gt;qemu-aarch64-static&lt;/code&gt; + binfmt to chroot into ARM from x86, install base packages (&lt;code&gt;openssh-server&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;adduser&lt;/code&gt;), create the user.&lt;/li&gt;
&lt;li&gt;Move the disk back to the phone and chroot in from Android.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The core: PostgreSQL with no System V IPC
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wall
&lt;/h3&gt;

&lt;p&gt;Immich needs PostgreSQL. I install PostgreSQL 18 for arm64 (the architecture is not the problem), and at the very first &lt;code&gt;initdb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: could not create shared memory segment: Function not implemented
DETAIL: Failed system call was shmget(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Diagnosis: this kernel is built &lt;strong&gt;without &lt;code&gt;CONFIG_SYSVIPC&lt;/code&gt;&lt;/strong&gt;. The entire System V shared-memory mechanism (&lt;code&gt;shmget&lt;/code&gt;/&lt;code&gt;shmat&lt;/code&gt;/&lt;code&gt;shmdt&lt;/code&gt;/&lt;code&gt;shmctl&lt;/code&gt;) that PostgreSQL uses for its shared memory &lt;strong&gt;does not exist&lt;/strong&gt;. &lt;code&gt;ipcs&lt;/code&gt; confirms it: the IPC tables are empty and inert.&lt;/p&gt;

&lt;p&gt;The classic PostgreSQL config workarounds (&lt;code&gt;dynamic_shared_memory_type=mmap&lt;/code&gt;, etc.) do nothing here, because it's the &lt;strong&gt;main&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: port a userspace shim to glibc
&lt;/h3&gt;

&lt;p&gt;The Termux community has exactly this problem on Android, and wrote &lt;code&gt;libandroid-shmem&lt;/code&gt;: a library that &lt;strong&gt;emulates the SysV shm calls in userspace&lt;/strong&gt;, on top of &lt;code&gt;/dev/ashmem&lt;/code&gt; (Android's anonymous shared memory, which &lt;em&gt;does&lt;/em&gt; exist and is accessible even to the &lt;code&gt;postgres&lt;/code&gt; user).&lt;/p&gt;

&lt;p&gt;The catch: that shim is written for Android's &lt;strong&gt;Bionic libc&lt;/strong&gt;, while my chroot runs &lt;strong&gt;glibc&lt;/strong&gt;. So I ported it: add &lt;code&gt;#define _GNU_SOURCE&lt;/code&gt;, adjust a few includes and signatures, provide the missing &lt;code&gt;ashmem.h&lt;/code&gt;/&lt;code&gt;shm.h&lt;/code&gt; headers, and compile.&lt;/p&gt;

&lt;p&gt;The result is a glibc &lt;code&gt;libandroid-shmem.so&lt;/code&gt;, installed in &lt;code&gt;/usr/local/lib/&lt;/code&gt;, activated globally via &lt;code&gt;/etc/ld.so.preload&lt;/code&gt; (it inserts itself ahead of libc and intercepts &lt;code&gt;shmget&lt;/code&gt; &amp;amp; friends). On the PostgreSQL side, force SysV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;shared_memory_type&lt;/span&gt; = &lt;span class="n"&gt;sysv&lt;/span&gt;
&lt;span class="n"&gt;dynamic_shared_memory_type&lt;/span&gt; = &lt;span class="n"&gt;sysv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;strong&gt;PostgreSQL starts.&lt;/strong&gt; The &lt;code&gt;shmget&lt;/code&gt; that didn't exist is now emulated by the shim and redirected to &lt;code&gt;/dev/ashmem&lt;/code&gt;. This is the keystone of the whole project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwsoqzh250eeub83tsse.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwsoqzh250eeub83tsse.png" alt=" " width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The keystone: a preloaded userspace shim turns a missing syscall into an ashmem-backed one.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Known limitation of the shim:&lt;/strong&gt; it doesn't digest repeated hot restarts of PostgreSQL well (&lt;code&gt;could not map shared memory segment&lt;/code&gt; after several restarts, from orphaned ashmem segments). A full reboot always comes back clean. Hardening the ashmem-segment cleanup is on the future-work list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  PostgreSQL 18 + VectorChord
&lt;/h3&gt;

&lt;p&gt;Immich uses vector search (for semantic search and face recognition). From PGDG: PostgreSQL 18, &lt;code&gt;pgvector&lt;/code&gt; 0.8.4 and &lt;strong&gt;VectorChord&lt;/strong&gt; 1.1.1 (arm64 &lt;code&gt;.deb&lt;/code&gt;), with &lt;code&gt;shared_preload_libraries='vchord'&lt;/code&gt;. Database &lt;code&gt;immich&lt;/code&gt;, role &lt;code&gt;immich&lt;/code&gt;, extensions &lt;code&gt;vchord&lt;/code&gt; and &lt;code&gt;vector&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compiling Immich natively for ARM
&lt;/h2&gt;

&lt;p&gt;Docker being out, Immich has to be built by hand. Leaning on the community "immich-native" approaches, the highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 24&lt;/strong&gt; + corepack/pnpm, &lt;code&gt;jellyfin-ffmpeg7&lt;/code&gt; for transcoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mandatory build order&lt;/strong&gt;: server → open-api/typescript-sdk → web → plugins. The TypeScript SDK must be generated &lt;strong&gt;before&lt;/strong&gt; the web frontend, or the front build fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;extism-js&lt;/code&gt; downgraded&lt;/strong&gt; from v1.6.0 to v1.3.0: v1.6 wants glibc 2.39, but bookworm ships 2.34.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine learning&lt;/strong&gt;: a Python 3.13 venv created via &lt;code&gt;uv&lt;/code&gt;, which has to live in a path readable by the &lt;code&gt;immich&lt;/code&gt; user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard-coded paths&lt;/strong&gt;: Immich bakes &lt;code&gt;/build&lt;/code&gt; into its &lt;code&gt;dist&lt;/code&gt;; you rewrite it (&lt;code&gt;sed&lt;/code&gt;) to the real app path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GeoNames&lt;/strong&gt; (geocoding) dropped into &lt;code&gt;$APP/geodata&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything lives under &lt;code&gt;/var/lib/immich/&lt;/code&gt; (itself on the external disk), as the system user &lt;code&gt;immich&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote access: Tailscale
&lt;/h2&gt;

&lt;p&gt;To drive the server and reach Immich from anywhere, &lt;strong&gt;Tailscale&lt;/strong&gt; (WireGuard mesh VPN, stable IP).&lt;/p&gt;

&lt;p&gt;The main blocker wasn't Tailscale itself but the fact that &lt;strong&gt;the chroot has no default route&lt;/strong&gt;: you have to add &lt;code&gt;ip route add default via &amp;lt;gateway&amp;gt; dev eth0&lt;/code&gt;. Other adjustments: recreate the TUN device (&lt;code&gt;mknod /dev/net/tun c 10 200&lt;/code&gt;), and work around the chroot's broken IPv6 with &lt;code&gt;GODEBUG=netdns=go+ipv4&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;There was also a subtle &lt;strong&gt;DNS conflict&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most painful lesson: storage reliability
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The number-one fragility of the whole build is the disk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 = &lt;strong&gt;ext4 corruption&lt;/strong&gt;, and the kernel remounts the filesystem &lt;strong&gt;read-only&lt;/strong&gt; as a safety measure. Cascade: PostgreSQL can't write → dies → all of Immich dies.&lt;/p&gt;

&lt;p&gt;The remedy, battle-tested more than once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spot the cause (mount went &lt;code&gt;ro&lt;/code&gt;, visible in &lt;code&gt;mount | grep serverdata&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Kill everything holding the disk (supervisord and its children), unmount the chroot bind-mounts, then unmount the disk.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;e2fsck -f -y /dev/block/sdX&lt;/code&gt; (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).&lt;/li&gt;
&lt;li&gt;Reboot: the boot script remounts the disk clean (&lt;code&gt;rw&lt;/code&gt;) and restarts the stack.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Golden rules that follow:&lt;/strong&gt; 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 &lt;em&gt;critical component&lt;/em&gt;, not a detail. An SSD-over-USB would be more robust to jolts — the only purchase I'm even considering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups: a 3-2-1 strategy
&lt;/h2&gt;

&lt;p&gt;A photo server without backups is worthless. 3-2-1: three copies, two media, one off-site.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k9p547jzgbvdgcdvnue.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k9p547jzgbvdgcdvnue.png" alt=" " width="800" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 — on the server.&lt;/strong&gt; A script does a &lt;code&gt;pg_dump&lt;/code&gt; of 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 &lt;em&gt;vital&lt;/em&gt;: albums, tags, and face recognition all live there — restoring the files alone would lose the entire organization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 — on the Linux PC.&lt;/strong&gt; A script pulls, over Tailscale via &lt;code&gt;rsync&lt;/code&gt;, both the SQL dumps (&lt;code&gt;--ignore-existing&lt;/code&gt;, to keep a longer history than the server's 14 days) and the originals folder (&lt;code&gt;--delete&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3 — off-site.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  The real-world epilogue
&lt;/h1&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating 100+ GB from Google Photos, without duplicates
&lt;/h2&gt;

&lt;p&gt;The migration source was &lt;strong&gt;Google Takeout&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;The right tool is &lt;strong&gt;&lt;a href="https://github.com/simulot/immich-go" rel="noopener noreferrer"&gt;immich-go&lt;/a&gt;&lt;/strong&gt;. It reads zips directly (no need to extract 100 GB anywhere), reads the Takeout JSON sidecars to &lt;strong&gt;restore real dates/GPS and recreate albums&lt;/strong&gt;, and — critically — &lt;strong&gt;deduplicates against what's already on the server&lt;/strong&gt; by file checksum. So re-importing the photos my phone had already uploaded creates no duplicates.&lt;/p&gt;

&lt;p&gt;Two things that saved me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feed all the zips at once.&lt;/strong&gt; Takeout splits arbitrarily, so a photo's &lt;code&gt;.json&lt;/code&gt; can land in a different archive than the photo. Process them separately and immich-go can't match the metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dry run first.&lt;/strong&gt; immich-go simulates the whole thing — how many to import, how many skipped as duplicates, how many albums it would create — writing nothing:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;immich-go upload from-google-photos &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://&amp;lt;LOCAL_IP&amp;gt;:2283 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;API_KEY&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrent-tasks&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63ejxrnqbrmoij7lzsj0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63ejxrnqbrmoij7lzsj0.png" alt=" " width="800" height="519"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I dropped &lt;code&gt;--concurrent-tasks&lt;/code&gt; from the default 16 to &lt;strong&gt;4&lt;/strong&gt;: 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 &lt;strong&gt;idempotent&lt;/strong&gt; — if it dies mid-way, you rerun the exact same command and dedup skips everything already uploaded. Which mattered, because…&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3 a.m. war story: overnight crash and ext4 recovery
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The debugging is a nice illustration of the architecture's failure modes. Symptoms from my laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping &lt;span class="nt"&gt;-c3&lt;/span&gt; &amp;lt;LOCAL_IP&amp;gt;        &lt;span class="c"&gt;# OK — Android is alive on the LAN&lt;/span&gt;
nc &lt;span class="nt"&gt;-vz&lt;/span&gt; &amp;lt;LOCAL_IP&amp;gt; 22       &lt;span class="c"&gt;# Connection refused — sshd is down&lt;/span&gt;
nc &lt;span class="nt"&gt;-vz&lt;/span&gt; &amp;lt;LOCAL_IP&amp;gt; 2283     &lt;span class="c"&gt;# Connection refused — Immich is down&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Android up, but the whole chroot stack down. Because &lt;code&gt;sshd&lt;/code&gt; and Tailscale both live &lt;em&gt;inside&lt;/em&gt; 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+ &lt;strong&gt;wireless debugging&lt;/strong&gt; over ADB is the answer, since the single USB-C port is busy hosting the disk).&lt;/p&gt;

&lt;p&gt;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 (&lt;strong&gt;&lt;code&gt;e2fsck&lt;/code&gt; the disk, then restart PostgreSQL and immich-server&lt;/strong&gt;) and it came back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkb3lh8tdsb9g5432d50q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkb3lh8tdsb9g5432d50q.png" alt=" " width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Back from the dead: all six services RUNNING again after the fsck. (Redact the IPs in the SSH banner before publishing.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making local uploads actually fast
&lt;/h2&gt;

&lt;p&gt;The mobile app was uploading at a miserable ~300 kB/s even though the phone and the server sit on the &lt;strong&gt;same Wi-Fi&lt;/strong&gt;. The culprit was routing: the app reached the server via its Tailscale IP, and &lt;code&gt;tailscale ping&lt;/code&gt; showed a "direct" connection that actually &lt;strong&gt;hairpinned&lt;/strong&gt; through the router's public IP — the traffic climbed up to the fiber and back down, capped by my &lt;em&gt;upload&lt;/em&gt; bandwidth instead of the LAN.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxgd0z2dbub8fjhjg7a6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxgd0z2dbub8fjhjg7a6.png" alt=" " width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is two-part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin the server's local IP&lt;/strong&gt; with a static DHCP lease (bind the dock's Ethernet MAC to a fixed address on the router), so it never changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point the app at the LAN directly.&lt;/strong&gt; Immich's mobile app has automatic endpoint switching: on the home Wi-Fi SSID it uses &lt;code&gt;http://&amp;lt;LOCAL_IP&amp;gt;:2283&lt;/code&gt; (LAN speed), and falls back to the Tailscale URL everywhere else. On Android this needs the &lt;strong&gt;Location permission&lt;/strong&gt; — that's how the app reads the current SSID; without it, it silently falls back to the external URL and never switches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Latency stayed the same (~3 ms local vs ~13 ms hairpinned), but throughput was no longer strangled by fiber upload.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is worth, honestly
&lt;/h2&gt;

&lt;p&gt;What Immich on this build does well: automatic mobile backup, timeline, albums, face recognition and semantic search, maps, memories, sharing — and above all, &lt;strong&gt;total data ownership, zero recurring cost, zero commercial analysis.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff9qvzb7nkajxnqw209mx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff9qvzb7nkajxnqw209mx.png" alt=" " width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The payoff: years of photos, self-hosted, on a phone that used to live in a drawer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;gradually&lt;/strong&gt; — pull the history via Takeout, import, verify, run in parallel for weeks, and only cut Google once trust is established.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three obstacles, and their solutions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Obstacle&lt;/th&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker impossible&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;devices&lt;/code&gt; cgroup unmountable (&lt;code&gt;CONFIG_CGROUP_DEVICE&lt;/code&gt; absent) &lt;strong&gt;and&lt;/strong&gt; empty cgroup v2 controller set — PID_NS, often blamed, was worked around with &lt;code&gt;pid: host&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;native&lt;/strong&gt; install in a chroot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL won't start&lt;/td&gt;
&lt;td&gt;kernel without &lt;code&gt;CONFIG_SYSVIPC&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;glibc port of the &lt;code&gt;libandroid-shmem&lt;/code&gt; shim&lt;/strong&gt; (SysV emulation over &lt;code&gt;/dev/ashmem&lt;/code&gt;, via &lt;code&gt;ld.so.preload&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeated ext4 corruption&lt;/td&gt;
&lt;td&gt;hot disk disconnects&lt;/td&gt;
&lt;td&gt;mount by label + &lt;code&gt;e2fsck&lt;/code&gt; + clean-shutdown discipline + a reliable cable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Credits &amp;amp; license
&lt;/h2&gt;

&lt;p&gt;The shared-memory shim is a &lt;strong&gt;glibc port of &lt;a href="https://github.com/pelya/android-shmem" rel="noopener noreferrer"&gt;&lt;code&gt;libandroid-shmem&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; from the Termux community, which emulates System V shared memory over &lt;code&gt;/dev/ashmem&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>android</category>
      <category>postgres</category>
      <category>homelab</category>
    </item>
    <item>
      <title>I built a 30MB database client in Rust. Here's what it cost me.</title>
      <dc:creator>Raphael</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:57:07 +0000</pubDate>
      <link>https://dev.to/raphplt/i-built-a-30mb-database-client-in-rust-heres-what-it-cost-me-1pjb</link>
      <guid>https://dev.to/raphplt/i-built-a-30mb-database-client-in-rust-heres-what-it-cost-me-1pjb</guid>
      <description>&lt;p&gt;DBeaver takes about 8 seconds to start on my machine. It idles around 800MB of RAM before I've opened a single connection. It does everything - and that's exactly the problem. Somewhere along the way, we accepted that database tools have to feel like operating systems.&lt;/p&gt;

&lt;p&gt;Six months ago I decided to find out whether that was actually true. The result is QoreDB, an open-source desktop database client that ships as a ~30MB binary and starts in under a second. This post is about the technical bets that made that possible, and the ones I regret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core bet: Tauri over Electron&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious path for a desktop app in 2026 was Electron. I went with Tauri v2 instead, and the trade is simple to state: Electron bundles Chromium with your app; Tauri uses the OS webview (WebView2 on Windows, WebKit on macOS/Linux) and runs your backend as a native Rust binary.&lt;/p&gt;

&lt;p&gt;What I gained, measured on my own builds: an installer around 30MB instead of 150MB+, cold start under a second, and idle memory that stays low enough that users don't notice the app is running. For a tool that positions itself as a "daily driver" you leave open all day, idle footprint matters more than any benchmark.&lt;/p&gt;

&lt;p&gt;What it cost me: three rendering engines instead of one. A CSS behavior that works in WebView2 can break subtly in WebKitGTK on Linux. I now maintain a small set of workarounds I would never have needed with Electron's "one Chromium everywhere" guarantee. If your app has heavy, pixel-critical UI, this tax is real. For a data grid and an editor, it turned out to be manageable — annoying, not fatal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rust where it counts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The frontend is React + TypeScript, and honestly, that part is boring in the best way. The interesting layer is the Rust backend: connection management, query execution, and streaming results over Tauri's IPC.&lt;/p&gt;

&lt;p&gt;Two things Rust bought me that I couldn't have gotten as cheaply elsewhere:&lt;/p&gt;

&lt;p&gt;Streaming large result sets without choking. Query results stream from the driver through Tokio into the webview in chunks, and the frontend virtualizes rendering (TanStack Virtual). Scrolling through a million-row table doesn't load a million rows into the DOM — or into memory. The Rust side holds the cursor; the UI only ever sees a window.&lt;/p&gt;

&lt;p&gt;Fearless concurrency for connection pools. A database client is fundamentally a concurrency problem: multiple connections, multiple in-flight queries, cancellation, timeouts. The borrow checker made this genuinely harder to write and genuinely easier to trust. I've had exactly zero data races in production. I cannot say that about any concurrent code I've written in other languages.&lt;/p&gt;

&lt;p&gt;What it cost me: velocity, especially early. Rust's ecosystem for database drivers is good (SQLx is excellent) but not uniform. Every engine beyond the majors meant evaluating a crate, sometimes wrapping a C library, sometimes writing more protocol code than I'd like. There were weeks where I shipped nothing visible because I was fighting a driver's async model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One abstraction over SQL and NoSQL&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;QoreDB currently speaks to 15 engines — PostgreSQL, MySQL/MariaDB, MongoDB, Redis and others — through a single internal abstraction layer. The hard part isn't connecting to 15 databases; it's designing an interface where a document collection and a relational table can be browsed, filtered, and edited with the same UI and the same keyboard shortcuts without lying about their differences.&lt;/p&gt;

&lt;p&gt;That abstraction deserves its own post (it's coming), but the short version: I model everything as "resources" with capabilities, rather than pretending everything is a table. A Redis keyspace declares it can't do joins; the UI adapts instead of erroring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd tell you if you're considering this stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tauri is production-ready if your UI tolerates webview differences and you're comfortable in Rust when things break — the escape hatches are Rust, not Node. The performance story isn't hype: the numbers above are real and users notice them immediately. But you're trading Electron's ecosystem maturity for a smaller, sharper toolset. For a solo developer, that trade only works if performance is the product. For QoreDB, it is.&lt;/p&gt;

&lt;p&gt;The project is open source (Apache 2.0 core) and I'm building it in public: github.com/QoreDB/QoreDB. Happy to answer anything about the stack in the comments, especially if you've fought the same webview battles.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>database</category>
      <category>opensource</category>
      <category>react</category>
    </item>
  </channel>
</rss>
