Step-by-step guide to setting up a remote dev environment
A VPS (Virtual Private Server) is a remote computer in a data center that stays on 24/7. Hetzner is a German hosting provider with great prices. You'll rent one and use it as your always-on development machine.
Go to Hetzner Cloud Console and create a new project. Add a server:
SSH (Secure Shell) lets you control the remote server from your local terminal. Hetzner gives you an IP address after creation — use it to connect:
ssh root@<your-ip>
Running as root (the admin account) is risky — a typo can break everything. Create a regular user for daily work, with sudo for when you need admin privileges:
adduser axel
usermod -aG sudo axel
# Copy SSH key
mkdir -p /home/axel/.ssh
cp ~/.ssh/authorized_keys /home/axel/.ssh/
chown -R axel:axel /home/axel/.ssh
chmod 700 /home/axel/.ssh
chmod 600 /home/axel/.ssh/authorized_keys
Update all installed packages, then enable the firewall. UFW (Uncomplicated Firewall) blocks all incoming traffic except what you explicitly allow — here, just SSH so you can still connect:
# As root
apt update && apt upgrade -y
ufw allow OpenSSH
ufw enable
# Restrict root login to key-only (no password)
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
systemctl restart sshd
Claude Code is an AI coding assistant that runs in your terminal. It can read your files, write code, run commands, and handle git — like pair-programming with an AI. You'll install it on the VPS so it's always available.
The native installer downloads the CLI binary and sets up automatic updates:
# Native installer (recommended — auto-updates, no Node.js needed)
curl -fsSL https://claude.ai/install.sh | bash
claude --version
# Start claude and follow the login prompt
claude
# Or log in explicitly
claude /login
Supports Claude Pro/Max subscriptions, Console API keys, or enterprise providers (Bedrock, Vertex, Foundry).
tmux is a terminal multiplexer — it keeps your terminal sessions alive on the server even when you disconnect. Without it, closing your laptop would kill any running Claude session:
# Start a named session
tmux new -s claude
# Inside tmux, run claude
claude
# Detach: Ctrl+B, then D
# Reattach later:
tmux attach -t claude
You'll likely want to run Claude with flags like --dangerously-skip-permissions every time. A bash alias works when you type interactively inside tmux, but not when you pass a command directly to tmux:
# This does NOT work — tmux runs a non-interactive shell, aliases aren't loaded
alias claude="claude --dangerously-skip-permissions"
tmux new -s dev 'claude' # ← runs bare claude, ignores alias
The fix is a wrapper script — a real executable in your PATH that tmux can always find:
# Create ~/.local/bin/claude-wrapper
#!/bin/bash
exec env CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 /home/axel/.local/bin/claude --dangerously-skip-permissions "$@"
# Make it executable
chmod +x ~/.local/bin/claude-wrapper
# Optional: short symlink
ln -sf ~/.local/bin/claude-wrapper ~/.local/bin/cc
Now both work:
tmux new -s dev 'claude-wrapper'
tmux new -s dev 'cc'
~/.bashrc too for interactive use. The wrapper script is only needed for tmux new -s name 'command' style invocations.
Create ~/.tmux.conf with sensible defaults for working with Claude Code. Mouse mode lets you scroll output with the mouse wheel, and a large scrollback ensures you don't lose context:
# ~/.tmux.conf
set -g history-limit 50000
set -g mouse on
Reload in an existing session with tmux source-file ~/.tmux.conf.
Claude Code stores its configuration in ~/.claude/. These files are auto-reloaded — no restart needed.
Settings — ~/.claude/settings.json:
{
"model": "opus",
"outputStyle": "explanatory",
"fastMode": true,
"statusLine": {
"type": "command",
"command": "bash /home/axel/.claude/statusline-command.sh"
}
}
model — default model to use ("opus", "sonnet", or "haiku")outputStyle — "explanatory" gives fuller context for each action, "default" is concisefastMode — uses the same model with faster output generationstatusLine — custom shell script that receives JSON on stdin with model name, context usage, session durationKeybindings — ~/.claude/keybindings.json:
Defaults work well — Enter submits, and /vim inside Claude Code toggles vim-style editing. To customize, add bindings like this example that swaps Enter and Ctrl+S:
{
"bindings": [{
"context": "Chat",
"bindings": {
"ctrl+s": "chat:submit",
"enter": "chat:newline"
}
}]
}
Custom status line — ~/.claude/statusline-command.sh:
#!/bin/bash
input=$(cat)
model_name=$(echo "$input" | grep -o '"display_name":"[^"]*"' | head -1 | sed 's/"display_name":"//;s/"//')
used_pct=$(echo "$input" | grep -o '"used_percentage":[0-9.]*' | head -1 | sed 's/"used_percentage"://')
duration_ms=$(echo "$input" | grep -o '"total_duration_ms":[0-9]*' | head -1 | sed 's/"total_duration_ms"://')
[ -z "$model_name" ] && model_name="Unknown"
[ -z "$used_pct" ] && used_pct=0
[ -z "$duration_ms" ] && duration_ms=0
duration_s=$(( duration_ms / 1000 ))
duration_h=$(( duration_s / 3600 ))
duration_m=$(( (duration_s % 3600) / 60 ))
if [ "$duration_h" -gt 0 ]; then
duration_str="${duration_h}h ${duration_m}m"
else
duration_str="${duration_m}m $((duration_s % 60))s"
fi
used_pct_int=$(printf "%.0f" "$used_pct")
bar_width=20
filled=$(( (used_pct_int * bar_width) / 100 ))
empty=$(( bar_width - filled ))
if [ "$used_pct_int" -ge 80 ]; then color="\033[91m"
elif [ "$used_pct_int" -ge 60 ]; then color="\033[93m"
else color="\033[92m"; fi
reset="\033[0m"
bar="["
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
bar+="]"
printf "${color}%s${reset} %s ${color}%3d%%${reset} %s" \
"$model_name" "$bar" "$used_pct_int" "$duration_str"
Shows a color-coded context usage bar (green → yellow → red), model name, percentage used, and session duration. Looks like:
Opus 4.6 [████████████░░░░░░░░] 60% 12m 34s
CLAUDE.md at your project root. Claude reads it automatically at the start of every session.
Claude Code has a plugin system that extends its capabilities with skills (domain-specific knowledge loaded on demand), MCP servers (live tool integrations), and slash commands. Plugins are installed from marketplaces — curated collections hosted on GitHub.
A skill is a prompt that gets loaded when Claude detects a relevant task — for example, a Cloudflare Workers skill activates when you're writing Worker code. Skills are context-efficient: they only load when needed, unlike MCP tools which register upfront.
An MCP server gives Claude direct tool access — querying D1 databases, reading KV stores, checking build logs. These run as background processes and provide real-time data.
A marketplace is a GitHub repo that bundles plugins. You add the marketplace once, then install individual plugins from it.
The cloudflare/skills plugin is a comprehensive bundle covering the entire Cloudflare platform. Install it in two steps:
# 1. Add the marketplace (one-time)
/plugin marketplace add cloudflare/skills
# 2. Install the plugin
/plugin install cloudflare@cloudflare
Run these commands inside a Claude Code session. Restart Claude Code after installing to load the new skills.
Skills (loaded automatically when relevant):
cloudflare:cloudflare — comprehensive platform skill (Workers, D1, KV, R2, AI, networking, security)cloudflare:workers-best-practices — reviews Worker code against production patternscloudflare:durable-objects — stateful coordination, SQLite storage, WebSocketscloudflare:agents-sdk — AI agents with state management and real-time WebSocketscloudflare:wrangler — CLI commands, config, and deployment best practicescloudflare:build-mcp / cloudflare:build-agent — scaffolding for MCP servers and AI agentscloudflare:web-perf — Core Web Vitals analysis and Lighthouse auditscloudflare:sandbox-sdk — secure code execution environmentsMCP servers (live tool access):
/plugin list.
Plugin data lives in ~/.claude/plugins/:
~/.claude/plugins/
known_marketplaces.json # Registered marketplaces
installed_plugins.json # Installed plugin versions
marketplaces/ # Cloned marketplace repos
cache/ # Installed plugin files
Cloudflare sits between users and your server. It provides DNS (translating domain names to IP addresses), a global edge network (serving content from 300+ locations worldwide), and tunnels (secure connections between your VPS and Cloudflare without opening ports).
To use Cloudflare, you add your domain and point its nameservers to Cloudflare. This means Cloudflare handles all DNS for that domain:
cloudflared is the client that runs on your VPS and establishes an encrypted tunnel to Cloudflare's edge. This lets you expose services (like SSH) without opening firewall ports:
# Add Cloudflare GPG key and repo
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared noble main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
# Install
sudo apt-get update && sudo apt-get install -y cloudflared
cloudflared --version
cloudflared tunnel login
# Opens a URL — copy it to your browser and authorize
A tunnel is a persistent, encrypted connection from your VPS to Cloudflare. Traffic flows through it instead of directly to your server's IP:
# Create
cloudflared tunnel create my-tunnel
# Note the tunnel ID (UUID) from the output
# Config file is at ~/.cloudflared/config.yml
The config file maps hostnames to local services. Here, requests to ssh.yourdomain.com get routed to your VPS's SSH server:
# ~/.cloudflared/config.yml
tunnel: <TUNNEL-ID>
credentials-file: /home/axel/.cloudflared/<TUNNEL-ID>.json
ingress:
- hostname: ssh.yourdomain.com
service: ssh://localhost:22
- service: http_status:404
systemd manages background services on Linux. Installing cloudflared as a service means it starts automatically on boot and restarts if it crashes:
sudo cloudflared service install
sudo systemctl status cloudflared
This creates a DNS record so that ssh.yourdomain.com routes through your tunnel:
cloudflared tunnel route dns my-tunnel ssh.yourdomain.com
Cloudflare Workers are serverless functions that run on Cloudflare's edge network — your code runs in 300+ data centers worldwide, close to your users. No servers to manage, no scaling to worry about. You write code, deploy it, and Cloudflare handles the rest.
Node.js is a JavaScript runtime. Wrangler (the Workers CLI) requires it. The NodeSource repository provides up-to-date LTS versions:
# Add NodeSource repo and install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version
Wrangler is Cloudflare's CLI tool for creating, testing, and deploying Workers:
npm install -g wrangler
Wrangler needs to know which Cloudflare account to deploy to. On a headless VPS (no browser), use API keys stored in a file that's auto-sourced on login:
# Create ~/.env.cloudflare:
export CLOUDFLARE_EMAIL="you@example.com"
export CLOUDFLARE_API_KEY="your-global-api-key"
# Auto-source in ~/.bashrc (see Step 5)
[ -f ~/.env.cloudflare ] && source ~/.env.cloudflare
CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY for wrangler on a headless VPS.
A Worker project is just a folder with a config file (wrangler.toml) and a TypeScript entry point. Hono is a lightweight web framework that makes routing easy:
mkdir -p ~/projects/my-worker && cd ~/projects/my-worker
npm init -y
npm install hono
npm install -D wrangler @cloudflare/workers-types typescript
This config file tells wrangler your worker's name, entry point, and what domain to serve it on:
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"
routes = [
{ pattern = "app.yourdomain.com", custom_domain = true }
]
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Cloudflare Workers!'));
export default app;
This uploads your code to Cloudflare's edge. Within seconds, it's live worldwide:
npx wrangler deploy
With everything set up, here's how the pieces connect. You SSH into the VPS, use Claude Code to build things, and deploy to Cloudflare's edge — all from the terminal.
This is your daily workflow. SSH in, reattach to your tmux session where Claude is running, build something, and ship it:
# 1. SSH into VPS (through CF tunnel or direct)
ssh axel@ssh.yourdomain.com
# 2. Attach to tmux
tmux attach -t claude
# 3. Claude Code builds your worker
claude "Create a worker that does X"
# 4. Deploy
cd ~/projects/my-worker
npx wrangler deploy
~/projects/
my-worker/
src/
index.ts # Hono app
page.ts # HTML templates
client/ # React/Vite client code (if needed)
public/ # Vite build output (git-ignored)
wrangler.toml
package.json
vite.config.ts # If you have client-side JS
Instead of remembering to source credentials before every deploy, add this to ~/.bashrc so it happens automatically on login:
# Add to ~/.bashrc
[ -f ~/.env.cloudflare ] && source ~/.env.cloudflare
npx wrangler deploy just works — no manual source neededwrangler tail for live logswrangler.toml bindings