diff --git a/.icons/hetzner-cloud.svg b/.icons/hetzner-cloud.svg new file mode 100644 index 000000000..8012f6eee --- /dev/null +++ b/.icons/hetzner-cloud.svg @@ -0,0 +1,4 @@ + + + + diff --git a/registry/coder/templates/hetzner-cloud/README.md b/registry/coder/templates/hetzner-cloud/README.md new file mode 100644 index 000000000..7737d9cae --- /dev/null +++ b/registry/coder/templates/hetzner-cloud/README.md @@ -0,0 +1,82 @@ +--- +name: Hetzner Cloud Linux Workspace +description: Provision a Hetzner Cloud server with private networking and a persistent home volume for Coder workspaces. +tags: [hetzner, terraform, linux, coder] +icon: /icon/hetzner-cloud.svg +--- + +# Hetzner Cloud Linux Workspace + +This template provisions a single Hetzner Cloud server optimised for Coder. It creates a dedicated private network, attaches +a persistent volume for the workspace home directory, and boots the machine with a cloud-init configuration that installs and +starts the Coder agent automatically. + +## Features + +- Choice of popular Ubuntu, Debian, Fedora, and Rocky Linux images +- Selectable CPX and CAX instance sizes, including x86 and ARM options +- Private network and firewall pre-configured for secure access +- Persistent ext4 home volume mounted at `/home/` +- Optional code-server and JetBrains module integrations + +## Requirements + +- Hetzner Cloud project with API access enabled +- Hetzner Cloud API token (`HCLOUD_TOKEN`) with permission to create servers, networks, firewalls, and volumes +- Coder v2.9+ (tested with Terraform >= 1.4) + +## Usage + +1. Export your Hetzner Cloud token before starting `coderd`: + ```bash + export HCLOUD_TOKEN="" + ``` +2. Import this template into your Coder workspace namespace (see [Coder template docs](https://coder.com/docs/templates/overview)). +3. When creating a workspace pick the desired location, server type, and image from the parameters sidebar. +4. Launch the workspace – the agent will come online automatically and the persistent volume will mount to the home directory. + +## Variables + +| Name | Description | Type | Default | Required | +|------------------|---------------------------------------------------|----------|----------------|----------| +| `hcloud_token` | Overrides the HCLOUD_TOKEN environment variable | `string` | `""` | no | + +### Workspace Parameters + +| Parameter | Description | +|-------------------------|------------------------------------------------| +| `Hetzner location` | Target data centre (nbg1, fsn1, hel1, ash, hil) | +| `Server type` | CPX/CAX instance family | +| `Server image` | Linux distribution image | +| `Home volume size` | Persistent volume size in GiB (10 – 1024) | +| `Private network CIDR` | CIDR for the created private network | +| `Subnet CIDR` | CIDR for the workspace subnet | + +## Resources Created + +- `hcloud_network` and `hcloud_network_subnet` for workspace isolation +- `hcloud_firewall` allowing SSH/HTTP/HTTPS ingress and full egress +- `hcloud_volume` formatted as ext4 and attached to the workspace server +- `hcloud_server` with user-data to start the Coder agent +- Optional `code-server` and `jetbrains` Coder modules for IDE support + +## Customisation + +- Adjust the default network ranges in the parameter definitions if they conflict with existing infrastructure. +- Update the `startup_script` in `coder_agent.main` to install language runtimes or tooling specific to your team. +- Add extra firewall rules or attach additional volumes as needed. + +## Troubleshooting + +### Workspace fails to start and reports unreachable agent +- Verify that the HCLOUD_TOKEN exported for `coderd` has `Read & Write` permissions. +- Check the Hetzner Cloud console for the server logs – ensure the agent service is running (`systemctl status coder-agent`). + +### Volume not mounted on first boot +- Hetzner volumes can take a few seconds to attach. Restarting the instance or re-running `systemctl start coder-agent` + after attachment will complete the mount. + +## Contributing + +Improvements and additional server options are welcome! Please read the [contributing guidelines](../../../../CONTRIBUTING.md) +before submitting a pull request. diff --git a/registry/coder/templates/hetzner-cloud/cloud-config.yaml.tftpl b/registry/coder/templates/hetzner-cloud/cloud-config.yaml.tftpl new file mode 100644 index 000000000..d42e35331 --- /dev/null +++ b/registry/coder/templates/hetzner-cloud/cloud-config.yaml.tftpl @@ -0,0 +1,56 @@ +#cloud-config +users: + - name: ${username} + groups: sudo + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + shell: /bin/bash +packages: + - git +fs_setup: + - label: ${home_volume_label} + filesystem: ext4 + device: ${volume_device} + partition: auto + overwrite: false +mounts: + - [ + "/dev/disk/by-label/${home_volume_label}", + "/home/${username}", + ext4, + "defaults,nofail,discard", + "0", + "2", + ] +write_files: + - path: /opt/coder/init + permissions: "0755" + encoding: b64 + content: ${init_script} + - path: /etc/systemd/system/coder-agent.service + permissions: "0644" + content: | + [Unit] + Description=Coder Agent + After=network-online.target + Wants=network-online.target + + [Service] + User=${username} + ExecStart=/opt/coder/init + Environment=CODER_AGENT_TOKEN=${coder_agent_token} + Restart=always + RestartSec=10 + TimeoutStopSec=90 + KillMode=process + + OOMScoreAdjust=-900 + SyslogIdentifier=coder-agent + + [Install] + WantedBy=multi-user.target +runcmd: + - usermod -aG sudo ${username} + - chown ${username}:${username} /home/${username} + - systemctl daemon-reload + - systemctl enable coder-agent + - systemctl start coder-agent diff --git a/registry/coder/templates/hetzner-cloud/main.tf b/registry/coder/templates/hetzner-cloud/main.tf new file mode 100644 index 000000000..c7d9a96e9 --- /dev/null +++ b/registry/coder/templates/hetzner-cloud/main.tf @@ -0,0 +1,407 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + coder = { + source = "coder/coder" + } + hcloud = { + source = "hetznercloud/hcloud" + } + } +} + +provider "coder" {} + +variable "hcloud_token" { + type = string + default = "" + sensitive = true + description = <<-EOF + Hetzner Cloud API token. It is recommended to supply this via the HCLOUD_TOKEN + environment variable when starting coderd instead of setting the variable directly. + EOF +} + +provider "hcloud" { + token = var.hcloud_token != "" ? var.hcloud_token : null +} + +locals { + owner_name = lower(replace(data.coder_workspace_owner.me.name, "[^a-zA-Z0-9-]", "-")) + workspace_name = lower(replace(data.coder_workspace.me.name, "[^a-zA-Z0-9-]", "-")) + server_name = substr("coder-${local.owner_name}-${local.workspace_name}", 0, 63) + username = local.owner_name != "" ? local.owner_name : "coder" + home_volume_label = "coder-home" + network_zones = { + nbg1 = "eu-central" + fsn1 = "eu-central" + hel1 = "eu-central" + ash = "us-east" + hil = "us-west" + } + network_zone = lookup(local.network_zones, data.coder_parameter.location.value, "eu-central") +} + +# Workspace parameters exposed in the Coder UI + +data "coder_parameter" "location" { + name = "hcloud_location" + display_name = "Hetzner location" + description = "Region where the server will be created." + default = "nbg1" + mutable = false + option { + name = "Germany (Nuremberg)" + value = "nbg1" + icon = "/emojis/1f1e9-1f1ea.png" + } + option { + name = "Germany (Falkenstein)" + value = "fsn1" + icon = "/emojis/1f1e9-1f1ea.png" + } + option { + name = "Finland (Helsinki)" + value = "hel1" + icon = "/emojis/1f1eb-1f1ee.png" + } + option { + name = "United States (Ashburn)" + value = "ash" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "United States (Hillsboro)" + value = "hil" + icon = "/emojis/1f1fa-1f1f8.png" + } +} + +data "coder_parameter" "server_type" { + name = "hcloud_server_type" + display_name = "Server type" + description = "Hetzner instance size. Prices are per hour." + default = "cpx21" + mutable = false + option { + name = "CPX11 – 2 vCPU, 2 GB RAM" + value = "cpx11" + } + option { + name = "CPX21 – 3 vCPU, 4 GB RAM" + value = "cpx21" + } + option { + name = "CPX31 – 4 vCPU, 8 GB RAM" + value = "cpx31" + } + option { + name = "CPX41 – 8 vCPU, 16 GB RAM" + value = "cpx41" + } + option { + name = "CAX11 (ARM) – 2 vCPU, 2 GB RAM" + value = "cax11" + } + option { + name = "CAX21 (ARM) – 4 vCPU, 8 GB RAM" + value = "cax21" + } +} + +data "coder_parameter" "image" { + name = "hcloud_image" + display_name = "Server image" + description = "Operating system image for the workspace." + default = "ubuntu-22.04" + mutable = false + option { + name = "Ubuntu 24.04 LTS" + value = "ubuntu-24.04" + icon = "/icon/ubuntu.svg" + } + option { + name = "Ubuntu 22.04 LTS" + value = "ubuntu-22.04" + icon = "/icon/ubuntu.svg" + } + option { + name = "Debian 12" + value = "debian-12" + icon = "/icon/debian.svg" + } + option { + name = "Fedora 40" + value = "fedora-40" + icon = "/icon/fedora.svg" + } + option { + name = "Rocky Linux 9" + value = "rocky-9" + icon = "/icon/rockylinux.svg" + } +} + +data "coder_parameter" "volume_size" { + name = "home_volume_size" + display_name = "Home volume size" + description = "Size of the persistent home volume (GiB)." + type = "number" + default = "50" + mutable = false + validation { + min = 10 + max = 1024 + } +} + +data "coder_parameter" "network_cidr" { + name = "network_cidr" + display_name = "Private network CIDR" + description = "CIDR block for the workspace private network." + default = "10.20.0.0/16" + mutable = false +} + +data "coder_parameter" "subnet_cidr" { + name = "subnet_cidr" + display_name = "Subnet CIDR" + description = "Subnet used for the workspace server. Must be within the private network CIDR." + default = "10.20.1.0/24" + mutable = false +} + +# Coder workspace metadata + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = "amd64" + os = "linux" + + startup_script = <<-EOT + set -e + + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + + # Install basic packages used in most development workflows + if command -v apt >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y build-essential curl git + fi + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email + } + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = "coder stat cpu" + } + + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = "coder stat mem" + } + + metadata { + key = "home" + display_name = "Home Disk" + interval = 600 + timeout = 30 + script = "coder stat disk --path /home/${local.username}" + } + + display_apps { + vscode = true + vscode_insiders = false + web_terminal = true + port_forwarding_helper = true + } +} + +module "code_server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder/code-server/coder" + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 1 +} + +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder/jetbrains/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + agent_name = "main" + folder = "/home/${local.username}" +} + +resource "hcloud_network" "workspace" { + name = "coder-${data.coder_workspace.me.id}-network" + ip_range = data.coder_parameter.network_cidr.value + labels = { + "coder.workspace_id" = data.coder_workspace.me.id + "coder.workspace_name" = data.coder_workspace.me.name + } +} + +resource "hcloud_network_subnet" "workspace" { + network_id = hcloud_network.workspace.id + type = "cloud" + network_zone = local.network_zone + ip_range = data.coder_parameter.subnet_cidr.value +} + +resource "hcloud_firewall" "workspace" { + name = "coder-${data.coder_workspace.me.id}-firewall" + + rule { + direction = "in" + description = "Allow SSH" + protocol = "tcp" + port = "22" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + description = "Allow HTTPS" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + description = "Allow HTTP" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + description = "Allow all TCP egress" + protocol = "tcp" + port = "0-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + description = "Allow all UDP egress" + protocol = "udp" + port = "0-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } +} + +resource "hcloud_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + size = data.coder_parameter.volume_size.value + location = data.coder_parameter.location.value + format = "ext4" + automount = false + labels = { + "coder.workspace_id" = data.coder_workspace.me.id + "coder.workspace_name" = data.coder_workspace.me.name + "coder.owner" = data.coder_workspace_owner.me.name + } +} + +resource "hcloud_server" "workspace" { + count = data.coder_workspace.me.start_count + name = local.server_name + image = data.coder_parameter.image.value + server_type = data.coder_parameter.server_type.value + location = data.coder_parameter.location.value + firewall_ids = [hcloud_firewall.workspace.id] + volume_ids = [hcloud_volume.home_volume.id] + + public_net { + enable_ipv4 = true + enable_ipv6 = true + } + + network { + network_id = hcloud_network.workspace.id + ip = cidrhost(data.coder_parameter.subnet_cidr.value, 10) + } + + user_data = templatefile("${path.module}/cloud-config.yaml.tftpl", { + username = local.username + home_volume_label = local.home_volume_label + volume_device = hcloud_volume.home_volume.linux_device + init_script = base64encode(coder_agent.main.init_script) + coder_agent_token = coder_agent.main.token + }) + + labels = { + "coder.workspace_id" = data.coder_workspace.me.id + "coder.workspace_name" = data.coder_workspace.me.name + "coder.owner" = data.coder_workspace_owner.me.name + } + + depends_on = [ + hcloud_network_subnet.workspace + ] +} + +resource "coder_metadata" "server" { + count = length(hcloud_server.workspace) > 0 ? 1 : 0 + resource_id = hcloud_server.workspace[0].id + + item { + key = "location" + value = data.coder_parameter.location.value + } + + item { + key = "server_type" + value = data.coder_parameter.server_type.value + } + + item { + key = "image" + value = data.coder_parameter.image.value + } +} + +resource "coder_metadata" "volume" { + resource_id = hcloud_volume.home_volume.id + + item { + key = "size" + value = "${hcloud_volume.home_volume.size} GiB" + } +} + +resource "coder_metadata" "network" { + resource_id = hcloud_network.workspace.id + + item { + key = "cidr" + value = data.coder_parameter.network_cidr.value + } + item { + key = "subnet" + value = data.coder_parameter.subnet_cidr.value + } +}