diff --git a/infra/main.tf b/infra/main.tf index 03ead0e..760950f 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -14,6 +14,9 @@ locals { bootstrap_script = templatefile("${path.module}/scripts/bootstrap.sh.tftpl", { deploy_user = var.deploy_user }) + + # IDs for every named key in var.ssh_key_names - installed on every server. + ssh_key_ids = [for k in data.hcloud_ssh_key.main : k.id] } module "network" { @@ -30,7 +33,7 @@ module "dev" { server_type = var.dev_server_type image = var.server_image location = var.location - ssh_key_id = data.hcloud_ssh_key.main.id + ssh_key_ids = local.ssh_key_ids network_id = module.network.id private_ip = var.dev_private_ip volume_size = var.dev_volume_size @@ -51,7 +54,7 @@ module "prod" { server_type = var.prod_server_type image = var.server_image location = var.location - ssh_key_id = data.hcloud_ssh_key.main.id + ssh_key_ids = local.ssh_key_ids network_id = module.network.id private_ip = var.prod_private_ip volume_size = var.prod_volume_size @@ -69,7 +72,7 @@ module "vpn" { server_type = var.vpn_server_type image = var.server_image location = var.location - ssh_key_id = data.hcloud_ssh_key.main.id + ssh_key_ids = local.ssh_key_ids allowed_ssh_source_ips = var.allowed_ssh_source_ips bootstrap_script = local.bootstrap_script ssh_private_key_path = var.ssh_private_key_path diff --git a/infra/modules/dev/main.tf b/infra/modules/dev/main.tf index 2aa4cbe..e2b2d25 100644 --- a/infra/modules/dev/main.tf +++ b/infra/modules/dev/main.tf @@ -54,7 +54,7 @@ resource "hcloud_server" "this" { server_type = var.server_type image = var.image location = var.location - ssh_keys = [var.ssh_key_id] + ssh_keys = var.ssh_key_ids firewall_ids = [hcloud_firewall.this.id] network { diff --git a/infra/modules/dev/variables.tf b/infra/modules/dev/variables.tf index 9828bfb..40a9f0c 100644 --- a/infra/modules/dev/variables.tf +++ b/infra/modules/dev/variables.tf @@ -13,9 +13,9 @@ variable "location" { type = string } -variable "ssh_key_id" { - description = "ID of the Hetzner Cloud SSH key to install on the server." - type = string +variable "ssh_key_ids" { + description = "IDs of the Hetzner Cloud SSH keys to install on the server." + type = list(string) } variable "network_id" { @@ -49,7 +49,7 @@ variable "bootstrap_script" { } variable "ssh_private_key_path" { - description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH." type = string } diff --git a/infra/modules/prod/main.tf b/infra/modules/prod/main.tf index f97ae0b..99fbc09 100644 --- a/infra/modules/prod/main.tf +++ b/infra/modules/prod/main.tf @@ -81,7 +81,7 @@ resource "hcloud_server" "this" { server_type = var.server_type image = var.image location = var.location - ssh_keys = [var.ssh_key_id] + ssh_keys = var.ssh_key_ids firewall_ids = [hcloud_firewall.this.id] network { diff --git a/infra/modules/prod/variables.tf b/infra/modules/prod/variables.tf index ea50186..3eb6544 100644 --- a/infra/modules/prod/variables.tf +++ b/infra/modules/prod/variables.tf @@ -13,9 +13,9 @@ variable "location" { type = string } -variable "ssh_key_id" { - description = "ID of the Hetzner Cloud SSH key to install on the server." - type = string +variable "ssh_key_ids" { + description = "IDs of the Hetzner Cloud SSH keys to install on the server." + type = list(string) } variable "network_id" { @@ -49,6 +49,6 @@ variable "bootstrap_script" { } variable "ssh_private_key_path" { - description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH." type = string } diff --git a/infra/modules/vpn/main.tf b/infra/modules/vpn/main.tf index 848eb7c..e1d6ffe 100644 --- a/infra/modules/vpn/main.tf +++ b/infra/modules/vpn/main.tf @@ -36,7 +36,7 @@ resource "hcloud_server" "this" { server_type = var.server_type image = var.image location = var.location - ssh_keys = [var.ssh_key_id] + ssh_keys = var.ssh_key_ids firewall_ids = [hcloud_firewall.this.id] connection { diff --git a/infra/modules/vpn/variables.tf b/infra/modules/vpn/variables.tf index 31f9d31..1976e9a 100644 --- a/infra/modules/vpn/variables.tf +++ b/infra/modules/vpn/variables.tf @@ -13,9 +13,9 @@ variable "location" { type = string } -variable "ssh_key_id" { - description = "ID of the Hetzner Cloud SSH key to install on the server." - type = string +variable "ssh_key_ids" { + description = "IDs of the Hetzner Cloud SSH keys to install on the server." + type = list(string) } variable "allowed_ssh_source_ips" { @@ -29,6 +29,6 @@ variable "bootstrap_script" { } variable "ssh_private_key_path" { - description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH." type = string } diff --git a/infra/ssh.tf b/infra/ssh.tf index a6eea7e..cffda71 100644 --- a/infra/ssh.tf +++ b/infra/ssh.tf @@ -1,3 +1,4 @@ data "hcloud_ssh_key" "main" { - name = var.ssh_key_name + for_each = toset(var.ssh_key_names) + name = each.value } diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index 1338581..8be0e88 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -4,12 +4,13 @@ # The Hetzner API token is NOT set here: export it as HCLOUD_TOKEN in your shell # before running tofu plan/apply. -# Name of an SSH key already uploaded to your Hetzner Cloud project -# (Console > Security > SSH Keys). Required. -ssh_key_name = "your-key-name" +# Names of SSH keys already uploaded to your Hetzner Cloud project +# (Console > Security > SSH Keys). All of them are installed on every server. Required. +ssh_key_names = ["your-key-name", "laptop", "phone"] -# Local path to the private key matching ssh_key_name above. OpenTofu uses this -# once per server to upload and run the post-install bootstrap script +# Local path to the private key matching ONE of ssh_key_names above (doesn't need +# to be all of them - just one you hold). OpenTofu uses this once per server to +# upload and run the post-install bootstrap script # (infra/scripts/bootstrap.sh.tftpl) immediately after creation. Required. ssh_private_key_path = "~/.ssh/id_ed25519" diff --git a/infra/variables.tf b/infra/variables.tf index b962133..7c041c8 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -70,9 +70,9 @@ variable "prod_private_ip" { default = "10.0.1.11" } -variable "ssh_key_name" { - description = "Name of an SSH key already uploaded to your Hetzner Cloud project (Console > Security > SSH Keys)." - type = string +variable "ssh_key_names" { + description = "Names of SSH keys already uploaded to your Hetzner Cloud project (Console > Security > SSH Keys). All are installed on every server." + type = list(string) } variable "allowed_ssh_source_ips" { @@ -82,7 +82,7 @@ variable "allowed_ssh_source_ips" { } variable "ssh_private_key_path" { - description = "Local path to the private key matching var.ssh_key_name. Used once per server by OpenTofu to upload and run the post-install bootstrap script over SSH immediately after creation (see infra/scripts/bootstrap.sh.tftpl)." + description = "Local path to the private key matching one of var.ssh_key_names - only needs to be a key you hold yourself, not all of them. Used once per server by OpenTofu to upload and run the post-install bootstrap script over SSH immediately after creation (see infra/scripts/bootstrap.sh.tftpl)." type = string } diff --git a/readme.md b/readme.md index 758f4a3..eab589f 100644 --- a/readme.md +++ b/readme.md @@ -68,9 +68,9 @@ architecture-beta . ├── infra/ # OpenTofu (Terraform-compatible) config — provisions the 3 servers, network, firewalls, volumes │ ├── main.tf # root module: wires network + dev/prod/vpn modules together -│ ├── variables.tf # shared inputs (sizes, locations, IP ranges, SSH key name) +│ ├── variables.tf # shared inputs (sizes, locations, IP ranges, SSH key names) │ ├── outputs.tf # pass-through outputs from each host module -│ ├── ssh.tf # looks up the SSH key already uploaded to Hetzner Cloud +│ ├── ssh.tf # looks up each SSH key already uploaded to Hetzner Cloud │ ├── versions.tf # provider requirements │ ├── terraform.tfvars.example │ ├── scripts/ @@ -98,7 +98,7 @@ Each of `services/dev`, `services/prod`, `services/vpn` follows the same convent ## Prerequisites - A [Hetzner Cloud](https://console.hetzner.cloud/) project and API token -- An SSH key uploaded to that project (Console → Security → SSH Keys), and the matching private key available locally (OpenTofu uses it once per server to run the post-install bootstrap — see below) +- One or more SSH keys uploaded to that project (Console → Security → SSH Keys) - all are installed on every server - plus the private key matching one of them available locally (OpenTofu uses it once per server to run the post-install bootstrap — see below) - [OpenTofu](https://opentofu.org/docs/intro/install/) `>= 1.6.0` - DNS records for the domains in [Service inventory](#service-inventory) pointed at the relevant server's public IP @@ -110,7 +110,7 @@ Docker + the Compose plugin no longer need installing by hand — the bootstrap cd infra export HCLOUD_TOKEN=your-hetzner-api-token # never commit this cp terraform.tfvars.example terraform.tfvars -$EDITOR terraform.tfvars # set ssh_key_name and ssh_private_key_path at minimum +$EDITOR terraform.tfvars # set ssh_key_names and ssh_private_key_path at minimum tofu init tofu plan @@ -131,7 +131,7 @@ Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vp Immediately after each `hcloud_server` is created, OpenTofu uploads and runs [`infra/scripts/bootstrap.sh.tftpl`](infra/scripts/bootstrap.sh.tftpl) over SSH as `root` (`connection` + `file`/`remote-exec` provisioners in each `modules//main.tf`). It: - installs Docker Engine + the Compose plugin -- creates a non-root sudo user (`var.deploy_user`, default `deploy`) with the same SSH key as root, in the `sudo` and `docker` groups +- creates a non-root sudo user (`var.deploy_user`, default `deploy`) with the same SSH keys as root, in the `sudo` and `docker` groups - hardens `sshd`: password authentication off, root login restricted to key-only (`PermitRootLogin prohibit-password`) - installs and enables `unattended-upgrades` for automatic security patches