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 #
- Flash the SD card: Raspberry Pi OS Lite 32-bit (armhf). Seed the
boot partition per
boot/README.md— user, SSH, Wi-Fi. - Router: must offer WPA2-only (not WPA2/WPA3 transition) — see
hardware gotchas below. Boot the Pi,
ssh pi. - 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. - Cloudflare:
CF_TOKEN=... cloudflare/setup.sh— creates the named tunnel (remotely-managed ingress), thepi+*.piproxied CNAMEs, the Worker-route exclusions, and saves the tunnel token. - Provision:
scripts/provision-pi.sh— installs both systemd services, generates secrets into/etc/zds.env, starts everything. - Grab the bootstrap invite code from the zds journal; sign up from any AT
Protocol client using
pi.chadtmiller.comas 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_FAILEDbefore 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
rtl8xxxualternate driver does not support this chip on the RPi kernel — blacklistingrtl8192curemoves 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 bootmeta-data; the stock file'sinstance_idkey 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 byprovision-pi.sh; retrofit an existing Pi withscripts/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 byprovision-pi.sh; retrofit withscripts/install-persistent-journal.sh. Past boots:journalctl --list-boots. - Secrets:
/etc/zds.envon 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.combut not*.pi.chadtmiller.com(deep wildcards need Advanced Certificate Manager), so.pi.handles fail TLS during handle verification. - Creating accounts requires
ZDS_PLC_ROTATION_KEYin/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.createAccountwith an invite code, thencom.atproto.identity.updateHandleif 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.