Provision a hardened single-node host on Hetzner Cloud and run services behind a Cloudflare Tunnel — zero inbound HTTP ports, no public origin IP.
Built from a self-hosted infra pattern I run for AI agents, scrapers, and small services. The same playbook works for any single-node deployment.
- Terraform — creates the server, SSH key, and a tight firewall (SSH only by default).
- Ansible — hardens SSH, sets up
ufw+fail2ban, installscloudflared, deploys your services as systemd units. - Cloudflare Tunnel (optional) — outbound-only connection to Cloudflare. Public traffic hits Cloudflare; the origin IP stays private.
- Idempotent — re-run anytime; nothing breaks.
| Layer | Tool | Why |
|---|---|---|
| Provision | Terraform + hetznercloud/hcloud |
reproducible servers, cheap (cx22 ~€4/mo) |
| Configure | Ansible | declarative roles, no agent on the box |
| Tunnel | cloudflared |
no inbound ports, free, DDoS-fronted |
| Service mgmt | systemd units (templated) | restart, journal, no Docker required |
# macOS
brew install terraform ansible
# Debian/Ubuntu
sudo apt install -y terraform ansible
# Need a Hetzner Cloud project + API token: https://console.hetzner.cloud
export TF_VAR_hcloud_token=hcloud_...cd terraform
cp terraform.tfvars.example terraform.tfvars # edit values
terraform init
terraform applyOutputs the server IP and a ready-to-run SSH command. Also writes ansible/inventory.ini.
cd ../ansible
ansible-playbook -i inventory.ini site.ymlThis installs base packages, hardens SSH, enables ufw + fail2ban, and applies any services you defined.
Create a tunnel in the Cloudflare Zero Trust dashboard, copy the connector token, then:
export CF_TUNNEL_TOKEN=eyJh...
cd ansible
ansible-playbook -i inventory.ini site.yml -e cloudflare_tunnel_enabled=trueRoute hostnames to http://localhost:PORT from the dashboard. No inbound ports.
Edit ansible/group_vars/all.yml:
managed_services:
- name: myapp
description: "My App"
user: root
working_directory: /opt/myapp
exec_start: "/usr/bin/node /opt/myapp/server.js"
environment:
NODE_ENV: production
PORT: "8080"
restart: alwaysRe-run the playbook. The unit is templated, enabled, and started.
.
├── terraform/
│ ├── main.tf server + firewall + SSH key
│ ├── variables.tf tunable inputs
│ ├── outputs.tf IP, SSH command
│ └── terraform.tfvars.example
└── ansible/
├── site.yml top-level play
├── group_vars/all.yml defaults — override here
└── roles/
├── common/ packages, timezone, auto-upgrades
├── hardening/ SSH config, ufw, fail2ban
├── cloudflare-tunnel/ cloudflared install + service
└── services/ systemd unit templater
Hetzner cx22 (2 vCPU / 4 GB / 40 GB) is around €4/month. Cloudflare Tunnel is free.
- SSH password auth is disabled. Use keys.
ssh_allow_fromdefaults to0.0.0.0/0— narrow it to your IP for production.expose_httpisfalseby default. Keep it that way if you front everything with a tunnel.- All variables are non-sensitive. Tokens (
TF_VAR_hcloud_token,CF_TUNNEL_TOKEN) come from the environment, nevertfvars.
MIT