Managing Hetzner infrastructure with OpenTofu

· Tech

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:

Terminal window
brew install opentofu

Create a .opentofu directory for your infrastructure configuration:

Terminal window
mkdir .opentofu
cd .opentofu

Basic Hetzner server configuration

Create a main.tf file with your server configuration:

main.tf
# Hetzner Cloud Provider Configuration
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45.0"
}
}
required_version = ">= 1.6.0"
}
# Configure the Hetzner Cloud Provider with API token from variables
provider "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 access
resource "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 traffic
resource "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 enabled
resource "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-init.yaml.tftpl
#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 security
users:
- 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 security
ssh_pwauth: false
# Disable direct root login for security
disable_root: true
# Write essential SSH daemon hardening configuration
# This creates a custom SSH config file with security improvements
write_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 configured
runcmd:
- systemctl restart sshd # Restart SSH daemon to apply new configuration

Create a variables.tf file with all configuration variables:

variables.tf
# Hetzner Cloud API Token - get this from Hetzner Cloud Console
variable "hcloud_token" {
description = "Hetzner Cloud API Token"
type = string
sensitive = true
}
# SSH Key Variables - path to your public key file
variable "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 Cloud
variable "ssh_key_name" {
description = "Name of the SSH key in Hetzner Cloud"
type = string
default = "my-ssh-key"
}
# Server Name in Hetzner Cloud
variable "server_name" {
description = "Name of the server in Hetzner Cloud"
type = string
default = "web-server"
}
# Firewall Name in Hetzner Cloud
variable "firewall_name" {
description = "Name of the firewall in Hetzner Cloud"
type = string
default = "web-firewall"
}
# Username for the server - will be created with sudo access
variable "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-effective
variable "server_type" {
description = "Hetzner Cloud server type (e.g., cax11, cx11, cpx11)"
type = string
default = "cax11"
}
# Server Image - using Fedora 42 as the operating system
variable "image" {
description = "Hetzner Cloud server image (e.g., fedora-42, ubuntu-22.04)"
type = string
default = "fedora-42"
}
# Server Location - nbg1 is Nuremberg datacenter
variable "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:

outputs.tf
# Server name as configured in Hetzner Cloud
output "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 reference
output "ssh_key_name" {
description = "The name of the SSH key."
value = hcloud_ssh_key.my_key.name
}
# Firewall name for reference
output "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:

.gitignore
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.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.tf
override.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
.terraformrc
terraform.rc

Create a terraform.tfvars.example file to document the available configuration options. Copy this to terraform.tfvars and fill in your values:

terraform.tfvars.example
# Hetzner Cloud API Token (required)
# Get this from Hetzner Cloud Console: https://console.hetzner.cloud/
# Projects > [Your Project] > Security > API Tokens
hcloud_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 it
username = "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

  1. Get your Hetzner API token from the Hetzner Cloud Console under “Security” → “API Tokens”

  2. Create your variables file:

Terminal window
cp terraform.tfvars.example terraform.tfvars

Edit terraform.tfvars with your actual values (API token, SSH key path, preferred names, etc.).

  1. Initialize OpenTofu:
Terminal window
tofu init
  1. Plan your changes:
Terminal window
tofu plan
  1. Apply the configuration:
Terminal window
tofu apply
  1. View outputs:
Terminal window
tofu output

Best 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.