This repository has no description

README.md

zds-pi #

A self-hosted AT Protocol PDS (zds, written in Zig) running on a 2011 Raspberry Pi 1 Model B (ARMv6, 512 MB RAM, 700 MHz) behind T-Mobile Home Internet CGNAT, publicly served at https://pi.chadtmiller.com via Cloudflare Tunnel.

Live since 2026-08-19. ~7 MB RSS. Federates with the Bluesky relay network.

Architecture #

internet ── Cloudflare edge (TLS, DNS: pi + *.pi CNAME → tunnel)
                │  (outbound-only tunnel: CGNAT never sees an inbound packet)
            cloudflared (systemd, ARMv6 build) ── zds :2583 (systemd, localhost)
                        Raspberry Pi 1 ── Edimax EW-7811Un ── 2.4 GHz Wi-Fi

Reproducing from scratch #

  1. Flash the SD card: Raspberry Pi OS Lite 32-bit (armhf). Seed the boot partition per boot/README.md — user, SSH, Wi-Fi.
  2. Router: must offer WPA2-only (not WPA2/WPA3 transition) — see hardware gotchas below. Boot the Pi, ssh pi.
  3. Build: scripts/build.sh — clones zds at a pinned commit, applies the ARMv6 patches (below), cross-compiles from macOS with Zig (-Dtarget=arm-linux-musleabihf -Dcpu=arm1176jzf_s). Never compile on the Pi; 512 MB is not enough for the Zig compiler.
  4. Cloudflare: CF_TOKEN=... cloudflare/setup.sh — creates the named tunnel (remotely-managed ingress), the pi + *.pi proxied CNAMEs, the Worker-route exclusions, and saves the tunnel token.
  5. Provision: scripts/provision-pi.sh — installs both systemd services, generates secrets into /etc/zds.env, starts everything.
  6. Grab the bootstrap invite code from the zds journal; sign up from any AT Protocol client using pi.chadtmiller.com as a custom hosting provider.

Later rebuilds: scripts/build.sh && scripts/deploy.sh.

ARMv6/32-bit patches (patches/) #

zds and its zat dependency assume 64-bit in a few places. All would be reasonable upstream contributions:

Patch Problem on 32-bit
zds-src-armv6.patch: PortableAtomic + use in store.zig, eventlog.zig, sync.zig ARMv6 has no 64-bit atomic instructions; std.atomic.Value(u64/i64) fails to compile. Falls back to a yielding spinlock; 64-bit targets compile to the same std.atomic.Value as before.
zds-src-armv6.patch: sync.zig nowMillis() ts.sec * 1000 in 32-bit math — overflows instantly in 2026 and crashed the server at startup. Real bug, not a port papercut.
patch-zat-vendored.py: cbor.zig Comptime struct-size asserts hard-code 64-bit pointer sizes.
patch-zat-vendored.py: transport.zig Another std.atomic.Value(u64) counter.

(The zat edits live in the untracked zig-pkg/ package cache, hence a patcher script keyed on content patterns instead of a .patch file.)

Hardware gotchas (hard-won) #

  • The Edimax EW-7811Un (rtl8192cu) cannot join WPA2/WPA3 transition-mode networks. It fails association with CONN_FAILED before the password is even tested; disabling PMF does not help. Router must be WPA2-only. If the router setting ever reverts, the Pi silently drops off the network.
  • The rtl8xxxu alternate driver does not support this chip on the RPi kernel — blacklisting rtl8192cu removes Wi-Fi entirely.
  • The dongle is 2.4 GHz-only, and its LED means "radio powered", not "connected". Wi-Fi powersave must be off or SSH sessions drop (nmcli con modify <con> 802-11-wireless.powersave 2 — already persisted).
  • cloudflared needs TimeoutStartSec=300 (pi/cloudflared-override.conf); ARMv6 TLS handshakes overrun systemd's default 90 s notify window.
  • cloud-init re-runs (new Wi-Fi config etc.) require bumping instance-id (hyphen!) in boot meta-data; the stock file's instance_id key is a lie.

Cloudflare zone quirks #

A Worker named pds owns chadtmiller.com/* and *.chadtmiller.com/* routes. Worker routes intercept proxied traffic before tunnels, so any subdomain that should bypass the Worker needs a null-script exclusion route (setup.sh handles this for pi and *.pi).

Operations #

  • Health: curl https://pi.chadtmiller.com/xrpc/_health
  • Logs: ssh pi 'sudo journalctl -u zds -f'
  • Wi-Fi watchdog (pi/wifi-watchdog.*): systemd timer pings the gateway (falling back to 1.1.1.1) every minute; after 3 consecutive failures it bounces the Wi-Fi via nmcli, after 6 it reboots. A reachable gateway counts as healthy — an upstream ISP outage isn't fixable by rebooting. Installed by provision-pi.sh; retrofit an existing Pi with scripts/install-wifi-watchdog.sh. Logs: journalctl -t wifi-watchdog.
  • Persistent journal (pi/journald-persistent.conf): survives reboots so Wi-Fi drops and watchdog reboots leave evidence, capped at 16 MB for the 4 GB SD card. Installed by provision-pi.sh; retrofit with scripts/install-persistent-journal.sh. Past boots: journalctl --list-boots.
  • Secrets: /etc/zds.env on the Pi (root:600). Admin token lives there.
  • Data: /home/chad/zds-data/ (SQLite + blobs) on a 2015-era 4 GB SD card. No backups yet — fun-grade, not trust-grade. Set up an off-Pi backup before storing an identity you care about.

First account (done 2026-08-19) #

  • Handle: zds.chadtmiller.com · DID: did:plc:3ei53t5k67z6i7gnznzb5ls2
  • Handles live at the first level (.chadtmiller.com), not under .pi.: Cloudflare's free Universal SSL covers *.chadtmiller.com but not *.pi.chadtmiller.com (deep wildcards need Advanced Certificate Manager), so .pi. handles fail TLS during handle verification.
  • Creating accounts requires ZDS_PLC_ROTATION_KEY in /etc/zds.env — a secp256k1 key that signs PLC identity operations. Back it up off-Pi; it is the identity control root.
  • Flow: com.atproto.server.createAccount with an invite code, then com.atproto.identity.updateHandle if the handle changes.

Permissioned data (enabled 2026-08-19) #

ZDS_PERMISSIONED_DATA=true in /etc/zds.env enables the experimental com.atproto.space.* / com.atproto.simplespace.* routes (private records, blobs, spaces). Explicitly experimental in zds — a prototype tracking Bluesky proposal 0016/PR #94, whose design is still moving. Expect breaking changes on zds upgrades; don't store anything precious in spaces yet.