This skill guides provisioning Hetzner Cloud infrastructure with OpenTofu/Terraform. Use when creating servers, networks, firewalls, load balancers, or volumes on Hetzner Cloud.
Provisions Hetzner Cloud infrastructure using OpenTofu/Terraform. Use when creating servers, networks, firewalls, load balancers, or volumes on Hetzner Cloud.
/plugin marketplace add majesticlabs-dev/majestic-marketplace/plugin install majestic-devops@majestic-marketplaceThis skill is limited to using the following tools:
resources/best-practices.mdresources/object-storage.mdresources/production-stack.mdHetzner Cloud offers high-performance, cost-effective cloud infrastructure with European data centers. This skill covers OpenTofu/Terraform patterns for Hetzner resources.
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.50"
}
}
}
provider "hcloud" {
# Token from environment: HCLOUD_TOKEN
# Or explicitly (not recommended):
# token = var.hcloud_token
}
# Set token via environment variable
export HCLOUD_TOKEN="your-api-token"
# Or with 1Password
HCLOUD_TOKEN=op://Infrastructure/Hetzner/api_token
Token Permissions:
| Location | Code | Region | Network Zone |
|---|---|---|---|
| Falkenstein | fsn1 | Germany | eu-central |
| Nuremberg | nbg1 | Germany | eu-central |
| Helsinki | hel1 | Finland | eu-central |
| Ashburn | ash | US East | us-east |
| Hillsboro | hil | US West | us-west |
| Type | vCPUs | RAM | Storage | Best For |
|---|---|---|---|---|
cx22 | 2 | 4 GB | 40 GB | Small apps |
cx32 | 4 | 8 GB | 80 GB | Medium apps |
cx42 | 8 | 16 GB | 160 GB | Production |
cx52 | 16 | 32 GB | 320 GB | High traffic |
| Type | vCPUs | RAM | Storage |
|---|---|---|---|
cpx11 | 2 | 2 GB | 40 GB |
cpx21 | 3 | 4 GB | 80 GB |
cpx31 | 4 | 8 GB | 160 GB |
cpx41 | 8 | 16 GB | 240 GB |
cpx51 | 16 | 32 GB | 360 GB |
| Type | vCPUs | RAM | Storage |
|---|---|---|---|
cax11 | 2 | 4 GB | 40 GB |
cax21 | 4 | 8 GB | 80 GB |
cax31 | 8 | 16 GB | 160 GB |
cax41 | 16 | 32 GB | 320 GB |
| Type | vCPUs | RAM | Storage |
|---|---|---|---|
ccx13 | 2 | 8 GB | 80 GB |
ccx23 | 4 | 16 GB | 160 GB |
ccx33 | 8 | 32 GB | 240 GB |
ccx43 | 16 | 64 GB | 360 GB |
resource "hcloud_server" "app" {
name = "${var.project}-${var.environment}-app"
server_type = "cx22"
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
labels = {
environment = var.environment
project = var.project
role = "app"
}
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
}
resource "hcloud_server" "app" {
name = "${var.project}-app"
server_type = "cx22"
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
user_data = <<-EOT
#cloud-config
package_update: true
packages:
- docker.io
- docker-compose-plugin
users:
- name: deploy
groups: docker, sudo
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ${var.deploy_ssh_key}
runcmd:
- systemctl enable --now docker
- sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
- sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
- systemctl restart sshd
EOT
labels = {
environment = var.environment
role = "app"
}
}
resource "hcloud_server" "worker" {
name = "${var.project}-worker"
server_type = "cax21" # ARM64 - great price/performance
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
labels = {
role = "worker"
arch = "arm64"
}
}
resource "hcloud_network" "private" {
name = "${var.project}-network"
ip_range = "10.0.0.0/16"
labels = {
project = var.project
}
}
resource "hcloud_network_subnet" "private" {
network_id = hcloud_network.private.id
type = "cloud"
network_zone = "eu-central" # Must match server location zone
ip_range = "10.0.1.0/24"
}
resource "hcloud_server" "db" {
name = "${var.project}-db"
server_type = "cpx31"
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
# Attach to private network
network {
network_id = hcloud_network.private.id
ip = "10.0.1.10" # Optional: specific IP
}
# Optionally disable public IP for security
public_net {
ipv4_enabled = false
ipv6_enabled = false
}
labels = {
role = "database"
}
depends_on = [hcloud_network_subnet.private]
}
resource "hcloud_firewall" "web" {
name = "${var.project}-web-firewall"
# SSH from specific IPs only (NEVER use 0.0.0.0/0!)
rule {
description = "SSH"
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_ip] # Use variable, no default
}
# HTTP/HTTPS from anywhere
rule {
description = "HTTP"
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
description = "HTTPS"
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
# ICMP for debugging (ping)
rule {
description = "ICMP"
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
}
# Apply to servers with label
apply_to {
label_selector = "role=web"
}
}
# IMPORTANT: admin_ip variable has NO default for security
variable "admin_ip" {
description = "Admin IP for SSH access (CIDR) - REQUIRED, no default"
type = string
# NO DEFAULT - forces explicit value
}
Security pattern: Never default SSH access to 0.0.0.0/0. Force explicit IP:
tofu apply -var="admin_ip=$(curl -s ifconfig.me)/32"
resource "hcloud_firewall" "db" {
name = "${var.project}-db-firewall"
# PostgreSQL from private network only
rule {
description = "PostgreSQL"
direction = "in"
protocol = "tcp"
port = "5432"
source_ips = ["10.0.0.0/16"] # Private network range
}
# SSH from bastion only
rule {
description = "SSH from bastion"
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["10.0.1.1/32"] # Bastion IP
}
apply_to {
label_selector = "role=database"
}
}
resource "hcloud_floating_ip" "app" {
type = "ipv4"
name = "${var.project}-vip"
home_location = "fsn1"
labels = {
project = var.project
purpose = "failover"
}
}
resource "hcloud_floating_ip_assignment" "app" {
floating_ip_id = hcloud_floating_ip.app.id
server_id = hcloud_server.app.id
}
output "floating_ip" {
value = hcloud_floating_ip.app.ip_address
}
resource "hcloud_load_balancer" "web" {
name = "${var.project}-lb"
load_balancer_type = "lb11"
location = "fsn1"
labels = {
project = var.project
}
}
resource "hcloud_load_balancer_network" "web" {
load_balancer_id = hcloud_load_balancer.web.id
network_id = hcloud_network.private.id
ip = "10.0.1.100"
}
resource "hcloud_load_balancer_service" "http" {
load_balancer_id = hcloud_load_balancer.web.id
protocol = "http"
listen_port = 80
destination_port = 8080
health_check {
protocol = "http"
port = 8080
interval = 10
timeout = 5
retries = 3
http {
path = "/health"
status_codes = ["200"]
}
}
}
resource "hcloud_load_balancer_target" "web" {
load_balancer_id = hcloud_load_balancer.web.id
type = "server"
server_id = hcloud_server.app.id
use_private_ip = true
depends_on = [hcloud_load_balancer_network.web]
}
resource "hcloud_managed_certificate" "web" {
name = "${var.project}-cert"
domain_names = [var.domain, "www.${var.domain}"]
labels = {
project = var.project
}
}
resource "hcloud_load_balancer_service" "https" {
load_balancer_id = hcloud_load_balancer.web.id
protocol = "https"
listen_port = 443
destination_port = 8080
http {
certificates = [hcloud_managed_certificate.web.id]
redirect_http = true
}
health_check {
protocol = "http"
port = 8080
interval = 10
timeout = 5
}
}
resource "hcloud_volume" "data" {
name = "${var.project}-data"
size = 100 # GB
location = "fsn1"
format = "ext4"
labels = {
project = var.project
purpose = "database"
}
}
resource "hcloud_volume_attachment" "data" {
volume_id = hcloud_volume.data.id
server_id = hcloud_server.db.id
automount = true
}
resource "hcloud_ssh_key" "deploy" {
name = "${var.project}-deploy"
public_key = file(var.ssh_public_key_path)
labels = {
project = var.project
purpose = "deployment"
}
}
Cloud-init runs at first boot. For ongoing configuration or re-running setup, use Ansible.
# outputs.tf
output "server_ip" {
value = hcloud_server.app.ipv4_address
description = "Server IP for Ansible inventory"
}
output "ansible_inventory" {
value = <<-EOT
[web]
${hcloud_server.app.ipv4_address} ansible_user=root
EOT
description = "Ansible inventory content"
}
#!/usr/bin/env bash
# infra/bin/provision
set -euo pipefail
INFRA_DIR="$(dirname "$0")/.."
# 1. Terraform
cd "$INFRA_DIR"
tofu apply
# 2. Wait for SSH
SERVER_IP=$(tofu output -raw server_ip)
until ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new root@$SERVER_IP true 2>/dev/null; do
echo "Waiting for server..."
sleep 5
done
# 3. Ansible
cd ansible
tofu output -raw ansible_inventory > hosts.ini
ansible-galaxy install -r requirements.yml --force
ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i hosts.ini playbook.yml
# 4. Kamal bootstrap
cd ../..
bundle exec kamal server bootstrap
Based on kamal-ansible-manager:
# infra/ansible/playbook.yml
---
- name: Configure Hetzner server for Kamal
hosts: web
become: true
vars:
swap_file_size_mb: "2048"
timezone: "UTC"
roles:
- role: geerlingguy.swap
when: ansible_swaptotal_mb < 1
tasks:
- name: Install Docker
ansible.builtin.shell: curl -fsSL https://get.docker.com | sh
args:
creates: /usr/bin/docker
- name: Enable Docker
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Install security packages
ansible.builtin.apt:
name: [fail2ban, ufw]
state: present
update_cache: true
- name: Configure fail2ban
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
maxretry = 5
bantime = 3600
mode: "0644"
- name: Configure UFW
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: [22, 80, 443]
- name: Enable UFW
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Harden SSH
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
notify: Restart ssh
handlers:
- name: Restart ssh
ansible.builtin.systemd:
name: ssh
state: restarted
# infra/ansible/requirements.yml
---
roles:
- name: geerlingguy.swap
version: 2.0.0
| Approach | Use Case |
|---|---|
| Cloud-init only | Immutable infra, destroy/recreate pattern |
| Ansible only | Existing servers, complex multi-step config |
| Cloud-init + Ansible | First boot basics, then Ansible for hardening |
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.