Hetzner + Cloudflare + Claude Code

Step-by-step guide to setting up a remote dev environment

Architecture Overview

Loading diagram...
Pan and zoom to explore. Drag nodes to rearrange.

1 Hetzner VPS

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.

Order a server

Go to Hetzner Cloud Console and create a new project. Add a server:

Initial SSH

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>

Create a user

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

Firewall & basics

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
Tip: After disabling root login, verify you can SSH as your user before closing the root session.

2 Claude Code

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.

Install Claude Code

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

Authenticate

# 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).

Tip: The native installer handles updates automatically. No need for nvm or npm.

tmux setup

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

Alias vs. wrapper script

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'
Tip: Keep the alias in ~/.bashrc too for interactive use. The wrapper script is only needed for tmux new -s name 'command' style invocations.

tmux config

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.

Tip: With mouse mode on, hold Shift while selecting text to use your terminal's native copy behavior instead of tmux's.

Claude Code config

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"
  }
}

Keybindings~/.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
Tip: Project-specific instructions go in CLAUDE.md at your project root. Claude reads it automatically at the start of every session.

3 Plugins & Skills

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.

How plugins work

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.

Install the Cloudflare Skills plugin

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.

What it includes

Skills (loaded automatically when relevant):

MCP servers (live tool access):

Tip: Plugins are per-user. Each user on the machine needs to run the install commands in their own Claude Code session. Check installed plugins with /plugin list.

Plugin storage

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

4 Cloudflare

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).

Account & domain

To use Cloudflare, you add your domain and point its nameservers to Cloudflare. This means Cloudflare handles all DNS for that domain:

Install cloudflared

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

Authenticate cloudflared

cloudflared tunnel login
# Opens a URL — copy it to your browser and authorize

Create a tunnel

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

Configure the tunnel

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

Run as systemd service

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

DNS record

This creates a DNS record so that ssh.yourdomain.com routes through your tunnel:

cloudflared tunnel route dns my-tunnel ssh.yourdomain.com
Tip: Add Cloudflare Access in front of the SSH hostname for zero-trust authentication.

5 Workers

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.

Install Node.js

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

Install wrangler

Wrangler is Cloudflare's CLI tool for creating, testing, and deploying Workers:

npm install -g wrangler

Authenticate

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
Note: Use the Global API Key (not an API Token) with CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY for wrangler on a headless VPS.

Create a worker

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

wrangler.toml

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 }
]

src/index.ts

import { Hono } from 'hono';

const app = new Hono();

app.get('/', (c) => c.text('Hello from Cloudflare Workers!'));

export default app;

Deploy

This uploads your code to Cloudflare's edge. Within seconds, it's live worldwide:

npx wrangler deploy

6 Putting It Together

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.

The development loop

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

Project structure pattern

~/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

Auto-source Cloudflare credentials

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

Key things to remember