Bye Bye Digital Ocean
I’ve been running Nextcloud on a DigitalOcean droplet for a while. It works fine, but it costs money every month for something that’s essentially sitting idle most of the time — a personal file sync server with one user doesn’t need a cloud VM. A Raspberry Pi 5 sitting on my desk does the same job for free.
This post covers the full migration: tearing down the DO setup, getting Nextcloud running locally on the Pi 5, and setting up Tailscale so I can still reach it remotely without exposing anything to the public internet.
The obvious candidate for a home server was my Pi Zero 2W, which was previously running Pi-hole. I didn’t use it for that in the end — 512MB RAM is genuinely not enough for Nextcloud. PHP, MariaDB, and Apache running simultaneously will fight over that memory constantly, and you’ll spend more time debugging OOM kills than actually using your files. The Zero 2W is a good device for lightweight tasks; Nextcloud isn’t one of them.
The Pi 5 2GB is the right call. It’s not lavishly specced, but it’s workable for a single-user instance with light traffic. The tradeoff is that my Pi 5 was previously my Kismet wardrive rig. Before wiping it, I pulled the Kismet config off the SD card while it was mounted on my laptop:
| |
That backup lives in a private Git repo. Restoring the wardrive setup later is a reflash and a cp.
OS and Base Setup
Raspberry Pi OS Lite 64-bit, headless. Same reasoning as the Kismet build — no desktop, no overhead, Avahi handles .local resolution so SSH just works via ssh nick@corvus-cloud.local once it’s on the network.
One thing worth knowing: on first boot, Pi OS kicks off an automatic apt run in the background. If you immediately try to run apt update && apt upgrade yourself, you’ll hit a lock error:
This isn’t a problem. Just watch it finish:
| |
Then run your own update once it’s done.
Docker installs cleanly via the convenience script:
| |
Log out and back in after the usermod or Docker commands will fail with a permission error. Docker Compose came bundled with the Docker install on the current Pi OS packages, so no separate installation was needed.
Nextcloud Stack
I went with a simple Docker Compose setup: Nextcloud and MariaDB, nothing else. No Redis, no reverse proxy layer — for a single-user Tailscale-only instance, the overhead isn’t worth it.
| |
restart: always on both services means everything comes back up automatically after a power cycle. That matters for a device that might lose power without warning.
The first docker compose up -d failed with an EOF from Docker Hub — a transient pull failure. Running it again worked fine.
Tailscale Setup
The whole point of moving off the cloud is to stop paying for a public IP. The replacement for remote access is Tailscale.
Tailscale works by having every device — the Pi, my laptop, my phone — connect outbound to Tailscale’s coordination servers, which broker a WireGuard tunnel between them. Because the tunnel negotiation is outbound from both sides, no port forwarding is needed on either end. Once the tunnel exists, traffic flows both ways through it. On networks where a direct peer-to-peer tunnel can’t be established, Tailscale falls back to relaying through their DERP servers, but direct tunnels are preferred.
The result is that 100.64.76.39 (the Pi’s Tailscale IP) is reachable from any device on my Tailnet, regardless of what network either device is on, without anything being exposed to the public internet.
Install on Pi OS:
| |
On EndeavourOS:
| |
Both prompt you to authenticate via a URL. After that they’re on the Tailnet and can reach each other.
I also updated my DuckDNS record to point <hostname>.duckdns.org at the Tailscale IP. Since Tailscale IPs are in the 100.64.x.x range and non-routable on the public internet, anyone querying the hostname gets a useless IP unless they’re on the Tailnet. It’s not private DNS, but it’s not a meaningful exposure either.
One issue: Firefox was preferring an IPv6 address for the DuckDNS hostname that still pointed at the old DO droplet. Clearing the HSTS cache for the domain (right-click the entry in history, “Forget about this site”) fixed it. If you’re getting SSL errors when you know the server is HTTP-only, this is probably why.
The Migration
The DO setup was Nextcloud AIO — the all-in-one Docker stack with PostgreSQL, Redis, and an Apache reverse proxy. This is meaningfully different from the MariaDB setup I’d built on the Pi, so a direct database migration wasn’t really on the table. For a single user with about 1GB of data, the sensible approach was to copy the files directly and let Nextcloud re-index them.
Finding the actual data took some digging. The AIO container maps data to /mnt on the host rather than inside the container:
| |
With the data location confirmed, I streamed a tar archive from the droplet to the Pi via my laptop, which had SSH access to both:
| |
No intermediate storage needed. The archive goes straight through the laptop into the Pi.
Extracting into the Docker volume:
| |
The --strip-components=3 drops the /mnt/ncdata/admin prefix so the files land directly in the data directory. Nextcloud’s default install had already created Documents and Photos directories, so those needed merging rather than a straight move. After sorting that out and fixing ownership:
| |
Trigger a filesystem rescan:
| |
Output:
381 files indexed. Everything was there.
Trusted Domains
Nextcloud maintains a whitelist of hostnames it will respond to. Out of the box it only trusts whatever hostname was used during initial setup. Add any additional ones via occ:
| |
Without this, accessing via any hostname not on the list gives you an “Access through untrusted domain” error.
Result
Nextcloud is running on the Pi 5, accessible at http://<tailscale-ip>:8080 from any device on the Tailnet. The DO droplet is gone. The iOS Nextcloud app works fine over Tailscale — install the Tailscale app, join the Tailnet, point Nextcloud at the IP.
The main risk with this setup is the SD card. Docker volumes live on it, and SD cards die without much warning. A periodic backup of the Docker volumes to external storage is the obvious next step, but that’s a separate post.
The Kismet rig is on hold for now. It’ll come back. For now, I really need a consistent file storage server that I can access anywhere, as I am working on my dissertation for school :)
🐦⬛