My personal setup for configuring a VPS as a secure remote development environment. The main goal: persistent Claude Code sessions that survive disconnects, enabling long-running AI agent workflows without tying up my laptop.
- Offload work from my laptop - Let heavy AI tasks run on a VPS while I do other things
- Persistent sessions - SSH disconnects? No problem. tmux keeps Claude Code running
- Decentralized project storage - Code lives on the VPS, accessible from anywhere
- Secure by default - Tailscale-only access, no exposed ports, no SSH keys to manage
Internet ──X──> VPS (all ports blocked)
│
Tailnet ────────> VPS (Tailscale SSH only)
- No public SSH - OpenSSH disabled entirely
- No open ports - UFW blocks everything except Tailscale
- Tailscale SSH - Authenticate with your identity provider
- AWS SSO - Temporary credentials, nothing long-lived on disk
| Layer | What | How it gets onto a machine |
|---|---|---|
| Agent config (any OS) | ~/.claude instructions, settings, hooks; global skills for Claude + Codex + OpenCode; tool versions via mise |
./bootstrap once, then envup (= git pull && ./apply) |
| Ubuntu box | Tailscale, UFW, Docker, zsh/tmux, shell dotfiles via stow | ./setup (interactive menu) |
git clone https://github.com/ohitslaurence/environment.git ~/dev/environment # on a Mac: ln -s ~/Development ~/dev first
~/dev/environment/bootstrap # installs mise + Claude Code, then ./apply./apply is idempotent and is the only thing that touches ~/.claude, ~/.agents, ~/.codex,
~/.config/opencode, ~/.config/mise. Runtime state (sessions, credentials, history) stays in
real directories; only tracked config is symlinked into the repo.
Per machine, never synced: gh auth login, claude login, MCP servers that need keys (claude mcp add -s user ...).
envup # pull + re-link (run on the machine that is behind)
npx skills update -g -y # upgrade upstream skills; then gritty commit --accept && git push
npx skills add mattpocock/skills -g -a claude-code,codex -s tdd,triage -y # add more
mise upgrade # bump tool versions (config.toml is tracked)Skills live in home/.agents/skills/. Upstream ones are recorded in home/.agents/.skill-lock.json
(tracked) so skills update knows their source; your own skills sit beside them with no lock entry.
Tested on Ubuntu 24.04 (Server / Cloud / Desktop). Plan ~30 min: most of it is automated, but several steps open browser windows for auth.
- Tailscale account — sign up at tailscale.com (free for personal). Decide what hostname you want this machine to be.
- GitHub account — you'll authenticate via browser
- AWS SSO start URL + region (only if you use AWS —
https://<org>.awsapps.com/start)
# Set a system hostname (matches what you'll use in Tailscale)
sudo hostnamectl set-hostname my-machine
# Install git + clone
sudo apt update && sudo apt install -y git
mkdir -p ~/dev
git clone https://github.com/ohitslaurence/environment.git ~/dev/environment
cd ~/dev/environment
# Run the setup
./setupIf you're SSH'd in as root on a fresh cloud image, the setup will refuse to continue and walk you through creating a regular sudo user first. Switch to that user and re-run.
The interactive menu (powered by gum) shows step status; pick Run All Remaining.
You'll hit these prompts in order — keep a browser handy:
| Step | What you'll do |
|---|---|
tailscale |
Confirm hostname → browser opens for Tailscale auth |
dotfiles |
Enter git email + name → GPG key auto-generates → public key printed (add to GitHub later) |
gh_auth |
Browser opens for GitHub CLI login (HTTPS, with admin:public_key scope) |
github_ssh |
Generates a new SSH key → offers to upload to GitHub via gh (say yes) |
aws_sso |
Enter SSO URL + region + profile name → run aws sso login after |
Progress is saved to ~/.config/vps-setup/state.json — interrupt anytime and re-running picks up where you left off.
# Re-login so docker group membership takes effect (or `newgrp docker`)
exit # then SSH back in
# Authenticate AWS SSO (if you set it up)
aws sso login --profile <your-profile>
# Add your GPG public key to GitHub for verified commits
# (printed during the dotfiles step; also: gpg --armor --export <KEY_ID>)
# Paste at: https://github.com/settings/gpg/new
# Verify everything looks right
bash scripts/analyze.shanalyze.sh should show ✓ on Tailscale, UFW, OpenSSH disabled, Docker default-localhost binding, no exposed containers, and no state drift.
- Cloud provider MFA — enable on your VPS provider account
- GitHub MFA — enable hardware key or authenticator at github.com/settings/security
- Tailscale ACLs — by default everyone in your tailnet can reach this box; tighten in the Tailscale admin console if needed
- Cloud provider firewall — if your VPS provider has its own firewall (DigitalOcean, Hetzner Cloud, etc.), keep all inbound blocked there too as a second layer
./setup # interactive menu, pick "Select Steps"
# or
bash steps/<name>.sh # run one directly| Category | Tools |
|---|---|
| Security | Tailscale SSH, UFW, auto-updates |
| Core | git, curl, build-essential, jq, stow |
| Modern CLI | eza, bat, zoxide, fzf, direnv |
| TUI | lazygit, lazydocker, htop, neovim |
| Runtime | Docker, Node.js (fnm), Bun, pnpm |
| AI | Claude Code, OpenCode, Codex |
| Sync | Syncthing (file sync to laptop) |
| Shell | zsh, tmux with persistence |
| Git | GPG commit signing, GitHub CLI |
| Cloud | AWS CLI with IAM Identity Center |
On your laptop, add to ~/.ssh/config for easy access:
Host <tailscale-hostname>
User <username>
Then:
# SSH in via Tailscale MagicDNS
ssh <tailscale-hostname>
# Start a persistent session
tmux new -s agent
# Run Claude Code
claude
# Detach anytime (Ctrl-w d — prefix is Ctrl-w)
# Reconnect later
tmux attach -t agentClaude keeps working even when you disconnect. Check back hours later and see what it's done.
c # claude
cc # claude --dangerously-skip-permissions (unrestricted mode)
ccu # ccusage - token usage and cost tracking
lg # lazygit
lzd # lazydocker
lt # tree view (eza)
z <dir> # smart cd (zoxide)ccu daily # Daily token usage and costs
ccu monthly # Monthly aggregated report
ccu session # Usage by conversation session
ccu blocks # 5-hour billing windows
ccu blocks --live # Real-time usage dashboardManaged with GNU Stow. Tracked files in home/:
.zshrc- vi-mode, modern CLI aliases, tool integrations.tmux.conf- Ctrl-w prefix (avoids Claude Code conflicts), vim navigation, tmux-resurrect + tmux-continuum (auto-save every 10 min, auto-restore on launch).gitconfig- GPG signing, sensible defaults.claude/settings.json.template- seeds~/.claude/settings.jsonon first run.config/opencode/opencode.json.template- seeds~/.config/opencode/opencode.jsonon first run.claude/hooks/,.agents/skills/- Claude Code hooks and skill packages
Configs are version controlled but secrets stay local. The pattern:
- Tracked configs in
home/contain no secrets — stow symlinks them to~ ~/.claude/settings.jsonand~/.config/opencode/opencode.jsonare gitignored. They're seeded from*.templateon first run, then you can add machine-specific MCP servers / API keys without leaking to the repo.- Long-lived shell secrets go in
~/.zshrc.local(sourced by.zshrc, not in git) - AI CLIs handle their own auth:
claude(browser),codex(browser),gh auth login(browser)
Inspired by thdxr/environment from Dax at SST.