From bed9f682f41185833afb5242200939ee1d19cb1a Mon Sep 17 00:00:00 2001 From: Cameron Stokes Date: Sun, 27 Sep 2026 08:32:33 -0700 Subject: [PATCH 1/3] terraform: add example aws-ec2-instance-windows-server --- .../aws-ec2-instance-windows-server/README.md | 33 +++++++ .../aws-ec2-instance-windows-server/main.tf | 89 +++++++++++++++++++ .../outputs.tf | 25 ++++++ .../variables.tf | 5 ++ .../versions.tf | 14 +++ .../aws-ec2-instance-windows-server/README.md | 5 ++ .../aws-ec2-instance-windows-server/main.tf | 60 +++++++++++++ .../outputs.tf | 13 +++ .../scripts/tailscale-windows.ps1.tftpl | 83 +++++++++++++++++ .../variables-tailscale.tf | 30 +++++++ .../variables.tf | 39 ++++++++ .../versions.tf | 10 +++ 12 files changed, 406 insertions(+) create mode 100644 terraform/aws/aws-ec2-instance-windows-server/README.md create mode 100644 terraform/aws/aws-ec2-instance-windows-server/main.tf create mode 100644 terraform/aws/aws-ec2-instance-windows-server/outputs.tf create mode 100644 terraform/aws/aws-ec2-instance-windows-server/variables.tf create mode 100644 terraform/aws/aws-ec2-instance-windows-server/versions.tf create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/README.md create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/outputs.tf create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/scripts/tailscale-windows.ps1.tftpl create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables-tailscale.tf create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables.tf create mode 100644 terraform/aws/internal-modules/aws-ec2-instance-windows-server/versions.tf diff --git a/terraform/aws/aws-ec2-instance-windows-server/README.md b/terraform/aws/aws-ec2-instance-windows-server/README.md new file mode 100644 index 0000000..ca756cd --- /dev/null +++ b/terraform/aws/aws-ec2-instance-windows-server/README.md @@ -0,0 +1,33 @@ +# aws-ec2-instance-windows-server + +This example creates the following: + +- a VPC and related resources including a NAT Gateway +- a Windows Server EC2 instance running Tailscale in a public subnet +- a Tailnet device key to authenticate the Tailscale device + +## Considerations + +- The userdata script sets the password of the Windows Administrator account to the value of the `windows_admin_password` input variable. AWS does not encrypt userdata. Do not use this method to set a production password. For production use, get the password from a secret store, for example AWS Secrets Manager. +- The userdata script authenticates the device with a scheduled task. This task runs at instance launch. Allow 1-2 minutes for the device to appear in the Tailscale Admin Console. +- Connect to the instance with RDP over Tailscale. Do not connect over the public internet. This example does not open TCP port 3389 to the internet. + +## To use + +Follow the documentation to configure the Terraform providers: + +- [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) +- [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) + +### Deploy + +```shell +terraform init +terraform apply -var="windows_admin_password=" +``` + +## To destroy + +```shell +terraform destroy +``` diff --git a/terraform/aws/aws-ec2-instance-windows-server/main.tf b/terraform/aws/aws-ec2-instance-windows-server/main.tf new file mode 100644 index 0000000..758d2a1 --- /dev/null +++ b/terraform/aws/aws-ec2-instance-windows-server/main.tf @@ -0,0 +1,89 @@ +locals { + name = "example-${basename(path.cwd)}" + + aws_tags = { + Name = local.name + } + + tailscale_acl_tags = [ + "tag:example-infra", + ] + + # Modify these to use your own VPC + vpc_cidr_block = module.vpc.vpc_cidr_block + vpc_id = module.vpc.vpc_id + subnet_id = module.vpc.public_subnets[0] + security_group_ids = [aws_security_group.tailscale.id] + instance_type = "t3.medium" +} + +# Remove this to use your own VPC. +module "vpc" { + source = "../internal-modules/aws-vpc" + + name = local.name + tags = local.aws_tags +} + +resource "tailscale_tailnet_key" "main" { + ephemeral = true + preauthorized = true + reusable = true + recreate_if_invalid = "always" + tags = local.tailscale_acl_tags +} + +module "tailscale_aws_ec2_windows" { + source = "../internal-modules/aws-ec2-instance-windows-server" + + instance_type = local.instance_type + instance_tags = local.aws_tags + + subnet_id = local.subnet_id + vpc_security_group_ids = local.security_group_ids + + # Variables for Tailscale resources + tailscale_hostname = local.name + tailscale_auth_key = tailscale_tailnet_key.main.key + + # Variables for the local Windows account used to run the Tailscale scheduled task + windows_admin_password = var.windows_admin_password + + depends_on = [ + module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available + ] +} + +resource "aws_security_group" "tailscale" { + vpc_id = local.vpc_id + name = local.name +} + +resource "aws_security_group_rule" "tailscale_ingress" { + security_group_id = aws_security_group.tailscale.id + type = "ingress" + from_port = 41641 + to_port = 41641 + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] +} + +resource "aws_security_group_rule" "egress" { + security_group_id = aws_security_group.tailscale.id + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] +} + +resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { + security_group_id = aws_security_group.tailscale.id + type = "ingress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [local.vpc_cidr_block] +} diff --git a/terraform/aws/aws-ec2-instance-windows-server/outputs.tf b/terraform/aws/aws-ec2-instance-windows-server/outputs.tf new file mode 100644 index 0000000..cafdee3 --- /dev/null +++ b/terraform/aws/aws-ec2-instance-windows-server/outputs.tf @@ -0,0 +1,25 @@ +output "resource_name_prefix" { + value = local.name +} + +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "vpc_cidr" { + value = module.vpc.vpc_cidr_block +} + +output "nat_public_ips" { + value = module.vpc.nat_public_ips +} + +output "instance_ids" { + value = module.tailscale_aws_ec2_windows[*].instance_id +} + +output "user_data_md5" { + description = "MD5 hash of the VM user_data script - for detecting changes" + value = module.tailscale_aws_ec2_windows.user_data_md5 + sensitive = true +} diff --git a/terraform/aws/aws-ec2-instance-windows-server/variables.tf b/terraform/aws/aws-ec2-instance-windows-server/variables.tf new file mode 100644 index 0000000..b22d9eb --- /dev/null +++ b/terraform/aws/aws-ec2-instance-windows-server/variables.tf @@ -0,0 +1,5 @@ +variable "windows_admin_password" { + description = "Password to set for the Windows Administrator account. Required so the Tailscale scheduled task can authenticate. Must not contain a double quote character." + type = string + sensitive = true +} diff --git a/terraform/aws/aws-ec2-instance-windows-server/versions.tf b/terraform/aws/aws-ec2-instance-windows-server/versions.tf new file mode 100644 index 0000000..70d492f --- /dev/null +++ b/terraform/aws/aws-ec2-instance-windows-server/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0, < 7.0" + } + tailscale = { + source = "tailscale/tailscale" + version = ">= 0.24" + } + } + + required_version = ">= 1.0, < 2.0" +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/README.md b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/README.md new file mode 100644 index 0000000..9e32135 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/README.md @@ -0,0 +1,5 @@ +# aws-ec2-instance-windows-server + +This module creates the following: + +- a Windows Server EC2 instance with Tailscale installed and authenticated via a userdata script diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf new file mode 100644 index 0000000..2434d45 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf @@ -0,0 +1,60 @@ +locals { + windows_install_script = templatefile( + "${path.module}/scripts/tailscale-windows.ps1.tftpl", + { + tailscale_auth_key = var.tailscale_auth_key, + tailscale_hostname = var.tailscale_hostname, + tailscale_msi_url = var.tailscale_msi_url, + username = var.windows_admin_username, + password = var.windows_admin_password, + } + ) +} + +data "aws_ami" "windows" { + owners = ["amazon"] + most_recent = true + + filter { + name = "name" + values = ["Windows_Server-2022-English-Full-Base-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } +} + +resource "aws_instance" "tailscale_instance" { + ami = data.aws_ami.windows.id + instance_type = var.instance_type + key_name = var.instance_key_name + + subnet_id = var.subnet_id + vpc_security_group_ids = var.vpc_security_group_ids + ipv6_address_count = var.ipv6_address_count + + iam_instance_profile = var.instance_profile_name + + metadata_options { + http_endpoint = var.instance_metadata_options["http_endpoint"] + http_tokens = var.instance_metadata_options["http_tokens"] + } + + tags = var.instance_tags + + user_data_replace_on_change = var.instance_user_data_replace_on_change + user_data = local.windows_install_script + + lifecycle { + ignore_changes = [ + ami, + ] + } +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/outputs.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/outputs.tf new file mode 100644 index 0000000..86b0753 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/outputs.tf @@ -0,0 +1,13 @@ +output "instance_id" { + value = aws_instance.tailscale_instance.id +} + +output "instance_private_ip" { + value = aws_instance.tailscale_instance.private_ip +} + +output "user_data_md5" { + description = "MD5 hash of the VM user_data script - for detecting changes" + value = md5(local.windows_install_script) + sensitive = true +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/scripts/tailscale-windows.ps1.tftpl b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/scripts/tailscale-windows.ps1.tftpl new file mode 100644 index 0000000..4df732b --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/scripts/tailscale-windows.ps1.tftpl @@ -0,0 +1,83 @@ + +# +# Installs Tailscale, authenticates the device, and verifies the connection. +# +Start-Transcript -Path "$env:SystemRoot\Temp\tailscale-user-data.log" -Append + +Write-Host "`n#`n# Beginning Tailscale installation...`n#`n" + +# Create temp directory if it doesn't exist +$tempPath = "$env:TEMP" +if (-not (Test-Path $tempPath)) { + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null +} +$Installer = "$env:TEMP\tailscale.msi" + +# https://tailscale.com/docs/install/windows/msi +$downloadTailscale = @{ + Uri = "${tailscale_msi_url}" + OutFile = $Installer +} +$downloadSuccess = $false +for ($loop = 1; $loop -le 10; $loop++) { + Write-Host "Downloading Tailscale attempt: [$loop / 10]" + try { + Invoke-WebRequest @downloadTailscale + $downloadSuccess = $true + break + } + catch { + Write-Host "Download attempt $loop failed: $_" + Start-Sleep -Seconds 2 + } +} +if (-not $downloadSuccess) { + Write-Host "Tailscale download failed. Exiting." + exit 1 +} +Write-Host "Tailscale download successful." + +Write-Host "Installing $Installer" +Start-Process msiexec.exe -ArgumentList "/i `"$Installer`"", "/qn", "/norestart" -Wait +Write-Host "Install complete." + +Write-Host "Removing $Installer" +Remove-Item $Installer -Force + +# Set the password for the local account below. schtasks needs the real, +# current password for this account to create a task that runs at boot. +Write-Host "Setting the password for local account [${username}]" +net user "${username}" "${password}" /active:yes + +# Create a task to authenticate to the tailnet on boot, then run it now so +# the device does not have to wait for a reboot to join the tailnet. +Write-Host "`n#`n# Creating task to authenticate to tailnet on boot`n#`n" +# /create = make a new task +# /tn = task name +# /tr = executable to run +# /sc onstart = run on boot +# /ru /rp = user and password to run the command as +# /V1 /Z = delete the task after it has run +schtasks /create /tn "TailscaleUpOnce" /tr "'C:\Program Files\Tailscale\tailscale.exe' up --unattended --hostname '${tailscale_hostname}' --auth-key '${tailscale_auth_key}'" /sc onstart /ru "${username}" /rp "${password}" /V1 /Z +schtasks /run /tn "TailscaleUpOnce" + +Write-Host "Waiting for Tailscale to authenticate..." +$connected = $false +for ($loop = 1; $loop -le 30; $loop++) { + & "C:\Program Files\Tailscale\tailscale.exe" status --peers=false *> $null + if ($LASTEXITCODE -eq 0) { + $connected = $true + break + } + Start-Sleep -Seconds 2 +} +if ($connected) { + Write-Host "`n#`n# Tailscale status: connected`n#`n" +} else { + Write-Host "`n#`n# Tailscale status: NOT connected`n#`n" +} + +Write-Host "`n#`n# Complete.`n#`n" + +Stop-Transcript + diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables-tailscale.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables-tailscale.tf new file mode 100644 index 0000000..87dfd51 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables-tailscale.tf @@ -0,0 +1,30 @@ +# +# Variables for Tailscale resources +# +variable "tailscale_auth_key" { + description = "Tailscale auth key to authenticate the device" + type = string +} +variable "tailscale_hostname" { + description = "Hostname to assign to the device" + type = string +} +variable "tailscale_msi_url" { + description = "URL to the Tailscale Windows installer (MSI) to download and install" + type = string + default = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi" +} + +# +# Variables for the local Windows account used to run the Tailscale scheduled task +# +variable "windows_admin_username" { + description = "Local Windows account used to run the Tailscale scheduled task" + type = string + default = "Administrator" +} +variable "windows_admin_password" { + description = "Password to set for `windows_admin_username`. Required so the scheduled task can authenticate as this account. Must not contain a double quote character." + type = string + sensitive = true +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables.tf new file mode 100644 index 0000000..0c54e90 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/variables.tf @@ -0,0 +1,39 @@ +# +# Variables for instance resources +# +variable "subnet_id" { + type = string +} +variable "ipv6_address_count" { + type = number + default = null +} +variable "vpc_security_group_ids" { + type = set(string) +} +variable "instance_type" { + type = string +} +variable "instance_tags" { + type = map(string) +} +variable "instance_user_data_replace_on_change" { + type = bool + default = true +} +variable "instance_key_name" { + type = string + default = "" +} +variable "instance_profile_name" { + type = string + default = null +} +variable "instance_metadata_options" { + type = map(string) + # IMDSv2 - not required, but recommended + default = { + http_endpoint = "enabled" + http_tokens = "required" + } +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/versions.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/versions.tf new file mode 100644 index 0000000..310c3f7 --- /dev/null +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0, < 7.0" + } + } + + required_version = ">= 1.0, < 2.0" +} From 8e6821b3eed129a0f6ded146357bf8d7b01ef199 Mon Sep 17 00:00:00 2001 From: Cameron Stokes Date: Sun, 27 Sep 2026 08:41:43 -0700 Subject: [PATCH 2/3] update to Windows 2025 --- terraform/aws/aws-ec2-instance-windows-server/README.md | 1 + .../internal-modules/aws-ec2-instance-windows-server/main.tf | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/terraform/aws/aws-ec2-instance-windows-server/README.md b/terraform/aws/aws-ec2-instance-windows-server/README.md index ca756cd..8169609 100644 --- a/terraform/aws/aws-ec2-instance-windows-server/README.md +++ b/terraform/aws/aws-ec2-instance-windows-server/README.md @@ -8,6 +8,7 @@ This example creates the following: ## Considerations +- This example was verified on Windows Server 2022 and Windows Server 2025. - The userdata script sets the password of the Windows Administrator account to the value of the `windows_admin_password` input variable. AWS does not encrypt userdata. Do not use this method to set a production password. For production use, get the password from a secret store, for example AWS Secrets Manager. - The userdata script authenticates the device with a scheduled task. This task runs at instance launch. Allow 1-2 minutes for the device to appear in the Tailscale Admin Console. - Connect to the instance with RDP over Tailscale. Do not connect over the public internet. This example does not open TCP port 3389 to the internet. diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf index 2434d45..129b087 100644 --- a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf @@ -17,7 +17,8 @@ data "aws_ami" "windows" { filter { name = "name" - values = ["Windows_Server-2022-English-Full-Base-*"] + # values = ["Windows_Server-2022-English-Full-Base-*"] + values = ["Windows_Server-2025-English-Full-Base-*"] } filter { From 9b6146aa16ce24d8b522278ea952273f9bd1a301 Mon Sep 17 00:00:00 2001 From: Cameron Stokes Date: Sun, 27 Sep 2026 08:50:19 -0700 Subject: [PATCH 3/3] take optional tailscale_auth_key --- terraform/aws/aws-ec2-instance-windows-server/README.md | 2 +- terraform/aws/aws-ec2-instance-windows-server/main.tf | 7 ++++++- terraform/aws/aws-ec2-instance-windows-server/variables.tf | 7 +++++++ .../aws-ec2-instance-windows-server/main.tf | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/terraform/aws/aws-ec2-instance-windows-server/README.md b/terraform/aws/aws-ec2-instance-windows-server/README.md index 8169609..a1efdff 100644 --- a/terraform/aws/aws-ec2-instance-windows-server/README.md +++ b/terraform/aws/aws-ec2-instance-windows-server/README.md @@ -4,7 +4,7 @@ This example creates the following: - a VPC and related resources including a NAT Gateway - a Windows Server EC2 instance running Tailscale in a public subnet -- a Tailnet device key to authenticate the Tailscale device +- a Tailnet device key to authenticate the Tailscale device, unless you provide the `tailscale_auth_key` input variable ## Considerations diff --git a/terraform/aws/aws-ec2-instance-windows-server/main.tf b/terraform/aws/aws-ec2-instance-windows-server/main.tf index 758d2a1..5bc7892 100644 --- a/terraform/aws/aws-ec2-instance-windows-server/main.tf +++ b/terraform/aws/aws-ec2-instance-windows-server/main.tf @@ -15,6 +15,9 @@ locals { subnet_id = module.vpc.public_subnets[0] security_group_ids = [aws_security_group.tailscale.id] instance_type = "t3.medium" + + # Use the provided auth key if set, otherwise use the one created below. + tailscale_auth_key = coalesce(var.tailscale_auth_key, try(tailscale_tailnet_key.main[0].key, null)) } # Remove this to use your own VPC. @@ -26,6 +29,8 @@ module "vpc" { } resource "tailscale_tailnet_key" "main" { + count = var.tailscale_auth_key == null ? 1 : 0 + ephemeral = true preauthorized = true reusable = true @@ -44,7 +49,7 @@ module "tailscale_aws_ec2_windows" { # Variables for Tailscale resources tailscale_hostname = local.name - tailscale_auth_key = tailscale_tailnet_key.main.key + tailscale_auth_key = local.tailscale_auth_key # Variables for the local Windows account used to run the Tailscale scheduled task windows_admin_password = var.windows_admin_password diff --git a/terraform/aws/aws-ec2-instance-windows-server/variables.tf b/terraform/aws/aws-ec2-instance-windows-server/variables.tf index b22d9eb..ebd72c3 100644 --- a/terraform/aws/aws-ec2-instance-windows-server/variables.tf +++ b/terraform/aws/aws-ec2-instance-windows-server/variables.tf @@ -3,3 +3,10 @@ variable "windows_admin_password" { type = string sensitive = true } + +variable "tailscale_auth_key" { + description = "Existing Tailscale auth key to authenticate the device. If not set, a new ephemeral, reusable auth key is created." + type = string + default = null + sensitive = true +} diff --git a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf index 129b087..303e4d1 100644 --- a/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf +++ b/terraform/aws/internal-modules/aws-ec2-instance-windows-server/main.tf @@ -16,7 +16,7 @@ data "aws_ami" "windows" { most_recent = true filter { - name = "name" + name = "name" # values = ["Windows_Server-2022-English-Full-Base-*"] values = ["Windows_Server-2025-English-Full-Base-*"] }