Anyone on home broadband without a public IPv4 knows the situation by heart: a Synology NAS with 7TB of storage and enough CPU to run Docker, unreachable outside the house except through the sluggish QuickConnect relay; meanwhile a 2-core/2GB cloud VPS carries the entire production stack — task board, blog, push notifications. Compute sits idle at home while the cloud box runs out of memory. On 2026-09-16, across two agent sessions (Claude Code in the morning, ZCode in the afternoon), I turned this completely around: four devices plus the cloud server woven into one Tailscale network, production moved onto the NAS, and the cloud reduced to a doorway.
The day split into three phases:
| Phase | Time | Agent | What happened |
|---|---|---|---|
| Network foundation | Morning | Claude Code | Four devices joined the tailnet, bare git repos on the NAS, one static UDP mapping on the optical modem — off-site direct connections became real |
| Cloud joins, then a retreat | Noon | ZCode | Cloud VM joined the tailnet (10ms direct), a public NAS gateway was built and deliberately torn down the same day |
| Production moves home | Afternoon | ZCode | Task board + blog migrated into NAS Docker, full database migration, cloud cleanup; by evening Claude Code moved the blog's primary git repo into the NAS bare repo too |
Morning: The Network Foundation — One UDP Mapping Worth 125×
The first-order problem of home self-hosting is reachability. The fleet: a MacBook (travels), a Windows desktop and a Raspberry Pi (fixed at home), and a Synology DS923+ (DSM 7.3.2, AMD R1600, 7TB). Installing Tailscale was uneventful — brew install --cask tailscale-app on the Mac, winget on Windows, the official script on the Pi. Both real traps were on the NAS:
The Synology Package Center doesn't list Tailscale. The official distribution lives at pkgs.tailscale.com/stable/; pick the tailscale-x86_64-<ver>-...-dsm7.spk for your architecture and verify against the published .sha256 before manual installation. After installing, the web panel often fails to show a login link — the CLI is the reliable path:
ssh -t nas 'sudo /var/packages/Tailscale/target/bin/tailscale set --operator=guancyxx'
ssh -t nas 'sudo /var/packages/Tailscale/target/bin/tailscale up'
The --operator switch is one-time; afterwards status/netcheck no longer need root.
With four devices enrolled, off-site access worked — but through DERP relay. Measured on 2026-09-16, with the Mac on a phone hotspot: 420ms RTT (peaking at 900ms), roughly 17KB/s throughput, and a git ls-remote taking 7.7 seconds. At that speed, cloning a 56MB repository takes close to an hour. The nearest relay was in Los Angeles. 25 pings over 60 seconds never upgraded to a direct connection — this was not a "wait it out" situation.
The bottleneck was the ISP optical modem (a China Telecom smart gateway): its regular useradmin account hides both UPnP and port mapping behind a super-admin login. The fix is one static mapping — UDP 41641 → the NAS's 41641 (Tailscale's default port). Two details matter:
- Do not use DMZ. The goal is exposing exactly one UDP port; DMZ would put DSM on 5000/5001 and SMB on 445 naked on the public internet.
tailscale netcheck'sPortMapping:field will not reflect a manual mapping — it only reports UPnP/NAT-PMP/PCP negotiations. Using it to verify the mapping produces a false negative.
After the mapping, same hotspot: RTT 42ms, throughput 2.1MB/s, git ls-remote 0.55s, and the 56MB clone in 27 seconds. One port, roughly 125× the throughput:
This phase also settled a second matter: the NAS's /volume1/git directory had existed, unused, since March 2025. DSM ships git 2.39.1 built in, so git init --bare is all the server side needs. The traps are all SSH: the "User Home Service" must be enabled in Control Panel first — otherwise the home directory doesn't exist, authorized_keys has nowhere to live, and ssh-copy-id reports success while login still fails. Then the permissions: 0711 on the home directory, 0700 on .ssh, 0600 on the key file — Synology's sshd silently ignores public keys when these are too loose. One more misdirection: DSM disables the SFTP subsystem by default, so scp/rsync both fail — move files with cat local | ssh nas 'cat > remote'. Git over SSH is unaffected; it speaks git-upload-pack, not SFTP.
At this point my Obsidian vault started syncing across four machines through the NAS bare repo (the same problem space I had surveyed across eight ecosystems earlier — that analysis had recommended GitHub as the backbone and rejected self-hosting; this implementation overturned that conclusion, and the original note has been corrected in place).
Noon: The Cloud Joins — and a Public Gateway That Lived for a Few Hours
With the mesh in place, the natural next thought runs the other direction: the cloud VM has a fixed public IP. Connect it to the NAS, let the NAS be the server and the cloud be the gateway?
The ZCode session installed Tailscale on the cloud VM and joined the same tailnet. An IDC box with a public address punches through NAT without suspense — tailscale ping showed a 10ms direct path between cloud and NAS. At this point, every pipe needed for "cloud as the front door, NAS as the datacenter" existed.
Then came the first real decision: should DSM itself go on the public internet? We actually built it — wildcard DNS pointed at the cloud, nginx proxying DSM, a Let's Encrypt certificate, Basic Auth as a second layer, verified end to end. And tore it down the same afternoon. The reasoning is worth writing down:
- Your own devices never needed that entrance. All four live in the tailnet; reaching the NAS is a single hop to its 100.x address, faster than any detour through the public internet.
- DSM is a management plane, not a service plane. Exposing it means putting the machine's highest-privilege interface in front of scanners — the nginx logs already held foreign IPs probing port 443 that day. Two auth layers reduce probability; they do not remove attack surface.
- What deserves public access is specific services, and those can ride the "cloud gateway → tunnel → NAS container" path. The management plane never leaves home.
Building it and tearing it down was not rework — it clarified the security boundary: the NAS itself never goes public; only specific service ports reached through the cloud gateway do. Every design in the next phase stands on that line.
Afternoon: Production Moves Home
With the architecture fixed, the migration itself is standard work. The topology:
Engineering decisions worth recording:
The entry point changed by exactly one upstream. The cloud nginx for tasks.guancyxx.cn used to split "local static root + /api to a local service." After migration the whole site collapsed into a single proxy_pass at the web container across the tunnel — static serving and API routing both moved down into the NAS. The cloud config went from "part of the deployment" to "pure pipe," which means application changes never touch the cloud again.
The deployment philosophy moved verbatim. The old cloud auto-deploy used marker gating: a .deployed-commit file records the commit that was actually deployed and health-checked, not git HEAD — a manual git pull on the box can't fool the skip check, and any failed step leaves the marker untouched so the next run retries. We rewrote this on the NAS with docker compose, semantics identical: merging a PR to main goes live within 15 minutes. The blog's equivalent script runs every 2 hours. When migrating a deployment system, the thing to preserve is not the scripts but these invariants.
Data migration moves all three files. A SQLite database in WAL mode comes with taskdeck.db, -wal, and -shm — all three travel together (the WAL held 1.7MB of unmerged data), with byte-size verification before restarting containers. The task board's full dataset: 662 tasks / 23 projects / 8 users / 718 comments / 2,670 activity records, schema at alembic 0005, nothing lost.
The publishing pipeline moved home last. In the evening, a Claude Code session migrated the blog's primary repo from GitHub to the NAS bare repo (/volume1/git/guancyxx.cn.git), and the NAS deploy checkout's origin now points at that local path — fetch never leaves the machine, push never leaves the country:
Cloud cleanup: the old systemd service disabled, 13 stopped containers and 3 images removed, old deployment directories deleted. Three things now live on that 2-core/2GB box: nginx, Tailscale, ntfy. Memory footprint 365Mi, about 2GB of disk freed (measured 2026-09-16).
The Pitfall List
The trap density that day was remarkable, and it spanned both agent sessions — worth its own section, listed as "what it looked like → what it actually was":
| # | Symptom | Truth and fix |
|---|---|---|
| 1 | Binding a container to the Tailscale IP failed with cannot assign requested address |
Synology's Tailscale runs in userspace-networking mode (no tailscale0 interface). The morning session had already observed "no interface on the NAS, inbound proxied to loopback"; the afternoon session turned that observation into a conclusion by hitting the docker bind. Fix: bind 0.0.0.0 and let tailscale forward inbound to localhost. Cost: see #7 |
| 2 | Fixed at-home machines got 345ms to the NAS through Tailscale | Optical modem hairpinning: traffic detours to a public endpoint and back. Machines in the same /24 should use the LAN IP directly; SSH configs split "mobile machines via MagicDNS, fixed machines via LAN" |
| 3 | After overwriting the DSM docker daemon config, all old images "vanished" | The DSM config file is dockerd.json (not docker.json), and fields like data-root / storage-driver: btrfs must survive — merge registry mirrors into the original config instead of rewriting it |
| 4 | The NAS couldn't pull from Docker Hub | Home broadband can't reach the registry. Daemon registry-mirrors pointed at a domestic mirror fixed it |
| 5 | nginx behavior didn't change after editing + reload | The Ubuntu sites-enabled/ entry was a standalone file, not a symlink to sites-available/ — the wrong file got edited. Fixed by converting to the canonical symlink |
| 6 | After container recreation, every /api call returned 502 |
nginx:alpine resolves proxy_pass http://api:8321 once at startup and hardcodes it; the api container gets a new IP on every deploy. Fix: resolver 127.0.0.11 valid=10s plus a variable-form proxy_pass for dynamic resolution |
| 7 | Three hidden costs of userspace mode | Throughput below TUN mode; the NAS cannot act as a subnet router or exit node; every source-IP-based NAS firewall rule is blind to Tailscale traffic (it all looks like localhost) — a 0.0.0.0-bound port is effectively exposed to the entire LAN. Acceptable on a home network, but know what you're accepting |
| 8 | nc on the NAS reported "all ports closed"; ping errored yet still exited 0 |
nc doesn't exist and ping lacks root — the tools themselves were broken, so every verdict they produced was a false negative. On an unfamiliar machine, sanity-check the tool against a known-open port before trusting its output |
#8 deserves an extra sentence because it's the meta-lesson: while troubleshooting connectivity on the NAS, the fake "all ports closed" result misdirected the investigation for a long time. Any automated flow that reasons from tool output should first verify the tool itself works — true for humans, true for agents.
What Belongs Home, and What Doesn't
With the migration done, the judgment. This architecture holds only when the traffic profile fits:
Belongs at home: task boards, blogs — small-traffic web/API workloads with KB-sized requests and two-digit daily actives; home upload bandwidth handles them with headroom. Bare git repos — pushes and pulls are naturally sparse, and 2.1MB/s direct connections make them imperceptible. And everything only you use: tailnet-direct beats any public route.
Doesn't belong at home: anything with an availability commitment. This architecture's uptime ceiling is home power × optical modem × upstream broadband — an outage or ISP maintenance takes production down. There is no cloud SLA to fall back on, only the degraded "the doorway is up, the backend isn't" experience. Large-download workloads don't fit either: every public byte rides "home upload → tunnel → cloud egress," with the few-Mbps cloud bandwidth and home upload as twin ceilings.
One more issue surfaced that day: with production data fully back on the NAS, backups stopped being the cloud provider's problem and became mine. Right now there's exactly one snapshot — the migration-time copy — sitting next to the live database (same disk, same machine; strictly speaking, not a backup). NAS-level scheduled backup is the missing piece this architecture needs before anyone calls it done — and its severity is the same as when I audited agent data safety.
Conclusion
One day, two agent sessions, one tailnet — what remains is a clean chain: public internet → cloud nginx (TLS termination) → 10ms Tailscale direct tunnel → NAS Docker → local database and bare repos. The cloud VM went from "all of production" to "a doorway," and 7TB of home storage carries both service and data roles for the first time.
Looking back, the most valuable artifacts of the day weren't any single technique but two decisions: the static mapping on the optical modem, precise to a single UDP port (instead of the lazy DMZ), and the boundary "the NAS never goes public; only specific services proxy through the cloud gateway." The former set the performance ceiling; the latter set the security floor. Every pitfall in between was, in essence, filling in details for those two decisions.
References
- Tailscale — Official site and documentation
- Tailscale — Official spk distribution directory for Synology (pick the
-dsm7package for your architecture and verify the.sha256) - Docker — Daemon configuration and registry-mirrors documentation
- nginx — Official documentation