skills/hetzner-codex-remote/SKILL.md
Prepare a Hetzner Cloud VPS for secure Codex remote SSH access. Use when the user wants to create or configure a Hetzner server for Codex remote control, fix "No codex found in PATH" on a remote machine, install agent development tooling on a VPS, harden SSH access to a Hetzner server, or connect the server through Codex Settings, Connections, Add SSH.
npx skillsauth add vltansky/skills hetzner-codex-remoteInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Set up a Hetzner Cloud server so Codex can use it as a remote SSH connection. Keep the setup secure by default: key-only SSH, no root SSH, a dedicated codex user, firewall allowlisting where practical, optional Tailscale-only SSH for roaming clients, and a verified codex CLI in PATH.
Do not copy personal data from examples or prior chats into commands, files, or documentation. Use placeholders for IPs, keys, project IDs, host aliases, and account details.
codex user.Give concise creation instructions instead of guessing through the UI:
codex-remote.If the user has no local SSH key, create one locally with:
ssh-keygen -t ed25519 -C "codex-remote"
Do not ask the user to paste private keys. Only public keys may be copied to providers or servers.
Use the server IP from Hetzner:
ssh -o StrictHostKeyChecking=accept-new root@<SERVER_IP> 'id && uname -a'
If root SSH does not work but another user does, adapt the commands to that user with sudo. If SSH key injection failed, use Hetzner's web console or rescue mode to add the public key; do not weaken the final SSH policy to password access.
Find the user's current public IPv4 for firewall allowlisting:
curl -4 -s https://ifconfig.me || curl -4 -s https://icanhazip.com
Warn that an IP allowlist can lock out SSH when the user's public IP changes. If the user uses the same client device from different networks, prefer Tailscale-only SSH over chasing public IP allowlists.
Run from the local machine, replacing placeholders:
PUBKEY="$(sed -n '1p' ~/.ssh/id_ed25519.pub)"
MYIP="<USER_PUBLIC_IPV4>"
SERVER="<SERVER_IP>"
ssh root@"$SERVER" "PUBKEY='$PUBKEY' MYIP='$MYIP' bash -s" <<'REMOTE'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y sudo ufw fail2ban unattended-upgrades curl ca-certificates git
if ! id codex >/dev/null 2>&1; then
adduser --disabled-password --gecos "Codex remote user" codex
fi
usermod -aG sudo codex
install -d -m 700 -o codex -g codex /home/codex/.ssh
printf '%s\n' "$PUBKEY" > /home/codex/.ssh/authorized_keys
chown codex:codex /home/codex/.ssh/authorized_keys
chmod 600 /home/codex/.ssh/authorized_keys
printf 'codex ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/90-codex
chmod 440 /etc/sudoers.d/90-codex
visudo -cf /etc/sudoers.d/90-codex >/dev/null
install -d -m 755 /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-codex-secure.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
X11Forwarding no
AllowUsers codex
EOF
sshd -t
systemctl reload ssh
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
if [ -n "${MYIP:-}" ] && [ "$MYIP" != "skip" ]; then
ufw allow from "$MYIP" to any port 22 proto tcp comment 'SSH from user public IP'
else
ufw allow 22/tcp comment 'SSH key-only'
fi
ufw --force enable
systemctl enable --now fail2ban
systemctl enable --now unattended-upgrades || true
REMOTE
Verify before closing any working session:
ssh -o BatchMode=yes codex@<SERVER_IP> 'id && sudo -n true && echo sudo-ok'
ssh -o BatchMode=yes root@<SERVER_IP> 'echo root-should-not-work'
The root command should fail.
Add a memorable alias to the user's local SSH config:
Host <HOST_ALIAS>
HostName <SERVER_IP>
User codex
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
Verify:
ssh <HOST_ALIAS> 'whoami && hostname'
Use this when the same client device needs SSH from different networks, or when the user wants SSH reachable only through their tailnet. Keep the existing public SSH path open until SSH over Tailscale is verified.
Install Tailscale on the remote server:
ssh <HOST_ALIAS> 'set -euo pipefail
curl -fsSL https://tailscale.com/install.sh | sh
sudo -n systemctl enable --now tailscaled
tailscale version
sudo -n tailscale up --hostname=<HOST_ALIAS> --accept-dns=false || true
sudo -n tailscale status --json | python3 -c "import json,sys; d=json.load(sys.stdin); print({\"BackendState\": d.get(\"BackendState\"), \"AuthURL\": d.get(\"AuthURL\"), \"TailscaleIPs\": (d.get(\"Self\") or {}).get(\"TailscaleIPs\"), \"DNSName\": (d.get(\"Self\") or {}).get(\"DNSName\"), \"HostName\": (d.get(\"Self\") or {}).get(\"HostName\")})"'
If BackendState is NeedsLogin, open the AuthURL in the user's browser and connect the device to the intended tailnet. If browser automation is available and the user asked you to handle it, use the browser to authorize the device. Select the correct tailnet when more than one is offered.
After authorization, collect the Tailscale IP:
TAILSCALE_IP="$(ssh <HOST_ALIAS> 'sudo -n tailscale ip -4 | sed -n "1p"')"
printf '%s\n' "$TAILSCALE_IP"
Verify SSH over Tailscale before changing the firewall:
ssh -o BatchMode=yes -o ConnectTimeout=8 codex@"$TAILSCALE_IP" 'hostname; whoami; codex --version'
If SSH reports a host-key mismatch, compare the public and Tailscale host keys before accepting the new host entry:
ssh-keyscan -T 5 -t ed25519 <SERVER_IP> "$TAILSCALE_IP"
ssh -o StrictHostKeyChecking=accept-new codex@"$TAILSCALE_IP" 'hostname; whoami'
Then update the local SSH alias to use the Tailscale IP:
Host <HOST_ALIAS>
HostName <TAILSCALE_IP>
User codex
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
Finally, close public SSH and allow SSH only over tailscale0:
ssh <HOST_ALIAS> 'set -euo pipefail
sudo -n ufw allow in on tailscale0 to any port 22 proto tcp comment "SSH over Tailscale"
sudo -n ufw --force delete allow from <USER_PUBLIC_IPV4> to any port 22 proto tcp || true
sudo -n ufw status numbered'
Verify that the alias still works and public SSH is blocked:
ssh -o BatchMode=yes -o ConnectTimeout=8 <HOST_ALIAS> 'hostname; whoami; sudo -n tailscale status --self; sudo -n ufw status numbered'
nc -vz -G 5 <SERVER_IP> 22
The nc check should fail or time out. If it succeeds, public SSH is still exposed and the firewall rules need another pass.
Disable Tailscale key expiry for long-lived remotes:
<HOST_ALIAS>.Expiry disabled.Install common build and coding-agent dependencies:
ssh <HOST_ALIAS> 'sudo -n DEBIAN_FRONTEND=noninteractive apt-get update -y && sudo -n DEBIAN_FRONTEND=noninteractive apt-get install -y git gh python3 python-is-python3 python3-pip python3-venv python3-dev pipx build-essential curl ca-certificates unzip jq ripgrep fd-find'
Install nvm, Node LTS, npm, Corepack-managed pnpm/yarn, and Codex CLI as the codex user:
ssh <HOST_ALIAS> 'bash -s' <<'REMOTE'
set -euo pipefail
NVM_DIR="$HOME/.nvm"
latest_tag=$(git ls-remote --tags https://github.com/nvm-sh/nvm.git 'v*' | awk '{print $2}' | sed 's#refs/tags/##; s#\^{}##' | sort -V | tail -1)
if [ ! -d "$NVM_DIR/.git" ]; then
git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR"
fi
git -C "$NVM_DIR" fetch --tags --quiet
git -C "$NVM_DIR" checkout --quiet "$latest_tag"
for f in "$HOME/.bashrc" "$HOME/.profile"; do
touch "$f"
if ! grep -q 'NVM_DIR' "$f"; then
cat >> "$f" <<'EOF'
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"
EOF
fi
done
. "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default 'lts/*'
nvm use default
corepack enable
corepack prepare pnpm@latest --activate
corepack prepare yarn@stable --activate
npm config set fund false
npm config set audit false
npm install -g @openai/codex@latest
NODE_BIN_DIR=$(dirname "$(command -v node)")
for bin in node npm npx corepack pnpm yarn codex; do
sudo -n ln -sfn "$NODE_BIN_DIR/$bin" "/usr/local/bin/$bin"
done
sudo -n ln -sfn /usr/bin/fdfind /usr/local/bin/fd
REMOTE
Use /usr/local/bin shims because remote-control checks often run non-interactive shells that do not source .bashrc.
Run the verification from a plain SSH command:
ssh <HOST_ALIAS> 'set -e
command -v codex
codex --version
node --version
npm --version
pnpm --version
yarn --version
git --version
gh --version | sed -n "1p"
python --version
python3 --version
sudo -n sshd -T | egrep "^(permitrootlogin|passwordauthentication|allowusers|kbdinteractiveauthentication|pubkeyauthentication) "
sudo -n ufw status verbose
sudo -n fail2ban-client status sshd || true
sudo -n tailscale status --self 2>/dev/null || true
test -f /var/run/reboot-required && echo reboot-required || echo reboot-not-required'
Expected properties:
command -v codex resolves, preferably under /usr/local/bin/codex.codex --version prints a version.permitrootlogin no, passwordauthentication no, and allowusers codex.ssh root@<SERVER_IP> fails.<HOST_ALIAS> resolves to the Tailscale IP and public <SERVER_IP>:22 times out.apt-get -s upgrade shows no urgent pending upgrades, or the remaining work is reported clearly.If the user sees No codex found in PATH, install @openai/codex and recreate the /usr/local/bin/codex shim.
Install gh, but do not invent credentials. If the local machine already has an authenticated GitHub CLI session and the user asks to use GitHub from the remote, prefer transferring that auth through gh auth token over a device-code flow. Do not print the token, write it to a file, or ask the user to paste it in chat.
Generic local-to-remote gh auth:
gh auth status -h github.com
gh auth token -h github.com | ssh <HOST_ALIAS> 'gh auth login --hostname github.com --with-token'
ssh <HOST_ALIAS> 'gh auth setup-git --hostname github.com && gh config set git_protocol https --host github.com && gh auth status -h github.com'
Then clone the requested repository into a predictable remote workspace:
ssh <HOST_ALIAS> 'set -euo pipefail
mkdir -p ~/Projects
cd ~/Projects
gh repo clone <OWNER>/<REPO> <REPO>
cd <REPO>
printf "repo=%s\nbranch=%s\nhead=%s\n" "$PWD" "$(git branch --show-current)" "$(git rev-parse --short HEAD)"
git status --short --branch'
If local gh is not authenticated or token transfer is not appropriate, use the user's normal secure token flow or GitHub's device login:
ssh <HOST_ALIAS>
gh auth login
Do not ask for tokens in chat unless there is no safer route and the user explicitly chooses it.
End by instructing the user:
<HOST_ALIAS>, or the full codex@<SERVER_IP> target.data-ai
Summarize your GitHub activity from the last 24 hours across all repos. Use when user says "what did I do", "my activity", "standup", "recap", "summarize my day", "what-i-did", "git activity", "daily summary".
development
Test-driven development loop. Write failing test first, then implement to make it pass. Use when the user says 'tdd', 'test first', 'write the test first', 'failing test', 'red green refactor', or for any bug fix where the fix should be proven by a test. Also use when autopilot or other skills need test-first execution.
development
Review changed code for reuse, quality, and efficiency, then fix any issues found. Use when the user says "simplify", "simplify this", "review changes", "clean up my code", "check for duplicates", "code reuse review", or wants a post-change quality sweep.
development
Create a GitHub PR with conventional format and AI session context. Use when user says 'create PR', 'open PR', 'submit changes', 'send to dev', 'ship it', or is done with their task.