This repository contains a Terraform module for creating a Kubernetes cluster with Talos in the Hetzner Cloud.
See the codeNew to Hetzner? Get 20β¬ credit (and support this project)!
If this module saved you time or money, consider supporting ongoing maintenance.
This repository contains a Terraform module for creating a Kubernetes cluster with Talos in the Hetzner Cloud.
[!WARNING] This module is under active development. Not all features are compatible with each other yet. Known issues are listed in the Known Issues section. If you find a bug or have a feature request, please open an issue.
| Goals | Status | Description |
|---|---|---|
| Production ready | β | Designed around the recommendations from the Talos Production Clusters. You still need to handle DNS/LB setup, backups, and operations. |
| Use private networks for the internal communication of the cluster | β | Hetzner Cloud Networks are used for internal node-to-node communication. |
| Secure API Exposure | β | The Kubernetes and Talos APIs are exposed to the public internet but secured via firewall rules. By default (firewall_use_current_ip = true), only traffic from your current IP address is allowed. |
| Possibility to change all CIDRs of the networks | β | All network CIDRs (network, node, pod, service) can be customized. |
| Configure the Cluster optimally to run in the Hetzner Cloud | β | This includes manual configuration of the network devices and not via DHCP, provisioning of Floating IPs (VIP), etc. |
127.0.0.1:7445) from within the cluster nodes.cluster_api_host variable (
e.g., kube.mydomain.com).cluster_api_host, you must create a DNS A record for this hostname pointing to the public IP
address you want clients to use. This could be:
enable_floating_ip = true).kubeconfig will use this hostname if kubeconfig_endpoint_mode = "public_endpoint".talosconfig uses direct per-node IPs by default and can optionally use endpoint hostnames via talosconfig_endpoints_mode.cluster_api_host is the Kubernetes API endpoint (TCP/6443). Talos API access uses TCP/50000 and is
configured separately via talosconfig_endpoints_mode.kube.[cluster_domain] (e.g., kube.cluster.local), but you can override it via
cluster_api_host_private.enable_alias_ip = true (the default), this module automatically configures /etc/hosts entries on each node
to resolve the internal API hostname to the private alias IP (10.0.1.100 by default). This ensures reliable
internal communication.enable_alias_ip = false, you must provide a working private DNS record for cluster_api_host_private yourself
(or accept the single-node fallback when using a single control plane).kube.example.com -> 10.0.1.100) and set cluster_api_host_private to
that name. This prevents client-side DNS failures when Talos embeds the internal endpoint into kubeconfig.cluster_api_host is not set):
cluster_api_host, the generated kubeconfig will use an IP address directly as the endpoint
(controlled by kubeconfig_endpoint_mode, defaulting to the first control plane's public IP or the Floating IP).talosconfig endpoints are configured separately via talosconfig_endpoints_mode.kube.[cluster_domain]) if enable_alias_ip = true.bootstrap_endpoint_mode):
bootstrap_endpoint_mode = "private_ip" so Terraform bootstraps and health-checks the cluster via private IPs
instead of public IPs.disable_public_ipv4 = true to provision nodes without public IPv4 addresses entirely.[!IMPORTANT] The Cilium version (
cilium_version) has to be compatible with the Kubernetes (kubernetes_version) version.
[!TIP] After initial cluster bootstrap, you can set
deploy_cilium = false(anddeploy_prometheus_operator_crds = falseif you used it) to hand off management to GitOps tools (e.g., Argo CD, Flux). Runterraform applyonce after toggling: Terraform removes these resources from state without deleting them from the cluster. This works because the module useskubectl_manifestwithapply_only = true, so Terraform does not delete these manifests on destroy.
Node objects with information about the server from the Cloud, like instance Type, Location, Server ID, IPs.Node objects when the server is deleted in the API.type: LoadBalancer and creates Hetzner Cloud Load Balancers for them, adds Kubernetes
Nodes as targets for the Load Balancer.[!TIP] After initial cluster bootstrap, you can set
deploy_hcloud_ccm = falseto hand off management to GitOps tools (e.g., Argo CD, Flux). Runterraform applyonce after toggling: Terraform removes these resources from state without deleting them from the cluster. This works because the module useskubectl_manifestwithapply_only = true, so Terraform does not delete these manifests on destroy.
tailscale.enabled is set to true._packer/ workflow, which is no longer maintained[!TIP] New to Hetzner Cloud? Use this referral link to get 20β¬ credit and support this project.
HCLOUD_TOKEN or use it in the following commands/terraform
files.| Talos | Kubernetes |
|---|---|
v1.13.11 and later stable 1.13 patches | >= 1.31.0, < 1.36.99 |
v1.14.3 and later stable 1.14 patches | >= 1.32.0, < 1.37.99 |
Terraform rejects unsupported combinations, prereleases, and unapproved Talos
minor versions. The boot image must match talos_version. Default snapshot lookup
requires both os=talos and version=<talos_version> labels.
See SUPPORTED_VERSIONS.md for the policy, Cilium compatibility limits, and test coverage. See MIGRATIONS.md before upgrading an existing cluster.
terraform-provider-imager (Recommended)[!TIP] You can use official Hetzner Talos ISOs with
talos_iso_x86 = { id = "<x86-iso-id>" }and/ortalos_iso_arm = { id = "<arm-iso-id>" }. Check their versions; these ISOs can be outdated. List Talos ISO IDs:hcloud iso listCheck the Hetzner changelog for current Talos ISO IDs: https://docs.hetzner.cloud/changelog You can use custom Talos images withtalos_image_x86 = { id = "<x86-image-id>" }and/ortalos_image_arm = { id = "<arm-image-id>" }. List Talos image IDs:hcloud image list
[!WARNING] Prefer custom Talos images/snapshots created with
terraform-provider-imagerover the official Hetzner Talos ISOs. Some Hetzner-provided Talos ISOs have booted Talos inmetalmode instead ofhcloud, which leads to incorrect nodeproviderIDs such astalos://metal/.... This breakshcloud-cloud-controller-managerroute creation and can cause pod-to-pod networking failures across nodes. If you use a Talos ISO, verify its version before use. See #417.
Before deploying with Terraform, you need Talos OS images (snapshots) available in your Hetzner Cloud project. The maintained workflow is to create those snapshots directly from Terraform with the companion provider hcloud-talos/imager.
terraform {
required_providers {
imager = {
source = "hcloud-talos/imager"
}
}
}
provider "imager" {
token = var.hcloud_token
}
resource "imager_image" "talos_x86" {
image_url = "https://factory.talos.dev/image/<schematic-id>/<talos-version>/hcloud-amd64.raw.xz"
architecture = "x86"
labels = {
version = var.talos_version
}
}
Pass the resulting snapshot IDs into the module in image objects:
talos_image_x86 = {
id = imager_image.talos_x86.id
}
Use talos_image_arm for ARM images. A complete example is available in
examples/extended.
Each architecture supports an image object or an ISO object:
| Architecture | Image input | ISO input |
|---|---|---|
| x86 | talos_image_x86 = { id = "123456" } | talos_iso_x86 = { id = "122630" } |
| ARM | talos_image_arm = { id = "234567" } | talos_iso_arm = { id = "122629" } |
The object must be known during planning. Its id can remain unknown until apply,
so an image created in the same apply works without a separate image build step.
Do not make the presence of the object depend on an ID that is unknown during
planning. IDs must be numeric strings, not null or empty.
For each architecture, supply an image object or an ISO object, not both. Do not
combine the object with a legacy ID input for that architecture. ARM and x86 can
use different sources. With no supplied source, the module searches for a snapshot
with os=talos and version=<talos_version> labels.
The legacy talos_image_id_* and talos_iso_id_* inputs remain supported. Their
values must be known during planning when they control image lookup. Use the
object inputs for IDs from resources created in the same apply. See
MIGRATIONS.md.
disable_arm and disable_x86 skip automatic image lookup and ISO attachment for
the selected architecture. They do not disable nodes, and supplied image IDs are
still used. You do not need these flags to skip lookup when you supply an image
or ISO object. Set the flag for an unused architecture if no image is available
for it.
[!WARNING] The
_packer/workflow is deprecated and no longer maintained. Usehcloud-talos/imagerfor new clusters and future updates.
_packer/ directory.HCLOUD_TOKEN environment variable or enter it when prompted by the build script).create.sh script from the root of the repository:
./_packer/create.sh
talos_version used during the Packer build matches the talos_version variable set in your Terraform configuration to avoid potential incompatibilities.Legacy Instructions: If you still need the deprecated Packer flow, refer to
_packer/README.md.
Use the module as shown in the following working minimal example:
[!NOTE] Verify version compatibility before deploying:
[!NOTE] Actually, your current IP address has to have access to the nodes during the creation of the cluster.
module "talos" {
source = "hcloud-talos/talos/hcloud"
# Find the latest version on the Terraform Registry:
# https://registry.terraform.io/modules/hcloud-talos/talos/hcloud
version = "<latest-version>" # Replace with the latest version number
talos_version = "v1.14.3" # Must match the boot image.
kubernetes_version = "1.35.0"
# Optional: use official Hetzner Talos ISOs.
# talos_iso_x86 = { id = "<x86-iso-id>" }
# talos_iso_arm = { id = "<arm-iso-id>" }
# Or use custom Talos images created by terraform-provider-imager.
# talos_image_x86 = { id = imager_image.talos_x86.id }
# talos_image_arm = { id = imager_image.talos_arm.id }
hcloud_token = "your-hcloud-token"
# If true, the current IP address will be used as the source for the firewall rules.
# ATTENTION: to determine the current IP, a request to a public service (https://ipv4.icanhazip.com) is made.
# If false, you have to provide your public IP address (as list) in the variable `firewall_kube_api_source` and `firewall_talos_api_source`.
firewall_use_current_ip = true
cluster_name = "dummy.com"
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cax11"
}
]
}
Or a more advanced example:
module "talos" {
source = "hcloud-talos/talos/hcloud"
# Find the latest version on the Terraform Registry:
# https://registry.terraform.io/modules/hcloud-talos/talos/hcloud
version = "<latest-version>" # Replace with the latest version number
# Use a supported Talos and Kubernetes combination.
talos_version = "v1.14.3"
kubernetes_version = "1.35.0"
cilium_version = "1.20.3"
hcloud_token = "your-hcloud-token"
cluster_name = "dummy.com"
cluster_domain = "cluster.dummy.com.local"
cluster_api_host = "kube.dummy.com"
firewall_use_current_ip = false
firewall_kube_api_source = ["your-ip"]
firewall_talos_api_source = ["your-ip"]
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cax11"
},
{
id = 2
type = "cax11"
},
{
id = 3
type = "cax11"
}
]
control_plane_allow_schedule = true
worker_nodes = [
{
id = 1
type = "cax21"
},
{
id = 2
type = "cax21"
},
{
id = 3
type = "cax21"
}
]
network_ipv4_cidr = "10.0.0.0/16"
node_ipv4_cidr = "10.0.1.0/24"
pod_ipv4_cidr = "10.0.16.0/20"
service_ipv4_cidr = "10.0.8.0/21"
# Enable Tailscale integration
tailscale = {
enabled = true
auth_key = "tskey-auth-xxxxxxxxxxxx" # Your Tailscale auth key
}
}
These snippets show only the endpoint- and access-related settings. Combine them with the required module inputs from the examples above.
Use Talos's built-in WireGuard to bootstrap and manage the cluster over private IPs. No site-to-site VPN VM or external DNS needed.
On the cluster side (added as a machine config patch):
talos_control_plane_extra_config_patches = [
yamlencode({
apiVersion = "v1alpha1"
kind = "WireguardConfig"
name = "wg0"
privateKey = "<base64-node-private-key>"
listenPort = 51820
addresses = [{ address = "10.200.0.1/24" }]
peers = [{
publicKey = "<base64-workstation-public-key>"
allowedIPs = ["10.200.0.0/24"]
}]
})
]
extra_firewall_rules = [
{
direction = "in"
protocol = "udp"
port = "51820"
source_ips = ["0.0.0.0/0"]
description = "WireGuard VPN tunnel"
}
]
On your workstation (macOS WireGuard app or wg-quick):
[Interface]
PrivateKey = <base64-workstation-private-key>
Address = 10.200.0.250/24
[Peer]
PublicKey = <base64-node-public-key>
Endpoint = <control-plane-public-ip>:51820
AllowedIPs = 10.200.0.0/24, 10.0.1.0/24
PersistentKeepalive = 25
Once the tunnel is active, switch to private bootstrap:
bootstrap_endpoint_mode = "private_ip"
kubeconfig_endpoint_mode = "private_ip"
talosconfig_endpoints_mode = "private_ip"
[!NOTE]
- The WireGuard patch is applied at node boot time (part of the Talos machine config).
- For the initial deployment, bootstrap still uses public IPs (
bootstrap_endpoint_mode = "public_ip", the default).- After nodes boot with the WireGuard interface active, enable private bootstrap for subsequent operations.
Use this when you already have a VPN gateway VM (10.0.1.250) on the same private network, or when you have split-horizon DNS resolving kube.vpn.example.com to the private VIP (10.0.1.100).
enable_alias_ip = true # default
cluster_api_host_private = "kube.vpn.example.com" # -> 10.0.1.100 (private VIP)
bootstrap_endpoint_mode = "private_ip"
kubeconfig_endpoint_mode = "private_ip" # uses 10.0.1.100 directly
# kubeconfig_endpoint_mode = "private_endpoint" # alternative: requires DNS
talosconfig_endpoints_mode = "private_ip"
# Optional: remove public IPv4 entirely
# disable_public_ipv4 = true
Provision nodes without public IPv4 addresses for maximum cost optimization and minimum attack surface. Requires the WireGuard tunnel or VPN gateway to be active before applying.
bootstrap_endpoint_mode = "private_ip"
disable_public_ipv4 = true
kubeconfig_endpoint_mode = "private_ip"
talosconfig_endpoints_mode = "private_ip"
[!WARNING] When
disable_public_ipv4 = true, nodes have no public IPs. All Terraform operations (bootstrap, health check, kubeconfig retrieval) use private IPs via the WireGuard tunnel or VPN gateway. Ensure your tunnel is active and reachable before applying.
Use this when you want a public, stable Kubernetes API endpoint without running your own load balancer.
firewall_use_current_ip = true
enable_floating_ip = true
kubeconfig_endpoint_mode = "public_ip" # uses the Floating IP for HA control planes
talosconfig_endpoints_mode = "public_ip"
Use this when you have a dedicated TCP (L4) load balancer pointing to all control planes on port 6443.
firewall_use_current_ip = true
cluster_api_host = "kube.example.com" # -> LB IP/DNS
kubeconfig_endpoint_mode = "public_endpoint"
talosconfig_endpoints_mode = "public_ip"
Use this when nodes should use a private VIP/hostname, but your kubeconfig should point to a public DNS/LB.
firewall_use_current_ip = true
enable_alias_ip = true # private VIP for nodes
cluster_api_host_private = "kube.internal.example.com" # -> 10.0.1.100 (private VIP)
cluster_api_host = "kube.example.com" # -> public Floating IP or TCP LB
kubeconfig_endpoint_mode = "public_endpoint"
talosconfig_endpoints_mode = "public_ip"
You can attach a VPN gateway VM (from another Terraform module) to the same private network as the cluster, allowing secure access from remote locations without exposing services to the public internet.
How it works:
10.0.1.250)Pattern A β Reference the network from another module:
module "cluster" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "vpn-cluster"
location_name = "fsn1"
control_plane_nodes = [
{ id = 1, type = "cax11" }
]
}
# In your VPN gateway module, consume the outputs:
module "vpn_gateway" {
source = "./vpn-gateway"
hcloud_network_id = module.cluster.hetzner_network_id
node_subnet_cidr = module.cluster.node_ipv4_cidr
# Give the VPN gateway a private IP in the same subnet, e.g., 10.0.1.250
}
Pattern B β Share a network between cluster and VPN modules (BYO network):
# Create the shared network once
resource "hcloud_network" "shared" {
name = "shared-net"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "shared" {
network_id = hcloud_network.shared.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.0.1.0/24"
}
# Pass it to the cluster module
module "cluster" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "vpn-cluster"
location_name = "fsn1"
# BYO network: module uses this instead of creating a new one
network_id = hcloud_network.shared.id
node_ipv4_cidr = "10.0.1.0/24" # must match the existing subnet
control_plane_nodes = [
{ id = 1, type = "cax11" }
]
}
# VPN gateway attached to the same network
module "vpn_gateway" {
source = "./vpn-gateway"
hcloud_network_id = hcloud_network.shared.id
node_subnet_cidr = "10.0.1.0/24"
}
Adding network routes for VPN client subnets:
If you want proper routing (instead of SNAT) between VPN clients and cluster nodes, add static routes to the Hetzner network:
module "cluster" {
# ... other settings ...
network_routes = [
{
destination = "10.8.0.0/24" # VPN client subnet
gateway = "10.0.1.250" # VPN gateway private IP
}
]
}
For more advanced use cases, you can define different types of worker nodes with individual configurations using the worker_nodes variable:
module "talos" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
talos_version = "v1.14.3"
kubernetes_version = "1.35.0"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "mixed-cluster"
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cx22"
}
]
# Define different worker node types
worker_nodes = [
# Standard x86 workers
{
id = 1
type = "cx22"
labels = {
"node.kubernetes.io/instance-type" = "cx22"
}
},
# ARM workers for specific workloads with taints
{
id = 2
type = "cax21"
labels = {
"node.kubernetes.io/arch" = "arm64"
"affinity.example.com" = "example"
}
taints = [
{
key = "arm64-only"
value = "true"
effect = "NoSchedule"
}
]
}
]
}
[!NOTE] The
worker_nodesvariable allows you to:
- Mix different server types (x86 and ARM)
- Add custom labels to nodes
- Apply taints for workload isolation
- Control the number of nodes by adding/removing entries
- Keep stable node identity by setting
id(1..N)
You need to pipe the outputs of the module:
output "talosconfig" {
value = module.talos.talosconfig
sensitive = true
}
output "kubeconfig" {
value = module.talos.kubeconfig
sensitive = true
}
Then you can then run the following commands to export the kubeconfig and talosconfig:
# Save the configs to files
terraform output --raw kubeconfig > ./kubeconfig
terraform output --raw talosconfig > ./talosconfig
You can then use kubectl and talosctl to interact with your cluster.
Remember to move the generated config files to a persistent location if needed (
e.g., ~/.kube/config, ~/.talos/config).
This module supports configuring Tailscale on your cluster nodes, which provides secure networking capabilities:
tailscale = {
enabled = true
auth_key = "tskey-auth-xxxxxxxxxxxx" # Your Tailscale auth key
}
When Tailscale is enabled:
[!NOTE] You must provide a valid Tailscale auth key when enabling this feature. Auth keys can be generated in the Tailscale admin console. For more information, see the Tailscale documentation on authentication keys.
kubelet_extra_args = {
system-reserved = "cpu=100m,memory=250Mi,ephemeral-storage=1Gi"
kube-reserved = "cpu=100m,memory=200Mi,ephemeral-storage=1Gi"
eviction-hard = "memory.available<100Mi,nodefs.available<10%"
eviction-soft = "memory.available<200Mi,nodefs.available<15%"
eviction-soft-grace-period = "memory.available=2m30s,nodefs.available=4m"
}
sysctls_extra_args = {
# Fix for https://github.com/cloudflare/cloudflared/issues/1176
"net.core.rmem_default" = "26214400"
"net.core.wmem_default" = "26214400"
"net.core.rmem_max" = "26214400"
"net.core.wmem_max" = "26214400"
}
kernel_modules_to_load = [
{
name = "binfmt_misc" # Required for QEMU
}
]
The kubernetes_version variable in this Terraform module is used for the initial deployment of your Kubernetes cluster.
It does not trigger in-place Kubernetes version upgrades on existing nodes.
To upgrade your Kubernetes cluster, you must use the talosctl upgrade-k8s command.
Important Considerations for talosctl commands:
talosctl talks to the Talos API (TCP/50000). Prefer talosconfig_endpoints_mode = "public_ip"
when running from outside, or "private_ip" over VPN/private networking. Endpoint hostname modes
("public_endpoint" / "private_endpoint") are also available for explicit gateway/proxy workflows.talosconfig (not a
VIP), because VIP availability depends on etcd health.firewall_use_current_ip or firewall_talos_api_source)
allow access to the Talos API port (default 50000) on your control plane nodes from where you are running talosctl.
Connectivity issues (e.g., i/o timeout) can occur if this port is blocked.Refer to the official Talos documentation on upgrading Kubernetes for detailed steps and best practices.
user_data (e.g. talos_machine_configuration) and image (e.g. version upgrades with packer) will
not be applied to existing nodes, because it would force a recreation of the nodes.network block for control-plane servers. This lets Talos manage the VIP alias IP. A check
warns if a server's network ID or private IP differs from the configuration. The check does not compare alias IPs and
does not stop a plan or apply. Terraform will not apply network ID or private IP changes to control-plane servers.
Review each warning and plan any network migration separately. Terraform continues to manage worker network blocks.enable_ipv6, but it currently has no
effect on the cluster's internal networking configuration provided by this module.enable_kube_span = true might prevent the cluster from reaching a ready state in some configurations.
Further investigation is needed.403 Forbidden user in startup log: This is a known issue related to rate limiting or IP blocking
by registry.k8s.io affecting some Hetzner IP ranges.
See #46
and registry.k8s.io #138.If this module saved you time or helped you run Talos on Hetzner more reliably, consider supporting ongoing maintenance:
Sponsorship is about sustainability and public appreciation, not a paid support contract or SLA. Sponsors can be acknowledged publicly via GitHub Sponsors.
656 followers Β· starred Jan 2026
168 followers Β· starred May 2025
72 followers Β· starred Dec 2024
156 followers Β· starred Apr 2024
This repository contains a Terraform module for creating a Kubernetes cluster with Talos in the Hetzner Cloud.
See the codeNew to Hetzner? Get 20β¬ credit (and support this project)!
If this module saved you time or money, consider supporting ongoing maintenance.
This repository contains a Terraform module for creating a Kubernetes cluster with Talos in the Hetzner Cloud.
[!WARNING] This module is under active development. Not all features are compatible with each other yet. Known issues are listed in the Known Issues section. If you find a bug or have a feature request, please open an issue.
| Goals | Status | Description |
|---|---|---|
| Production ready | β | Designed around the recommendations from the Talos Production Clusters. You still need to handle DNS/LB setup, backups, and operations. |
| Use private networks for the internal communication of the cluster | β | Hetzner Cloud Networks are used for internal node-to-node communication. |
| Secure API Exposure | β | The Kubernetes and Talos APIs are exposed to the public internet but secured via firewall rules. By default (firewall_use_current_ip = true), only traffic from your current IP address is allowed. |
| Possibility to change all CIDRs of the networks | β | All network CIDRs (network, node, pod, service) can be customized. |
| Configure the Cluster optimally to run in the Hetzner Cloud | β | This includes manual configuration of the network devices and not via DHCP, provisioning of Floating IPs (VIP), etc. |
127.0.0.1:7445) from within the cluster nodes.cluster_api_host variable (
e.g., kube.mydomain.com).cluster_api_host, you must create a DNS A record for this hostname pointing to the public IP
address you want clients to use. This could be:
enable_floating_ip = true).kubeconfig will use this hostname if kubeconfig_endpoint_mode = "public_endpoint".talosconfig uses direct per-node IPs by default and can optionally use endpoint hostnames via talosconfig_endpoints_mode.cluster_api_host is the Kubernetes API endpoint (TCP/6443). Talos API access uses TCP/50000 and is
configured separately via talosconfig_endpoints_mode.kube.[cluster_domain] (e.g., kube.cluster.local), but you can override it via
cluster_api_host_private.enable_alias_ip = true (the default), this module automatically configures /etc/hosts entries on each node
to resolve the internal API hostname to the private alias IP (10.0.1.100 by default). This ensures reliable
internal communication.enable_alias_ip = false, you must provide a working private DNS record for cluster_api_host_private yourself
(or accept the single-node fallback when using a single control plane).kube.example.com -> 10.0.1.100) and set cluster_api_host_private to
that name. This prevents client-side DNS failures when Talos embeds the internal endpoint into kubeconfig.cluster_api_host is not set):
cluster_api_host, the generated kubeconfig will use an IP address directly as the endpoint
(controlled by kubeconfig_endpoint_mode, defaulting to the first control plane's public IP or the Floating IP).talosconfig endpoints are configured separately via talosconfig_endpoints_mode.kube.[cluster_domain]) if enable_alias_ip = true.bootstrap_endpoint_mode):
bootstrap_endpoint_mode = "private_ip" so Terraform bootstraps and health-checks the cluster via private IPs
instead of public IPs.disable_public_ipv4 = true to provision nodes without public IPv4 addresses entirely.[!IMPORTANT] The Cilium version (
cilium_version) has to be compatible with the Kubernetes (kubernetes_version) version.
[!TIP] After initial cluster bootstrap, you can set
deploy_cilium = false(anddeploy_prometheus_operator_crds = falseif you used it) to hand off management to GitOps tools (e.g., Argo CD, Flux). Runterraform applyonce after toggling: Terraform removes these resources from state without deleting them from the cluster. This works because the module useskubectl_manifestwithapply_only = true, so Terraform does not delete these manifests on destroy.
Node objects with information about the server from the Cloud, like instance Type, Location, Server ID, IPs.Node objects when the server is deleted in the API.type: LoadBalancer and creates Hetzner Cloud Load Balancers for them, adds Kubernetes
Nodes as targets for the Load Balancer.[!TIP] After initial cluster bootstrap, you can set
deploy_hcloud_ccm = falseto hand off management to GitOps tools (e.g., Argo CD, Flux). Runterraform applyonce after toggling: Terraform removes these resources from state without deleting them from the cluster. This works because the module useskubectl_manifestwithapply_only = true, so Terraform does not delete these manifests on destroy.
tailscale.enabled is set to true._packer/ workflow, which is no longer maintained[!TIP] New to Hetzner Cloud? Use this referral link to get 20β¬ credit and support this project.
HCLOUD_TOKEN or use it in the following commands/terraform
files.| Talos | Kubernetes |
|---|---|
v1.13.11 and later stable 1.13 patches | >= 1.31.0, < 1.36.99 |
v1.14.3 and later stable 1.14 patches | >= 1.32.0, < 1.37.99 |
Terraform rejects unsupported combinations, prereleases, and unapproved Talos
minor versions. The boot image must match talos_version. Default snapshot lookup
requires both os=talos and version=<talos_version> labels.
See SUPPORTED_VERSIONS.md for the policy, Cilium compatibility limits, and test coverage. See MIGRATIONS.md before upgrading an existing cluster.
terraform-provider-imager (Recommended)[!TIP] You can use official Hetzner Talos ISOs with
talos_iso_x86 = { id = "<x86-iso-id>" }and/ortalos_iso_arm = { id = "<arm-iso-id>" }. Check their versions; these ISOs can be outdated. List Talos ISO IDs:hcloud iso listCheck the Hetzner changelog for current Talos ISO IDs: https://docs.hetzner.cloud/changelog You can use custom Talos images withtalos_image_x86 = { id = "<x86-image-id>" }and/ortalos_image_arm = { id = "<arm-image-id>" }. List Talos image IDs:hcloud image list
[!WARNING] Prefer custom Talos images/snapshots created with
terraform-provider-imagerover the official Hetzner Talos ISOs. Some Hetzner-provided Talos ISOs have booted Talos inmetalmode instead ofhcloud, which leads to incorrect nodeproviderIDs such astalos://metal/.... This breakshcloud-cloud-controller-managerroute creation and can cause pod-to-pod networking failures across nodes. If you use a Talos ISO, verify its version before use. See #417.
Before deploying with Terraform, you need Talos OS images (snapshots) available in your Hetzner Cloud project. The maintained workflow is to create those snapshots directly from Terraform with the companion provider hcloud-talos/imager.
terraform {
required_providers {
imager = {
source = "hcloud-talos/imager"
}
}
}
provider "imager" {
token = var.hcloud_token
}
resource "imager_image" "talos_x86" {
image_url = "https://factory.talos.dev/image/<schematic-id>/<talos-version>/hcloud-amd64.raw.xz"
architecture = "x86"
labels = {
version = var.talos_version
}
}
Pass the resulting snapshot IDs into the module in image objects:
talos_image_x86 = {
id = imager_image.talos_x86.id
}
Use talos_image_arm for ARM images. A complete example is available in
examples/extended.
Each architecture supports an image object or an ISO object:
| Architecture | Image input | ISO input |
|---|---|---|
| x86 | talos_image_x86 = { id = "123456" } | talos_iso_x86 = { id = "122630" } |
| ARM | talos_image_arm = { id = "234567" } | talos_iso_arm = { id = "122629" } |
The object must be known during planning. Its id can remain unknown until apply,
so an image created in the same apply works without a separate image build step.
Do not make the presence of the object depend on an ID that is unknown during
planning. IDs must be numeric strings, not null or empty.
For each architecture, supply an image object or an ISO object, not both. Do not
combine the object with a legacy ID input for that architecture. ARM and x86 can
use different sources. With no supplied source, the module searches for a snapshot
with os=talos and version=<talos_version> labels.
The legacy talos_image_id_* and talos_iso_id_* inputs remain supported. Their
values must be known during planning when they control image lookup. Use the
object inputs for IDs from resources created in the same apply. See
MIGRATIONS.md.
disable_arm and disable_x86 skip automatic image lookup and ISO attachment for
the selected architecture. They do not disable nodes, and supplied image IDs are
still used. You do not need these flags to skip lookup when you supply an image
or ISO object. Set the flag for an unused architecture if no image is available
for it.
[!WARNING] The
_packer/workflow is deprecated and no longer maintained. Usehcloud-talos/imagerfor new clusters and future updates.
_packer/ directory.HCLOUD_TOKEN environment variable or enter it when prompted by the build script).create.sh script from the root of the repository:
./_packer/create.sh
talos_version used during the Packer build matches the talos_version variable set in your Terraform configuration to avoid potential incompatibilities.Legacy Instructions: If you still need the deprecated Packer flow, refer to
_packer/README.md.
Use the module as shown in the following working minimal example:
[!NOTE] Verify version compatibility before deploying:
[!NOTE] Actually, your current IP address has to have access to the nodes during the creation of the cluster.
module "talos" {
source = "hcloud-talos/talos/hcloud"
# Find the latest version on the Terraform Registry:
# https://registry.terraform.io/modules/hcloud-talos/talos/hcloud
version = "<latest-version>" # Replace with the latest version number
talos_version = "v1.14.3" # Must match the boot image.
kubernetes_version = "1.35.0"
# Optional: use official Hetzner Talos ISOs.
# talos_iso_x86 = { id = "<x86-iso-id>" }
# talos_iso_arm = { id = "<arm-iso-id>" }
# Or use custom Talos images created by terraform-provider-imager.
# talos_image_x86 = { id = imager_image.talos_x86.id }
# talos_image_arm = { id = imager_image.talos_arm.id }
hcloud_token = "your-hcloud-token"
# If true, the current IP address will be used as the source for the firewall rules.
# ATTENTION: to determine the current IP, a request to a public service (https://ipv4.icanhazip.com) is made.
# If false, you have to provide your public IP address (as list) in the variable `firewall_kube_api_source` and `firewall_talos_api_source`.
firewall_use_current_ip = true
cluster_name = "dummy.com"
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cax11"
}
]
}
Or a more advanced example:
module "talos" {
source = "hcloud-talos/talos/hcloud"
# Find the latest version on the Terraform Registry:
# https://registry.terraform.io/modules/hcloud-talos/talos/hcloud
version = "<latest-version>" # Replace with the latest version number
# Use a supported Talos and Kubernetes combination.
talos_version = "v1.14.3"
kubernetes_version = "1.35.0"
cilium_version = "1.20.3"
hcloud_token = "your-hcloud-token"
cluster_name = "dummy.com"
cluster_domain = "cluster.dummy.com.local"
cluster_api_host = "kube.dummy.com"
firewall_use_current_ip = false
firewall_kube_api_source = ["your-ip"]
firewall_talos_api_source = ["your-ip"]
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cax11"
},
{
id = 2
type = "cax11"
},
{
id = 3
type = "cax11"
}
]
control_plane_allow_schedule = true
worker_nodes = [
{
id = 1
type = "cax21"
},
{
id = 2
type = "cax21"
},
{
id = 3
type = "cax21"
}
]
network_ipv4_cidr = "10.0.0.0/16"
node_ipv4_cidr = "10.0.1.0/24"
pod_ipv4_cidr = "10.0.16.0/20"
service_ipv4_cidr = "10.0.8.0/21"
# Enable Tailscale integration
tailscale = {
enabled = true
auth_key = "tskey-auth-xxxxxxxxxxxx" # Your Tailscale auth key
}
}
These snippets show only the endpoint- and access-related settings. Combine them with the required module inputs from the examples above.
Use Talos's built-in WireGuard to bootstrap and manage the cluster over private IPs. No site-to-site VPN VM or external DNS needed.
On the cluster side (added as a machine config patch):
talos_control_plane_extra_config_patches = [
yamlencode({
apiVersion = "v1alpha1"
kind = "WireguardConfig"
name = "wg0"
privateKey = "<base64-node-private-key>"
listenPort = 51820
addresses = [{ address = "10.200.0.1/24" }]
peers = [{
publicKey = "<base64-workstation-public-key>"
allowedIPs = ["10.200.0.0/24"]
}]
})
]
extra_firewall_rules = [
{
direction = "in"
protocol = "udp"
port = "51820"
source_ips = ["0.0.0.0/0"]
description = "WireGuard VPN tunnel"
}
]
On your workstation (macOS WireGuard app or wg-quick):
[Interface]
PrivateKey = <base64-workstation-private-key>
Address = 10.200.0.250/24
[Peer]
PublicKey = <base64-node-public-key>
Endpoint = <control-plane-public-ip>:51820
AllowedIPs = 10.200.0.0/24, 10.0.1.0/24
PersistentKeepalive = 25
Once the tunnel is active, switch to private bootstrap:
bootstrap_endpoint_mode = "private_ip"
kubeconfig_endpoint_mode = "private_ip"
talosconfig_endpoints_mode = "private_ip"
[!NOTE]
- The WireGuard patch is applied at node boot time (part of the Talos machine config).
- For the initial deployment, bootstrap still uses public IPs (
bootstrap_endpoint_mode = "public_ip", the default).- After nodes boot with the WireGuard interface active, enable private bootstrap for subsequent operations.
Use this when you already have a VPN gateway VM (10.0.1.250) on the same private network, or when you have split-horizon DNS resolving kube.vpn.example.com to the private VIP (10.0.1.100).
enable_alias_ip = true # default
cluster_api_host_private = "kube.vpn.example.com" # -> 10.0.1.100 (private VIP)
bootstrap_endpoint_mode = "private_ip"
kubeconfig_endpoint_mode = "private_ip" # uses 10.0.1.100 directly
# kubeconfig_endpoint_mode = "private_endpoint" # alternative: requires DNS
talosconfig_endpoints_mode = "private_ip"
# Optional: remove public IPv4 entirely
# disable_public_ipv4 = true
Provision nodes without public IPv4 addresses for maximum cost optimization and minimum attack surface. Requires the WireGuard tunnel or VPN gateway to be active before applying.
bootstrap_endpoint_mode = "private_ip"
disable_public_ipv4 = true
kubeconfig_endpoint_mode = "private_ip"
talosconfig_endpoints_mode = "private_ip"
[!WARNING] When
disable_public_ipv4 = true, nodes have no public IPs. All Terraform operations (bootstrap, health check, kubeconfig retrieval) use private IPs via the WireGuard tunnel or VPN gateway. Ensure your tunnel is active and reachable before applying.
Use this when you want a public, stable Kubernetes API endpoint without running your own load balancer.
firewall_use_current_ip = true
enable_floating_ip = true
kubeconfig_endpoint_mode = "public_ip" # uses the Floating IP for HA control planes
talosconfig_endpoints_mode = "public_ip"
Use this when you have a dedicated TCP (L4) load balancer pointing to all control planes on port 6443.
firewall_use_current_ip = true
cluster_api_host = "kube.example.com" # -> LB IP/DNS
kubeconfig_endpoint_mode = "public_endpoint"
talosconfig_endpoints_mode = "public_ip"
Use this when nodes should use a private VIP/hostname, but your kubeconfig should point to a public DNS/LB.
firewall_use_current_ip = true
enable_alias_ip = true # private VIP for nodes
cluster_api_host_private = "kube.internal.example.com" # -> 10.0.1.100 (private VIP)
cluster_api_host = "kube.example.com" # -> public Floating IP or TCP LB
kubeconfig_endpoint_mode = "public_endpoint"
talosconfig_endpoints_mode = "public_ip"
You can attach a VPN gateway VM (from another Terraform module) to the same private network as the cluster, allowing secure access from remote locations without exposing services to the public internet.
How it works:
10.0.1.250)Pattern A β Reference the network from another module:
module "cluster" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "vpn-cluster"
location_name = "fsn1"
control_plane_nodes = [
{ id = 1, type = "cax11" }
]
}
# In your VPN gateway module, consume the outputs:
module "vpn_gateway" {
source = "./vpn-gateway"
hcloud_network_id = module.cluster.hetzner_network_id
node_subnet_cidr = module.cluster.node_ipv4_cidr
# Give the VPN gateway a private IP in the same subnet, e.g., 10.0.1.250
}
Pattern B β Share a network between cluster and VPN modules (BYO network):
# Create the shared network once
resource "hcloud_network" "shared" {
name = "shared-net"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "shared" {
network_id = hcloud_network.shared.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.0.1.0/24"
}
# Pass it to the cluster module
module "cluster" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "vpn-cluster"
location_name = "fsn1"
# BYO network: module uses this instead of creating a new one
network_id = hcloud_network.shared.id
node_ipv4_cidr = "10.0.1.0/24" # must match the existing subnet
control_plane_nodes = [
{ id = 1, type = "cax11" }
]
}
# VPN gateway attached to the same network
module "vpn_gateway" {
source = "./vpn-gateway"
hcloud_network_id = hcloud_network.shared.id
node_subnet_cidr = "10.0.1.0/24"
}
Adding network routes for VPN client subnets:
If you want proper routing (instead of SNAT) between VPN clients and cluster nodes, add static routes to the Hetzner network:
module "cluster" {
# ... other settings ...
network_routes = [
{
destination = "10.8.0.0/24" # VPN client subnet
gateway = "10.0.1.250" # VPN gateway private IP
}
]
}
For more advanced use cases, you can define different types of worker nodes with individual configurations using the worker_nodes variable:
module "talos" {
source = "hcloud-talos/talos/hcloud"
version = "<latest-version>"
talos_version = "v1.14.3"
kubernetes_version = "1.35.0"
hcloud_token = "your-hcloud-token"
firewall_use_current_ip = true
cluster_name = "mixed-cluster"
location_name = "fsn1"
control_plane_nodes = [
{
id = 1
type = "cx22"
}
]
# Define different worker node types
worker_nodes = [
# Standard x86 workers
{
id = 1
type = "cx22"
labels = {
"node.kubernetes.io/instance-type" = "cx22"
}
},
# ARM workers for specific workloads with taints
{
id = 2
type = "cax21"
labels = {
"node.kubernetes.io/arch" = "arm64"
"affinity.example.com" = "example"
}
taints = [
{
key = "arm64-only"
value = "true"
effect = "NoSchedule"
}
]
}
]
}
[!NOTE] The
worker_nodesvariable allows you to:
- Mix different server types (x86 and ARM)
- Add custom labels to nodes
- Apply taints for workload isolation
- Control the number of nodes by adding/removing entries
- Keep stable node identity by setting
id(1..N)
You need to pipe the outputs of the module:
output "talosconfig" {
value = module.talos.talosconfig
sensitive = true
}
output "kubeconfig" {
value = module.talos.kubeconfig
sensitive = true
}
Then you can then run the following commands to export the kubeconfig and talosconfig:
# Save the configs to files
terraform output --raw kubeconfig > ./kubeconfig
terraform output --raw talosconfig > ./talosconfig
You can then use kubectl and talosctl to interact with your cluster.
Remember to move the generated config files to a persistent location if needed (
e.g., ~/.kube/config, ~/.talos/config).
This module supports configuring Tailscale on your cluster nodes, which provides secure networking capabilities:
tailscale = {
enabled = true
auth_key = "tskey-auth-xxxxxxxxxxxx" # Your Tailscale auth key
}
When Tailscale is enabled:
[!NOTE] You must provide a valid Tailscale auth key when enabling this feature. Auth keys can be generated in the Tailscale admin console. For more information, see the Tailscale documentation on authentication keys.
kubelet_extra_args = {
system-reserved = "cpu=100m,memory=250Mi,ephemeral-storage=1Gi"
kube-reserved = "cpu=100m,memory=200Mi,ephemeral-storage=1Gi"
eviction-hard = "memory.available<100Mi,nodefs.available<10%"
eviction-soft = "memory.available<200Mi,nodefs.available<15%"
eviction-soft-grace-period = "memory.available=2m30s,nodefs.available=4m"
}
sysctls_extra_args = {
# Fix for https://github.com/cloudflare/cloudflared/issues/1176
"net.core.rmem_default" = "26214400"
"net.core.wmem_default" = "26214400"
"net.core.rmem_max" = "26214400"
"net.core.wmem_max" = "26214400"
}
kernel_modules_to_load = [
{
name = "binfmt_misc" # Required for QEMU
}
]
The kubernetes_version variable in this Terraform module is used for the initial deployment of your Kubernetes cluster.
It does not trigger in-place Kubernetes version upgrades on existing nodes.
To upgrade your Kubernetes cluster, you must use the talosctl upgrade-k8s command.
Important Considerations for talosctl commands:
talosctl talks to the Talos API (TCP/50000). Prefer talosconfig_endpoints_mode = "public_ip"
when running from outside, or "private_ip" over VPN/private networking. Endpoint hostname modes
("public_endpoint" / "private_endpoint") are also available for explicit gateway/proxy workflows.talosconfig (not a
VIP), because VIP availability depends on etcd health.firewall_use_current_ip or firewall_talos_api_source)
allow access to the Talos API port (default 50000) on your control plane nodes from where you are running talosctl.
Connectivity issues (e.g., i/o timeout) can occur if this port is blocked.Refer to the official Talos documentation on upgrading Kubernetes for detailed steps and best practices.
user_data (e.g. talos_machine_configuration) and image (e.g. version upgrades with packer) will
not be applied to existing nodes, because it would force a recreation of the nodes.network block for control-plane servers. This lets Talos manage the VIP alias IP. A check
warns if a server's network ID or private IP differs from the configuration. The check does not compare alias IPs and
does not stop a plan or apply. Terraform will not apply network ID or private IP changes to control-plane servers.
Review each warning and plan any network migration separately. Terraform continues to manage worker network blocks.enable_ipv6, but it currently has no
effect on the cluster's internal networking configuration provided by this module.enable_kube_span = true might prevent the cluster from reaching a ready state in some configurations.
Further investigation is needed.403 Forbidden user in startup log: This is a known issue related to rate limiting or IP blocking
by registry.k8s.io affecting some Hetzner IP ranges.
See #46
and registry.k8s.io #138.If this module saved you time or helped you run Talos on Hetzner more reliably, consider supporting ongoing maintenance:
Sponsorship is about sustainability and public appreciation, not a paid support contract or SLA. Sponsors can be acknowledged publicly via GitHub Sponsors.
656 followers Β· starred Jan 2026
168 followers Β· starred May 2025
72 followers Β· starred Dec 2024
156 followers Β· starred Apr 2024