OpenTofu is an open-source infrastructure as code tool that makes managing cloud resources predictable and repeatable. In this post, I’ll show you how to use OpenTofu to manage Hetzner Cloud infrastructure.
Why OpenTofu instead of Terraform?
OpenTofu is a community-driven fork of Terraform that emerged after HashiCorp changed Terraform’s license to BSL (Business Source License). OpenTofu uses the MPL-2.0 license, which ensures it remains truly open-source and free from vendor lock-in.
Its development is transparent and community-focused, making it a reliable long-term choice. It’s also a drop-in replacement for most Terraform configurations, so migration is straightforward.
Why OpenTofu instead of manual configuration?
Manual server management through web interfaces or CLI tools becomes problematic as your infrastructure grows. With OpenTofu, infrastructure changes are documented in code, making them reproducible and consistent across environments.
Version control allows you to track changes and roll back when needed. You can also integrate with CI/CD pipelines for automation and enable team collaboration through shared infrastructure code.
Setting up OpenTofu with Hetzner
First, install OpenTofu on macOS:
brew install opentofuCreate a .opentofu directory for your infrastructure configuration:
mkdir .opentofucd .opentofuBasic Hetzner server configuration
Create a main.tf file with your server configuration:
# Hetzner Cloud Provider Configurationterraform { required_providers { hcloud = { source = "hetznercloud/hcloud" version = "~> 1.45.0" } } required_version = ">= 1.6.0"}
# Configure the Hetzner Cloud Provider with API token from variablesprovider "hcloud" { token = var.hcloud_token}
# Define the SSH key resource - this manages your SSH key in Hetzner Cloud# The key will be used for secure server accessresource "hcloud_ssh_key" "my_key" { name = var.ssh_key_name public_key = file(pathexpand(var.ssh_key_path))}
# Define the firewall resource with security rules# This firewall allows SSH on custom port and HTTPS trafficresource "hcloud_firewall" "web_firewall" { name = var.firewall_name
# Allow SSH on custom port (default 22 changed for security) rule { direction = "in" protocol = "tcp" port = var.ssh_port source_ips = ["0.0.0.0/0", "::/0"] }
# Allow HTTPS traffic for web services rule { direction = "in" protocol = "tcp" port = "443" source_ips = ["0.0.0.0/0", "::/0"] }}
# Create the main server with all configurations# Uses Fedora 42 on ARM-based cax11 server type with backups enabledresource "hcloud_server" "web_server" { name = var.server_name server_type = var.server_type # cax11 - ARM-based server image = var.image # fedora-42 location = var.location # nbg1 - Nuremberg datacenter backups = true # Enable automatic backups
# Attach SSH key by directly referencing the resource's ID ssh_keys = [ hcloud_ssh_key.my_key.id ]
# Attach firewall by directly referencing the resource's ID firewall_ids = [ hcloud_firewall.web_firewall.id ]
# Cloud-init configuration for initial server setup # This template configures the server with custom user and SSH settings user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", { username = var.username, ssh_public_key = file(pathexpand(var.ssh_key_path)), ssh_port = var.ssh_port })}Create a cloud-init.yaml.tftpl template file for server initialization:
#cloud-config# Create a new user with sudo privileges and an SSH key# This replaces the default root user with a custom user for better securityusers: - name: ${username} # Username from OpenTofu variable groups: wheel # Add to wheel group for sudo access shell: /bin/bash # Set default shell sudo: ALL=(ALL) NOPASSWD:ALL # Grant passwordless sudo access ssh_authorized_keys: - ${ssh_public_key} # SSH public key from OpenTofu variable
# Disable password authentication system-wide for securityssh_pwauth: false
# Disable direct root login for securitydisable_root: true
# Write essential SSH daemon hardening configuration# This creates a custom SSH config file with security improvementswrite_files: - path: /etc/ssh/sshd_config.d/99-hardening.conf content: | # Change SSH port from the default 22 to reduce automated attacks Port ${ssh_port}
# Disable root login completely PermitRootLogin no
# Only allow key-based authentication (no passwords) PasswordAuthentication no PubkeyAuthentication yes AuthenticationMethods publickey
# Limit user access to the specified user only AllowUsers ${username}
# Set a grace time for authentication to 30 seconds LoginGraceTime 30
# Connection and security settings MaxAuthTries 3 # Limit failed login attempts ClientAliveInterval 300 # Send keepalive every 5 minutes ClientAliveCountMax 0 # Disconnect if no response
# Disable challenge-response, as we only use public keys ChallengeResponseAuthentication no
# Disable authentication mechanisms that are not used KerberosAuthentication no GSSAPIAuthentication no
# Disable unnecessary features for security X11Forwarding no # No GUI forwarding AllowTcpForwarding no # No port forwarding AllowAgentForwarding no # No SSH agent forwarding PermitTunnel no # No tunneling
# Increase logging verbosity for security monitoring LogLevel VERBOSE permissions: "0644" # Read-write for root, read-only for others owner: root:root # Owned by root
# Initial server setup commands# These commands run after the system is configuredruncmd: - systemctl restart sshd # Restart SSH daemon to apply new configurationCreate a variables.tf file with all configuration variables:
# Hetzner Cloud API Token - get this from Hetzner Cloud Consolevariable "hcloud_token" { description = "Hetzner Cloud API Token" type = string sensitive = true}
# SSH Key Variables - path to your public key filevariable "ssh_key_path" { description = "Path to the SSH public key file (e.g., ~/.ssh/hetzner.pub). The file must exist." type = string}
# SSH Key Name in Hetzner Cloudvariable "ssh_key_name" { description = "Name of the SSH key in Hetzner Cloud" type = string default = "my-ssh-key"}
# Server Name in Hetzner Cloudvariable "server_name" { description = "Name of the server in Hetzner Cloud" type = string default = "web-server"}
# Firewall Name in Hetzner Cloudvariable "firewall_name" { description = "Name of the firewall in Hetzner Cloud" type = string default = "web-firewall"}
# Username for the server - will be created with sudo accessvariable "username" { description = "Username for the server (will be created and granted sudo access)" type = string default = "myuser"}
# Custom SSH Port for security (changed from default 22)variable "ssh_port" { description = "Custom SSH port (default 22 changed for security)" type = number default = 2851}
# Server Type - cax11 is ARM-based and cost-effectivevariable "server_type" { description = "Hetzner Cloud server type (e.g., cax11, cx11, cpx11)" type = string default = "cax11"}
# Server Image - using Fedora 42 as the operating systemvariable "image" { description = "Hetzner Cloud server image (e.g., fedora-42, ubuntu-22.04)" type = string default = "fedora-42"}
# Server Location - nbg1 is Nuremberg datacentervariable "location" { description = "Hetzner Cloud server location (e.g., nbg1, fsn1, hel1)" type = string default = "nbg1"}Create an outputs.tf file to display useful information after deployment:
# Server name as configured in Hetzner Cloudoutput "server_name" { description = "The name of the server." value = hcloud_server.web_server.name}
# Server location (datacenter)output "server_location" { description = "The location of the server." value = hcloud_server.web_server.location}
# Server image (operating system)output "server_image" { description = "The image used by the server." value = hcloud_server.web_server.image}
# Server status (running, stopped, etc.)output "server_status" { description = "The status of the server." value = hcloud_server.web_server.status}
# SSH key name for referenceoutput "ssh_key_name" { description = "The name of the SSH key." value = hcloud_ssh_key.my_key.name}
# Firewall name for referenceoutput "firewall_name" { description = "The name of the firewall." value = hcloud_firewall.web_firewall.name}Create a .gitignore file to exclude sensitive and generated files from version control:
# Local .terraform directories**/.terraform/*
# .tfstate files*.tfstate*.tfstate.*
# Crash log filescrash.logcrash.*.log
# Exclude all .tfvars files to prevent accidental sensitive data exposure.# Only un-ignore specific tfvars files if they are safe to commit.*.tfvars*.tfvars.json
# Ignore override files as they are usually used for local testing and patches.override.tfoverride.tf.json*_override.tf*_override.tf.json
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan# .tfplan files can contain sensitive data.*.tfplan
# Ignore CLI configuration files.terraformrcterraform.rcCreate a terraform.tfvars.example file to document the available configuration options. Copy this to terraform.tfvars and fill in your values:
# Hetzner Cloud API Token (required)# Get this from Hetzner Cloud Console: https://console.hetzner.cloud/# Projects > [Your Project] > Security > API Tokenshcloud_token = "your_hetzner_api_token_here"
# SSH Key Configuration# Path to your SSH public key file (required)ssh_key_path = "~/.ssh/id_rsa.pub"# Name for the SSH key in Hetzner Cloud (optional, defaults to "my-ssh-key")ssh_key_name = "my-ssh-key"
# Server Configuration# Name for the server in Hetzner Cloud (optional, defaults to "web-server")server_name = "web-server"
# Firewall Configuration# Name for the firewall in Hetzner Cloud (optional, defaults to "web-firewall")firewall_name = "web-firewall"
# User Configuration# Username for the server (optional, defaults to "myuser")# This user will be created with sudo access and your SSH key will be added to itusername = "myuser"
# Server Hardware Configuration# Server type (optional, defaults to "cax11")# Available types: cax11, cx11, cpx11, cx21, cpx21, cx31, cpx31, etc.server_type = "cax11"
# Server image (optional, defaults to "fedora-42")# Available images: fedora-42, ubuntu-22.04, ubuntu-20.04, debian-12, etc.image = "fedora-42"
# Server location (optional, defaults to "nbg1")# Available locations: nbg1 (Nuremberg), fsn1 (Falkenstein), hel1 (Helsinki), ash (Ashburn), hil (Hillsboro)location = "nbg1"Using the configuration
-
Get your Hetzner API token from the Hetzner Cloud Console under “Security” → “API Tokens”
-
Create your variables file:
cp terraform.tfvars.example terraform.tfvarsEdit terraform.tfvars with your actual values (API token, SSH key path, preferred names, etc.).
- Initialize OpenTofu:
tofu init- Plan your changes:
tofu plan- Apply the configuration:
tofu apply- View outputs:
tofu outputBest practices
Keep sensitive data in variables rather than hardcoding them directly in your configuration files. Use remote state storage for team collaboration and better state management.
Commit your .tf files to version control but exclude .tfstate files, as they may contain sensitive data. Create reusable modules for common patterns and use tofu validate and tofu fmt regularly to maintain code quality.
Why not Hetzner CLI?
While hcloud CLI is useful for quick tasks, OpenTofu provides a declarative approach where you describe the desired state rather than individual commands. It automatically handles resource dependencies and tracks what exists versus what needs to change.
OpenTofu also offers easy rollback capability to revert to previous configurations. Your infrastructure becomes self-documenting through code, making it easier to understand and maintain over time.
Conclusion
OpenTofu makes Hetzner Cloud infrastructure management predictable and maintainable. It’s particularly valuable when you need to manage multiple servers, environments, or work in a team.
Start small with a single server configuration and gradually expand as you become more comfortable with the tool.