# THEON SETUP PROMPT — HETZNER VPS + CLOUDFLARE SSH This file is meant to be pasted into an LLM by Theon. The LLM should use it to give the user clear, executable instructions for provisioning a Hetzner Cloud Linux VPS and configuring Cloudflare so SSH access does not require opening port 22 to the public internet long-term. The LLM must read the whole file before answering. Treat it as an operator runbook plus a prompt contract. Do not skip the input-gathering phase. Do not invent account names, hostnames, IPs, locations, server plans, or users. ============================================================================== TARGET OUTCOME ============================================================================== The final system should look like this: User laptop SSH client -> local cloudflared ProxyCommand -> Cloudflare Access / Cloudflare Tunnel -> Hetzner VPS sshd on localhost:22 The user should be able to run: ssh and land on the Hetzner VPS as . Long-term public inbound SSH should not be required. Public port 22 may be used only as a temporary bootstrap path, and only from if a Hetzner firewall is used. ============================================================================== THEON / LLM OPERATING CONTRACT ============================================================================== When this file is pasted into an LLM, the LLM must do the following: 1. First answer with a short checklist of missing values from the placeholder list. Ask for only the values that are actually missing. 2. Once values are supplied, produce a concrete step-by-step setup plan in this order: Phase A — Hetzner Cloud project/server setup Phase B — first SSH bootstrap and non-root sudo user Phase C — baseline hardening and temporary firewall posture Phase D — Cloudflare domain/tunnel setup Phase E — client SSH config with cloudflared ProxyCommand Phase F — verification, cleanup, and final records 3. Every phase must include: Goal: Commands or console steps: Verify: Stop condition: 4. Do not tell the user to disable public SSH until an alternate verified login path exists. 5. Do not tell the user to disable password auth until key-based login for is verified in a second session. 6. Distinguish clearly between: Public Access hostname: with cloudflared access ssh / ProxyCommand Private WARP route: private IP/CIDR route for WARP clients This runbook's default is the public Access hostname path for laptop SSH. Add WARP/private routing only if the user asks for iPad/Termius/native client access through Cloudflare One. 7. If asked to actually perform the setup, make changes incrementally and verify after each step. If asked only for instructions, do not claim that anything has been changed. 8. Keep secrets out of the answer. Never ask for private keys, API tokens, or account passwords. Public SSH keys are safe to paste. ============================================================================== FINAL ANSWER FORMAT EXPECTED FROM THE LLM ============================================================================== After the setup is complete, or after producing a setup plan, summarize: Hetzner project: Server name: Server type: Location: Image: Public IP(s): Linux user: SSH hostname: Tunnel name: Tunnel UUID: Cloudflared config path: Client SSH config stanza: Whether public port 22 remains reachable: What still needs manual user action: ============================================================================== PLACEHOLDERS — ASK THE USER FOR THESE FIRST ============================================================================== Apex domain on Cloudflare, e.g. example.com Subdomain to use for SSH, e.g. ssh. Short identifier, e.g. vps-tunnel Non-root Linux user on the VPS, e.g. alice Hetzner Cloud project name Hetzner server name, e.g. asgard Hetzner server plan, e.g. ask user; do not guess Hetzner location, e.g. ask user; do not guess Server image, e.g. ubuntu-24.04 or debian-12 Name of the public SSH key in Hetzner Console Public IP/CIDR allowed for temporary bootstrap SSH Public IPv4 or IPv6 assigned by Hetzner Do not invent values. If any are missing, ask the user. ============================================================================== PREREQUISITES — VERIFY BEFORE TOUCHING ANYTHING ============================================================================== 1. If the VPS already exists, the user has SSH access as with sudo. Verify: ssh @ 'sudo -n true' returns 0. If the VPS does not exist yet, complete the Hetzner setup section below before continuing to Cloudflare setup. 2. The user can run a browser on their laptop (one-time OAuth login is needed in step 2 below). 3. is added to a Cloudflare account and its nameservers point at Cloudflare. If not, stop and tell the user to do this first via dash.cloudflare.com. 4. The VPS is Ubuntu/Debian. If it's something else (Fedora, Arch, Alpine, macOS), adapt the install command in step 1 accordingly. The rest is distro-agnostic. ============================================================================== HETZNER CLOUD VPS SETUP — RUN BEFORE STEP 1 IF THE VPS DOES NOT EXIST ============================================================================== Goal: create a minimal Hetzner Cloud server that can be bootstrapped over SSH, then rely on Cloudflare Tunnel for long-term SSH access without a public inbound port 22. Use Hetzner Console unless the user explicitly asks for hcloud/Terraform. The Console path is less error-prone for one-off setup. ------------------------------------------------------------------------------ HETZNER STEP H1 — CREATE OR SELECT THE PROJECT ------------------------------------------------------------------------------ In Hetzner Console: Cloud -> Projects -> New Project Name the project according to the user's convention. If the user already has a project, use that project and do not create another one. Verify: The project exists in console.hetzner.cloud and the user can create servers in it. ------------------------------------------------------------------------------ HETZNER STEP H2 — ADD THE OPERATOR SSH KEY ------------------------------------------------------------------------------ In Hetzner Console: Security -> SSH Keys -> Add SSH Key Paste the user's public key. Prefer ed25519 keys. Record: SSH key name: Important: Select this key when creating the server. Hetzner's Console creation flow only injects selected SSH keys at server creation time. Keys can still be added later inside the server's authorized_keys, but not retroactively via the original create-server form. Verify: The key appears under Security -> SSH Keys. ------------------------------------------------------------------------------ HETZNER STEP H3 — CREATE THE SERVER ------------------------------------------------------------------------------ In Hetzner Console: Servers -> Create Server Use explicit choices from the user: Location: Image: (Ubuntu 24.04 LTS or Debian 12 are reasonable defaults) Type: Name: SSH key: Networking: Keep public IPv4 and/or IPv6 enabled during bootstrap unless the user is intentionally building a private-only server and already has a jump path. Backups: Ask the user. Hetzner backups are convenient but not free. For cheap disposable test boxes, snapshots on demand may be enough. Cloud config: If the user wants repeatable provisioning, use the cloud-init example in H4. Otherwise create the server with only the SSH key, then harden manually in H5. Verify: The server is running and Hetzner Console shows . ------------------------------------------------------------------------------ HETZNER STEP H4 — OPTIONAL CLOUD-INIT FOR A NON-ROOT SUDO USER ------------------------------------------------------------------------------ Use this only when creating a new server. Do not apply cloud-init blindly to an existing server. Before creating the server, add cloud-init user data similar to this, replacing and : #cloud-config users: - default - name: groups: sudo shell: /bin/bash sudo: ALL=(ALL) NOPASSWD:ALL lock_passwd: true ssh_authorized_keys: - package_update: true packages: - ca-certificates - curl - gnupg - lsb-release - tmux - jq ssh_pwauth: false disable_root: false Notes: - lock_passwd: true prevents password login for . - ssh_pwauth: false disables SSH password auth from first boot on images that honor the setting. - disable_root: false keeps root available for emergency recovery during bootstrap. After the non-root user and tunnel are verified, root SSH can be disabled in H6. Verify after first boot: ssh @ 'id && sudo -n true' should print the user's uid/groups and exit 0. ------------------------------------------------------------------------------ HETZNER STEP H5 — MANUAL NON-ROOT USER SETUP (IF NOT USING CLOUD-INIT) ------------------------------------------------------------------------------ If the server was created with only root access, log in as root using the selected SSH key: ssh root@ Create the non-root user: adduser --disabled-password --gecos "" usermod -aG sudo install -d -m 700 -o -g /home//.ssh cp /root/.ssh/authorized_keys /home//.ssh/authorized_keys chown : /home//.ssh/authorized_keys chmod 600 /home//.ssh/authorized_keys printf '%s\n' ' ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90- chmod 440 /etc/sudoers.d/90- Verify from the user's local machine: ssh @ 'id && sudo -n true' Do not continue until this works. ------------------------------------------------------------------------------ HETZNER STEP H6 — TEMPORARY HETZNER FIREWALL DURING BOOTSTRAP ------------------------------------------------------------------------------ If using Cloudflare Tunnel as the long-term ingress path, public SSH should only be temporary. In Hetzner Console: Firewalls -> Create Firewall Recommended temporary inbound rules: TCP 22 from only ICMP from anywhere if the user wants ping diagnostics Do not add broad 0.0.0.0/0 SSH unless the user explicitly accepts that risk. Do not add HTTP/HTTPS unless the server will host a public origin outside Cloudflare Tunnel. Apply the firewall to the server. Verify from the operator network: ssh @ 'hostname' Verify from a different network if possible: TCP 22 should not be reachable unless that network is inside . After Cloudflare Tunnel is working and the client can connect through , remove the TCP 22 inbound rule or detach the bootstrap firewall. Cloudflare Tunnel makes outbound connections from the VPS, so it does not need public inbound SSH. ------------------------------------------------------------------------------ HETZNER STEP H7 — BASELINE OS HARDENING BEFORE CLOUDFLARE SETUP ------------------------------------------------------------------------------ Run as : sudo apt-get update sudo apt-get -y upgrade sudo apt-get -y install ca-certificates curl gnupg lsb-release jq tmux ufw If key-based login for is verified, disable SSH password auth: sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.before-key-only sudo sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config sudo sed -i 's/^#\?KbdInteractiveAuthentication .*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config sudo sshd -t sudo systemctl reload ssh Keep the current SSH session open. From a second terminal, verify: ssh @ 'true' Only after the second login works should you close the first session. Optional root hardening after is verified: sudo sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config sudo sshd -t sudo systemctl reload ssh ------------------------------------------------------------------------------ HETZNER STEP H8 — RECORD VALUES FOR THE REST OF THIS DOC ------------------------------------------------------------------------------ Print these back to the user before starting Cloudflare setup: Hetzner project: Server name: Server type: Location: Image: Public IPv4/IPv6: Linux user: SSH key name: Bootstrap firewall: enabled/disabled, and whether TCP 22 remains open At this point the rest of the runbook can start at STEP 1. ============================================================================== STEP 1 — INSTALL cloudflared ON THE VPS ============================================================================== Run on the VPS: 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 $(lsb_release -cs) main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt-get update && sudo apt-get install -y cloudflared Verify: cloudflared --version should print a version line and exit 0. ============================================================================== STEP 2 — AUTHENTICATE cloudflared TO THE USER'S CF ACCOUNT ============================================================================== Run on the VPS as (NOT as root): cloudflared tunnel login The command prints a URL. Hand the URL to the user, ask them to open it in their browser, select , and authorize. The command finishes once they click Authorize. Verify: test -f ~/.cloudflared/cert.pem && echo OK should print OK. ============================================================================== STEP 3 — CREATE THE TUNNEL ============================================================================== cloudflared tunnel create Capture the tunnel UUID from the output (a string like "da26758d-1064-45fc-9539-d9a10448335b"). Store it as for the rest of the doc. Verify: cloudflared tunnel list should list with the same UUID. ============================================================================== STEP 4 — WRITE THE TUNNEL CONFIG (system-wide, so systemd can use it) ============================================================================== Move the credentials file into /etc/cloudflared so root can read it: sudo mkdir -p /etc/cloudflared sudo cp ~/.cloudflared/.json /etc/cloudflared/ sudo cp ~/.cloudflared/cert.pem /etc/cloudflared/ sudo chown root:root /etc/cloudflared/.json /etc/cloudflared/cert.pem sudo chmod 600 /etc/cloudflared/.json Write the config — substitute the real values for and : sudo tee /etc/cloudflared/config.yml >/dev/null <<'YAML' tunnel: credentials-file: /etc/cloudflared/.json ingress: - hostname: service: ssh://localhost:22 - service: http_status:404 YAML Verify: sudo cloudflared --config /etc/cloudflared/config.yml tunnel ingress validate should print "Validating rules from /etc/cloudflared/config.yml ... OK". ============================================================================== STEP 5 — POINT DNS AT THE TUNNEL ============================================================================== cloudflared tunnel route dns This creates a CNAME -> .cfargotunnel.com in Cloudflare DNS. If the command fails with "An A, AAAA, or CNAME record with that host already exists": delete the conflicting record at dash.cloudflare.com -> -> DNS, then re-run. Verify: dig +short CNAME should return ".cfargotunnel.com." within ~1 minute. ============================================================================== STEP 6 — RUN cloudflared AS A SYSTEMD SERVICE ============================================================================== sudo cloudflared service install sudo systemctl enable --now cloudflared Verify: sudo systemctl status cloudflared --no-pager should show "active (running)" and the most recent journal lines should include several "Registered tunnel connection" entries (one per CF datacenter, typically 4 different POPs). If the service fails to start, dump logs and surface them: sudo journalctl -u cloudflared -n 100 --no-pager ============================================================================== STEP 7 — CONFIGURE THE CLIENT (the user's laptop) ============================================================================== The client also needs cloudflared installed (it acts as a local SSH proxy). On macOS: brew install cloudflared On Linux: same package recipe as step 1. Append to the user's ~/.ssh/config (create the file if missing, mode 600): Host User ProxyCommand cloudflared access ssh --hostname=%h Verify: ssh should land in a shell on the VPS. The first connection may print "connecting via cloudflared access ssh" and pause for a second. ============================================================================== OPTIONAL — PUT CLOUDFLARE ACCESS IN FRONT (zero-trust SSH) ============================================================================== Without Access, anyone with valid SSH credentials can reach the VPS via . With Access, Cloudflare also requires a browser/SSO login before the SSH handshake even starts. Do this only if the user asks for zero-trust. Steps: 1. Zero Trust dashboard (one.dash.cloudflare.com) -> Access -> Applications -> Add an application -> Self-hosted. 2. Application domain: . 3. Add an Access policy: Action=Allow, Include=Emails -> the user's email. 4. (Optional) For headless scripts on the VPS that need to hit other Access-protected apps on the same domain, also add a service token and a policy with decision=non_identity, include=[{service_token: {token_id}}]. The user can then export CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET and pass them as headers from curl/scripts. The client-side ProxyCommand from step 7 already supports Access — first connection launches a browser for SSO. ============================================================================== OPTIONAL — ADD WARP PRIVATE ROUTING FOR NATIVE CLIENTS ============================================================================== Use this only if the user needs native clients that cannot run cloudflared as an SSH ProxyCommand, such as many iPad SSH apps. Do not confuse this with . The route above is a public Cloudflare Access hostname for clients that can run cloudflared locally. WARP private routing is a different path. WARP private route model: Native SSH client -> Cloudflare One/WARP device tunnel -> Cloudflare private IP or private hostname route -> cloudflared tunnel -> VPS private IP:22 If the VPS has a private Hetzner network address, record it as . If it does not, create/attach a Hetzner private network first or skip this section. Add a Cloudflare Tunnel private IP route: cloudflared tunnel route ip add /32 Verify: cloudflared tunnel route ip get Then make sure the user's Cloudflare One device profile routes that private IP/CIDR through WARP. Depending on the organization's Split Tunnel mode, this means either including /32 or removing it from the excluded ranges. If Gateway network policies are enabled, allow TCP 22 to . Native client configuration example: Host: Port: 22 User: Key: user's SSH private key in that client Optional private hostname: asgard.private. -> :22 Keep it separate from unless the user explicitly understands the public/private DNS split. Recommended pattern: laptop SSH with cloudflared ProxyCommand asgard.private. WARP-native clients ============================================================================== KEY NUMBERS TO RECORD WHEN DONE ============================================================================== Print these back to the user at the end so they have them written down: Tunnel name: Tunnel UUID: SSH hostname: Config path: /etc/cloudflared/config.yml Credentials path: /etc/cloudflared/.json Service unit: cloudflared.service Client SSH config: ~/.ssh/config (Host stanza) ============================================================================== COMMON FAILURE MODES ============================================================================== - "tunnel login" hangs: ask the user whether they actually opened the URL. The command waits indefinitely until they click Authorize. - "CNAME already exists": a previous tunnel or A record is squatting the hostname. Delete it via the Cloudflare DNS dashboard. - ssh hangs after handshake start: cloudflared service is up but ingress is wrong. Re-check /etc/cloudflared/config.yml — hostname must exactly match ; ingress must end with "- service: http_status:404". - "ssh: Could not resolve hostname ": DNS hasn't propagated yet, or the tunnel route dns step was skipped. Run dig and step 5 again. - "permission denied" on the credentials JSON when systemd starts: chown to root and chmod 600. - Multiple ingress entries needed later (e.g. add an HTTP service): edit /etc/cloudflared/config.yml, add another rule above the catch-all http_status:404, then "sudo systemctl restart cloudflared". ============================================================================== END OF DOC ==============================================================================