There’s a particular kind of “where do I plug this in?” problem that doesn’t have a clean answer in any single shop. I needed internet in a spot where pulling a cable wasn’t realistic, and “consumer LTE router from the operator’s catalogue” wasn’t going to cut it either: I wanted remote SSH access without poking a hole in anyone’s NAT, I wanted the link to survive a short power cut without anyone noticing, and I wanted to know when it didn’t — without staring at a dashboard. Off-the-shelf boxes solve maybe one of those things, and usually behind a vendor app I’d rather not install.

So instead of buying yet another opaque little box, I built one out of parts I already trusted: a Raspberry Pi 5 with a Waveshare A7670E Cat-1 modem HAT for the LTE link, a Waveshare UPS HAT B on top for battery backup, and Ansible to glue the whole thing together. The result lives in my garage — the hostname is, fittingly, garage — and the entire configuration is in a GitHub repo: one ansible-playbook away from a freshly imaged Pi to a working router with UPS monitoring, remote SSH, and daily speedtests.

Simple and functional: plug in the Pi, copy secrets.yml.example to secrets.yml, fill in four values, run bootstrap.sh. After that the Pi shares its LTE uplink as a normal Ethernet LAN, holds the link through power cuts long enough to either ride them out or shut down cleanly, comes back up on its own when the power returns, and lets me SSH in from anywhere through a Twingate tunnel — no port forwarding, no public IP, no dynamic DNS.

Under the hood it’s seven Ansible roles, all running locally on the Pi (Debian 13 Trixie). The modem role is the heart of it: it flips the A7670E from GSM mode into RNDIS (USB Ethernet) by writing AT commands directly to /dev/ttyUSB1, makes that persistent with a udev rule, and explicitly blocks ModemManager from touching the device (it resets the modem on every probe). NetworkManager then manages usb0 as the LTE uplink and eth0 as the LAN side, with a static 192.168.10.1/24, dnsmasq handing out DHCP, and nftables doing NAT/masquerade. The waveshare_ups role talks to the INA219 on I2C bus 1 (address 0x42) every 30 seconds, applies a small deadband and a confirm-count so a single noisy reading doesn’t flip the state, and posts events to Slack: AC on/off, battery thresholds, charging voltage when power returns. Below 25% it sends one last alert and calls systemctl halt — and here’s the satisfying part: the UPS HAT keeps 5V on the Pi’s GPIO even after halt, so the Pi reboots immediately into a small ups-boot-check service that either re-halts (if AC is still gone) or lets the boot finish (if it’s back). No babysitting, no manual power cycling. The twingate_connector role installs the Twingate connector, points it at usb0, and adds a loopback alias (192.168.10.1/32 on lo) so Twingate can proxy SSH to the Pi itself even after eth0 switches to router mode. The speedtest role runs Ookla in Docker on a cron, appends each reading to /var/lib/speedtest/results.jsonl, and at 08:00 every morning summarizes min/avg/max for download, upload and ping into Slack. The router role — the one that actually flips eth0 from “management” to “LAN gateway” — is intentionally the last thing you run, after Twingate SSH is confirmed working, because running it earlier means losing the only connection you have to the box.

What’s cool about it? Most of the friction in a project like this isn’t the high-level idea; it’s the long tail of weird, hardware-specific glue. AT commands you only need once a year. Udev rules whose syntax I always have to look up. INA219 register layouts. The exact incantation that convinces NetworkManager to leave usb0 alone and let the manufacturer’s own dial script touch it instead. The Twingate loopback trick. nftables vs iptables vs ufw on a system that’s already got opinions. With Claude as a co-pilot the long tail collapsed into a series of small, specific conversations — “here’s what mmcli is doing to my modem, here’s the AT command sequence I actually need, generate the udev rule and the systemd unit that survives reboot” — and the result is a repository where every choice is documented in code, not in a notebook lost somewhere on the messy desk.

The beauty is that this version is reproducible: the Pi has died once already, I reflashed the card, ran bootstrap.sh, and the garage was back online before I’d finished my coffee. That’s the part I care about — not that an AI wrote some Ansible for me or not, but that I now own a small, self-documenting piece of infrastructure I can rebuild on demand.

The full playbook, roles, and docs are on GitHub — fork it, steal the bits you need, or use it as a starting point for your own LTE-on-a-Pi build.