overview

A Raspberry Pi 5 (8 GB) on my desk runs most of what I host. It's a NAS for movies, shows, music and school files, the backend for this website's misc page, the production server for NEU SquashHub, the SSH portfolio, and Home Assistant. Everything except the OS lives on an external SSD, gets backed up to a second drive every night, and is reachable from my phone and laptop anywhere over Tailscale.

The Flask API behind this site and SquashHub both used to run on an AWS EC2 instance. They moved here and the AWS setup is gone.

storage

4 TB SSD: A SanDisk Extreme Pro holds everything: media, Home Assistant's config, the photos my wall tablet takes, and my own files. It's formatted ext4 with only 0.5% reserved space, since nothing on it needs root's emergency headroom.
2 TB HDD: A Seagate Expansion drive that exists only to hold backups of the SSD.
mounted by UUID: USB drive letters swap around after a replug, so both drives mount by filesystem UUID with nofail and a 10 second timeout. A missing drive can't stop the Pi from booting.
why ext4, not btrfs: These are USB drives, and USB drives get knocked loose. ext4 recovers cleanly from an ungraceful disconnect, it's mature on ARM, and I already get snapshots from the backup setup below. I'll revisit btrfs RAID1 if I ever buy a matching second disk.

That decision got tested once: a drive came unplugged mid-write. The recovery was dmesg and journalctl to confirm what happened, unmount, fsck -f, remount, restart the file server. No data lost.

The Pi itself also got two stability fixes after an rsync crash: the USB ports are allowed their full current budget so two drives don't brown out, and the hardware watchdog is on, so a hard lockup reboots itself within two minutes instead of waiting for me to get home.

backups

rsnapshot copies the SSD to the HDD every night at 2 AM, then keeps 7 daily, 4 weekly and 6 monthly snapshots. Each snapshot looks like a full copy, but unchanged files are hard links to the previous one, so six months of history costs barely more than one copy.

the setting i'd tell anyone to turn on: no_create_root. if the backup drive isn't mounted, rsnapshot refuses to run instead of happily writing a full backup onto the Pi's SD card and filling it.

One thing that confused me at first: du over-reports a single snapshot, because a hard-linked file gets counted against whichever snapshot du sees first. df -h tells the truth.

services

copyparty: The file server. It serves the SSD over the web and WebDAV, so the same files open in a browser, in Finder, and in nPlayer on my iPhone. I picked it over Nextcloud because it's far lighter on a Pi and I didn't need Nextcloud's photo sync; iCloud already does that.
vedsite API: A Flask app that talks to Spotify, Last.fm and Letterboxd for the misc page, published at api.vedsite.com through a Cloudflare Tunnel. No open ports on my router.
SquashHub: The club's React + Express + PostgreSQL stack, three containers, published at neusquashhub.com through the same tunnel.
SSH portfolio: The Go binary behind ssh portfolio.vedsite.com, reached through a small Oracle Cloud relay.
Home Assistant: In Docker on the host network, with its config on the SSD so it's in the nightly backup like everything else. It has its own page.
Tailscale: Every device I own is on one tailnet, which is how I reach the Pi from anywhere without exposing it. The Pi is also an exit node, so on public Wi-Fi my phone can route through home.

how i use it: piplay

Most of the time the NAS is a movie library. Browsers can't play most of the audio tracks in my MKV files (DTS-HD, TrueHD, AC3), and the file server hands the raw file to the browser without transcoding. So I wrote piplay, a shell function on my Mac that streams any file or folder straight into mpv.

piplay "movies/Paris, Texas (1984)"

give it a folder and it plays the biggest video inside, which is almost always the film and not the sample or the extras.

paths survive the trip: Film folders are full of spaces, parentheses and apostrophes, which break a remote shell command. The path is base64-encoded on the Mac and decoded on the Pi, so nothing needs escaping.
the Pi does the searching: Over SSH, the Pi lists the folder's videos by size and sends back the largest one. If there's nothing playable it says so before mpv ever opens.
mpv streams it: The Mac URL-encodes the path and opens it in mpv over HTTP. Range requests make seeking instant, and mpv remembers where I stopped, so Shift+Q and coming back tomorrow picks up at the same frame.

monitoring

The first thing on my wall tablet's dashboard is a live panel for the Pi: CPU, memory, both drives and every container. It's built on Beszel, but the panel itself is mine.

Beszel agent (Pi) → Beszel hub (Oracle) → Flask route → static panel in Home Assistant

The agent on the Pi reads system stats and the Docker socket. The hub runs on my Oracle Cloud VM, not the Pi, so if the Pi goes down the thing watching it doesn't go down with it. A private route in the same Flask API logs into the hub as a read-only user and flattens the latest sample into one small JSON response, and a plain HTML/CSS/JS page inside Home Assistant renders it. Beszel only shows drives you declare explicitly, so both mounts are listed by hand.

failures are loud: Each fetch has an 8 second timeout, and a timeout turns the panel red instead of quietly showing stale numbers in grey.
three caches: Every change to the panel's CSS or JS needs a version bump in two places, because the tablet's browser, the Home Assistant app and the dashboard's iframe each cache it separately. I learned that one the slow way.

construction

Raspberry Pi 5 Raspberry Pi OS Docker Compose ext4 rsnapshot copyparty Tailscale Cloudflare Tunnel nginx Flask Beszel Oracle Cloud mpv

Each service is its own Docker Compose project, and bind mounts always point at the parent directory rather than a single file, because anything that writes atomically (write a temp file, then rename it over the old one) breaks a single-file mount. Logs persist across reboots, capped at 500 MB.

architecture

internet → Cloudflare Tunnel → nginx → vedsite API / SquashHub
my devices → Tailscale → copyparty / Home Assistant → 4 TB SSD → nightly rsnapshot → 2 TB HDD

Public traffic only ever reaches the two things meant to be public, through Cloudflare. Everything else is private to my tailnet.

Source? This one is mostly configuration rather than code, so there's no repo. The API it runs is what powers my misc page.