How I Turned My Home Server into a Remote Dev Environment (and Ditched Cloud IDEs for Good)
A practical guide to self-hosted dev boxes with Tailscale, Docker, and Claude Code
I got tired of paying for cloud dev environments that felt sluggish, had opinionated limitations, and disappeared whenever I closed a browser tab. My home server was sitting there with a 12-core CPU and plenty of RAM — so I built my own remote dev setup. It’s been running for a few months now and I haven’t looked back.
Here’s exactly how it works.
The Core Idea
The setup is built around two principles:
- The dev environment lives in a Docker container — fully reproducible, version-controlled, and disposable if something breaks
- Tailscale handles the network — no port forwarding, no VPN headaches, it just works from anywhere
Each “dev box” is a Docker Compose stack with two containers: a Tailscale sidecar that owns the network namespace, and the main dev container that borrows it. Your IDE connects over SSH to the Tailscale IP — same as if the box were sitting next to you.
The Stack
Host: Ubuntu 24.04, managed by CasaOS
Container image: python:3.12-bookworm (Debian base, everything available)
Network: Tailscale sidecar pattern
Access: SSH, JupyterLab, dev server ports — all via Tailscale
The Compose Pattern
The trick that makes this work is network_mode: service:tailscale. The Tailscale container gets its own network namespace and an IP on your tailnet. The main container borrows that namespace entirely — so any traffic from your dev box goes through Tailscale automatically.
services:
devbox:
image: python:3.12-bookworm
network_mode: service:tailscale
depends_on:
tailscale:
condition: service_started
tailscale:
image: tailscale/tailscale:latest
environment:
TS_AUTHKEY: tskey-auth-... # reusable key from admin console
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
cap_add:
- NET_ADMIN
- SYS_MODULE
devices:
- /dev/net/tun:/dev/net/tun
ports:
- "2222:22" # SSH
- "8889:8888" # JupyterLab
- "8086:8080" # Dev server
volumes:
- ./tailscale-state:/var/lib/tailscale
A few gotchas I hit:
- Don’t set
hostnameon any container that usesnetwork_mode: service:X— newer Docker Engine versions hard-reject this combination - Use a reusable Tailscale auth key, not ephemeral — otherwise every container recreate invalidates the key
- If Tailscale complains about “changing settings requires mentioning all flags”, add
TS_EXTRA_ARGS: --resetto clear stale state
What’s Inside the Container
The first boot runs a setup script that installs everything and drops a marker file in a bind-mounted directory. Subsequent boots skip straight to starting SSH.
The full tool list:
- Languages: Python 3.12, Node.js 20
- Python: JupyterLab, pandas, numpy, scikit-learn, FastAPI, uvicorn, pytest
- Shell: zsh + oh-my-zsh + starship prompt + zoxide + fzf
- CLI tools: lazygit, eza, bat, ripgrep, fd, neovim, tmux, gh, glab
- AI: Claude Code, Ollama CLI (pointed at my local Ollama server)
- IDE support: JetBrains remote dev (config/cache/local dirs all bind-mounted)
The AI tooling deserves its own mention. Claude Code running inside the container, with the Ollama server address set to the host machine, means I can run models locally for code completions while Claude handles the heavier reasoning tasks. It’s a surprisingly good combination.
Persistent State via Bind Mounts
The container image is ephemeral — whenever it’s recreated, packages are gone. The solution is to bind-mount everything that should survive:
volumes:
- /srv/appdata/devbox/ssh:/root/.ssh
- /srv/appdata/devbox/claude_config:/root/.claude
- /srv/appdata/devbox/npm_global:/root/.npm-global
- /srv/appdata/devbox/shell_history:/root/.shell_history_store
- /srv/appdata/devbox/gh_config:/root/.config/gh
- /srv/documents/projects:/workspace
SSH keys, Claude config, npm globals, shell history, GitHub CLI auth — all persisted. The workspace is a bind mount from the host so files survive regardless of what happens to the container.
The setup marker (/opt/devbox/.setup_done) also lives in a bind mount. If the marker exists, the container skips the 15-minute first-boot setup and jumps straight to sshd -D.
Connecting from an IDE
From JetBrains (Gateway or any IDE with remote dev support):
- Add an SSH connection:
root@<tailscale-ip>on port2222 - Open a remote project pointing at
/workspace - Done — the IDE runs on the server, only the UI runs locally
From VS Code it’s the same: Remote-SSH extension, connect to the Tailscale IP.
The JetBrains cache and config directories are bind-mounted on the server side, so IDE indexes persist across container recreates. First reconnect after a recreate rebuilds the index; after that it’s instant.
Running Multiple Boxes
I run two stacks — one for personal projects, one for work. They’re identical in structure, just different ports and Tailscale machine names.
CasaOS manages both stacks and restarts them on boot. Each has its own workspace directory, its own SSH key pair, and its own Tailscale machine on my tailnet. From the client side they just look like two SSH hosts.
The Tailscale admin console lets me see which boxes are online, their IPs, and last-seen time — effectively a dashboard for my remote dev fleet.
What I’d Do Differently
Build a custom image instead of the setup script. The current approach runs 15+ minutes of setup on first boot. A Dockerfile that bakes everything in would make container recreates instant. I’ll get around to it eventually.
Use a non-root user. Running everything as root inside the container is fine for a personal setup but not great practice. A proper devuser with sudo access would be cleaner.
Add health checks. Right now if sshd crashes inside the container, Docker thinks the container is still running (it is — the process that exits is sshd, not the entrypoint). A proper healthcheck that pings the SSH port would catch this.
The Result
From anywhere — a laptop on a coffee shop WiFi, a tablet at home, a work machine — I open my IDE and I’m in a full dev environment with my tools, my history, my keys, and 16 GB of RAM. No browser-based editor limitations, no per-seat pricing, no cold starts.
The whole thing runs on hardware I already owned. The only running cost is electricity.
If you have questions or want to share how you’ve set up something similar, drop a comment below.