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

Claude also keeps a persistent file-based memory at ~/.claude/projects/<workdir>/memory/ — facts about you, project context, and feedback that should carry across sessions. It's on by default; the index is MEMORY.md in that directory, and Claude reads it at session start. Override the location with autoMemoryDirectory if you want it elsewhere.

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

Other useful MCP servers

Configure additional MCP servers in ~/.claude.json under the mcpServers key. A few that pair well with a headless VPS:

# ~/.claude.json (excerpt)
{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["-y", "@playwright/mcp@latest",
               "--init-page", "/home/axel/.config/playwright-mcp/init.ts"]
    },
    "elevenlabs": {
      "command": "uvx",
      "args": ["elevenlabs-mcp"],
      "env": { "ELEVENLABS_MCP_BASE_PATH": "/tmp" }
    }
  }
}
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 and configured MCP servers with /mcp.

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.

Service tokens for headless API access

Once you put apps behind CF Access, the same VPS that hosts them can no longer hit them with a plain curl — Access redirects to a browser login. The fix is a service token with a non_identity bypass policy on each app:

# Create a service token (Zero Trust dashboard → Access → Service Auth)
# You get CF-Access-Client-Id and CF-Access-Client-Secret

# Save them to ~/.env.cloudflare and source from ~/.bashrc:
export CF_ACCESS_CLIENT_ID="..."
export CF_ACCESS_CLIENT_SECRET="..."

# Then any curl call works:
curl -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
     -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
     https://app.yourdomain.com/api/...

Each Access app needs a policy with decision: non_identity and include: [{service_token: {token_id}}]. Add it once per app via the dashboard, or scripted via the API.

Tip: The Playwright MCP server can inject these headers via an --init-page script so Claude can browse your CF Access-protected apps without a separate login flow.

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

Wrangler — local, not global

Wrangler is Cloudflare's CLI for Workers. Don't install it globally — pin it as a dev dependency in each Worker project and call it via npx. That way every project locks its own Wrangler version and there's no global drift to debug:

# Inside each Worker project
npm install -D wrangler @cloudflare/workers-types

# Then:
npx wrangler dev       # local dev
npx wrangler deploy    # ship to edge
npx wrangler tail      # live logs

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

JSONC (JSON with comments) is the modern format — it gets you autocompletion via the bundled JSON schema:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-09-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

Bindings — the storage and AI primitives

Workers gain capabilities through bindings declared in wrangler.jsonc. The common ones:

Use the cloudflare-bindings MCP server (from the Cloudflare Skills plugin) to create and inspect bindings without leaving Claude.

6 Local Helpers

Two small services running on the VPS make agentic workflows much smoother: a notification bridge that pings your phone when long jobs finish, and a local AI proxy that lets your Workers consume your Claude subscription instead of paying per-token.

Phone notification bridge

You'll often kick off long jobs (deploys, builds, scripted demos) and walk away. A small HTTP service on localhost can accept a notification from any script or agent and forward it to your phone via Telegram, Slack, or similar.

Pattern: a tiny systemd service exposes POST /send on localhost:7777. A shell wrapper POSTs to it. Now any script — including agents — can notify "deploy finished":

#!/bin/bash
# ~/bin/notify
curl -s -X POST http://localhost:7777/send \
  -H "Authorization: Bearer $BRIDGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"text\":\"$*\"}"

Run the bridge as a systemd service so it's always available:

# /etc/systemd/system/notify-bridge.service
[Unit]
Description=Notification bridge
After=network.target

[Service]
ExecStart=/usr/bin/node /srv/notify/server.js
Restart=on-failure
User=axel
EnvironmentFile=/etc/notify/bridge.env

[Install]
WantedBy=multi-user.target
Gotcha: If the bridge ends up as the parent of your tmux server (e.g. you launched tmux from inside it during testing), systemctl restart will kill tmux and every session inside it. Add a KillMode=process override under /etc/systemd/system/notify-bridge.service.d/override.conf so only the main PID gets killed.

Local AI proxy → Cloudflare AI Gateway

Your Claude Pro/Max subscription comes with the claude CLI's -p mode — one-shot, non-interactive prompting. You can wrap that as an HTTP service that mimics the OpenAI/Ollama API, then plug it into Cloudflare's AI Gateway as a custom provider. Now any Worker can call "AI" backed by your subscription, with zero marginal API cost.

// /srv/ai-proxy/server.js  (sketch)
import http from 'node:http';
import { spawn } from 'node:child_process';

http.createServer(async (req, res) => {
  if (req.url === '/v1/chat/completions') {
    const body = await readJson(req);
    const prompt = body.messages.map(m => m.content).join('\n');
    const child = spawn('claude', ['-p', prompt]);
    let out = '';
    child.stdout.on('data', d => out += d);
    child.on('close', () => {
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({
        choices: [{ message: { role: 'assistant', content: out } }]
      }));
    });
  }
}).listen(11434, '127.0.0.1');

Wiring it up:

  1. Run the proxy as a systemd service on 127.0.0.1:11434.
  2. Add a route in your Cloudflare Tunnel config — e.g. ai.yourdomain.comhttp://127.0.0.1:11434.
  3. In the Cloudflare AI Gateway dashboard, add a custom provider with base URL https://ai.yourdomain.com.
  4. Workers call https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/custom-myprovider/.... Cloudflare adds caching, rate limiting, retries, and analytics for free.
Caveats: Each request spawns a CLI process (~1-2s overhead). Fine for sub-RPS background jobs (synthesis, scoring), not for chat-latency interactive flows. Streaming and tool use need extra plumbing. AI.run() from Workers can hit error 2005 with custom providers — fall back to a manual fetch() against the gateway URL.

7 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 secrets

Instead of remembering to source credentials before every deploy, keep one file per service in ~/ (mode 600) and source them all from ~/.bashrc:

# ~/.bashrc
for f in ~/.env.cloudflare ~/.env.elevenlabs ~/.env.anthropic; do
  [ -f "$f" ] && source "$f"
done

Now npx wrangler deploy, MCP servers that read $ELEVENLABS_API_KEY, and any ad-hoc curl all work without a manual source.

Key things to remember