Skip to content

Commit 427bc32

Browse files
authored
terraform: add example aws-ec2-instance-windows-server (#54)
1 parent 22ed274 commit 427bc32

12 files changed

Lines changed: 420 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# aws-ec2-instance-windows-server
2+
3+
This example creates the following:
4+
5+
- a VPC and related resources including a NAT Gateway
6+
- a Windows Server EC2 instance running Tailscale in a public subnet
7+
- a Tailnet device key to authenticate the Tailscale device, unless you provide the `tailscale_auth_key` input variable
8+
9+
## Considerations
10+
11+
- This example was verified on Windows Server 2022 and Windows Server 2025.
12+
- 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.
13+
- 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.
14+
- 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.
15+
16+
## To use
17+
18+
Follow the documentation to configure the Terraform providers:
19+
20+
- [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs)
21+
- [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs)
22+
23+
### Deploy
24+
25+
```shell
26+
terraform init
27+
terraform apply -var="windows_admin_password=<a-strong-password>"
28+
```
29+
30+
## To destroy
31+
32+
```shell
33+
terraform destroy
34+
```
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
locals {
2+
name = "example-${basename(path.cwd)}"
3+
4+
aws_tags = {
5+
Name = local.name
6+
}
7+
8+
tailscale_acl_tags = [
9+
"tag:example-infra",
10+
]
11+
12+
# Modify these to use your own VPC
13+
vpc_cidr_block = module.vpc.vpc_cidr_block
14+
vpc_id = module.vpc.vpc_id
15+
subnet_id = module.vpc.public_subnets[0]
16+
security_group_ids = [aws_security_group.tailscale.id]
17+
instance_type = "t3.medium"
18+
19+
# Use the provided auth key if set, otherwise use the one created below.
20+
tailscale_auth_key = coalesce(var.tailscale_auth_key, try(tailscale_tailnet_key.main[0].key, null))
21+
}
22+
23+
# Remove this to use your own VPC.
24+
module "vpc" {
25+
source = "../internal-modules/aws-vpc"
26+
27+
name = local.name
28+
tags = local.aws_tags
29+
}
30+
31+
resource "tailscale_tailnet_key" "main" {
32+
count = var.tailscale_auth_key == null ? 1 : 0
33+
34+
ephemeral = true
35+
preauthorized = true
36+
reusable = true
37+
recreate_if_invalid = "always"
38+
tags = local.tailscale_acl_tags
39+
}
40+
41+
module "tailscale_aws_ec2_windows" {
42+
source = "../internal-modules/aws-ec2-instance-windows-server"
43+
44+
instance_type = local.instance_type
45+
instance_tags = local.aws_tags
46+
47+
subnet_id = local.subnet_id
48+
vpc_security_group_ids = local.security_group_ids
49+
50+
# Variables for Tailscale resources
51+
tailscale_hostname = local.name
52+
tailscale_auth_key = local.tailscale_auth_key
53+
54+
# Variables for the local Windows account used to run the Tailscale scheduled task
55+
windows_admin_password = var.windows_admin_password
56+
57+
depends_on = [
58+
module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available
59+
]
60+
}
61+
62+
resource "aws_security_group" "tailscale" {
63+
vpc_id = local.vpc_id
64+
name = local.name
65+
}
66+
67+
resource "aws_security_group_rule" "tailscale_ingress" {
68+
security_group_id = aws_security_group.tailscale.id
69+
type = "ingress"
70+
from_port = 41641
71+
to_port = 41641
72+
protocol = "udp"
73+
cidr_blocks = ["0.0.0.0/0"]
74+
ipv6_cidr_blocks = ["::/0"]
75+
}
76+
77+
resource "aws_security_group_rule" "egress" {
78+
security_group_id = aws_security_group.tailscale.id
79+
type = "egress"
80+
from_port = 0
81+
to_port = 0
82+
protocol = "-1"
83+
cidr_blocks = ["0.0.0.0/0"]
84+
ipv6_cidr_blocks = ["::/0"]
85+
}
86+
87+
resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" {
88+
security_group_id = aws_security_group.tailscale.id
89+
type = "ingress"
90+
from_port = 0
91+
to_port = 0
92+
protocol = "-1"
93+
cidr_blocks = [local.vpc_cidr_block]
94+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
output "resource_name_prefix" {
2+
value = local.name
3+
}
4+
5+
output "vpc_id" {
6+
value = module.vpc.vpc_id
7+
}
8+
9+
output "vpc_cidr" {
10+
value = module.vpc.vpc_cidr_block
11+
}
12+
13+
output "nat_public_ips" {
14+
value = module.vpc.nat_public_ips
15+
}
16+
17+
output "instance_ids" {
18+
value = module.tailscale_aws_ec2_windows[*].instance_id
19+
}
20+
21+
output "user_data_md5" {
22+
description = "MD5 hash of the VM user_data script - for detecting changes"
23+
value = module.tailscale_aws_ec2_windows.user_data_md5
24+
sensitive = true
25+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
variable "windows_admin_password" {
2+
description = "Password to set for the Windows Administrator account. Required so the Tailscale scheduled task can authenticate. Must not contain a double quote character."
3+
type = string
4+
sensitive = true
5+
}
6+
7+
variable "tailscale_auth_key" {
8+
description = "Existing Tailscale auth key to authenticate the device. If not set, a new ephemeral, reusable auth key is created."
9+
type = string
10+
default = null
11+
sensitive = true
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
terraform {
2+
required_providers {
3+
aws = {
4+
source = "hashicorp/aws"
5+
version = ">= 6.0, < 7.0"
6+
}
7+
tailscale = {
8+
source = "tailscale/tailscale"
9+
version = ">= 0.24"
10+
}
11+
}
12+
13+
required_version = ">= 1.0, < 2.0"
14+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# aws-ec2-instance-windows-server
2+
3+
This module creates the following:
4+
5+
- a Windows Server EC2 instance with Tailscale installed and authenticated via a userdata script
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
locals {
2+
windows_install_script = templatefile(
3+
"${path.module}/scripts/tailscale-windows.ps1.tftpl",
4+
{
5+
tailscale_auth_key = var.tailscale_auth_key,
6+
tailscale_hostname = var.tailscale_hostname,
7+
tailscale_msi_url = var.tailscale_msi_url,
8+
username = var.windows_admin_username,
9+
password = var.windows_admin_password,
10+
}
11+
)
12+
}
13+
14+
data "aws_ami" "windows" {
15+
owners = ["amazon"]
16+
most_recent = true
17+
18+
filter {
19+
name = "name"
20+
# values = ["Windows_Server-2022-English-Full-Base-*"]
21+
values = ["Windows_Server-2025-English-Full-Base-*"]
22+
}
23+
24+
filter {
25+
name = "virtualization-type"
26+
values = ["hvm"]
27+
}
28+
29+
filter {
30+
name = "architecture"
31+
values = ["x86_64"]
32+
}
33+
}
34+
35+
resource "aws_instance" "tailscale_instance" {
36+
ami = data.aws_ami.windows.id
37+
instance_type = var.instance_type
38+
key_name = var.instance_key_name
39+
40+
subnet_id = var.subnet_id
41+
vpc_security_group_ids = var.vpc_security_group_ids
42+
ipv6_address_count = var.ipv6_address_count
43+
44+
iam_instance_profile = var.instance_profile_name
45+
46+
metadata_options {
47+
http_endpoint = var.instance_metadata_options["http_endpoint"]
48+
http_tokens = var.instance_metadata_options["http_tokens"]
49+
}
50+
51+
tags = var.instance_tags
52+
53+
user_data_replace_on_change = var.instance_user_data_replace_on_change
54+
user_data = local.windows_install_script
55+
56+
lifecycle {
57+
ignore_changes = [
58+
ami,
59+
]
60+
}
61+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
output "instance_id" {
2+
value = aws_instance.tailscale_instance.id
3+
}
4+
5+
output "instance_private_ip" {
6+
value = aws_instance.tailscale_instance.private_ip
7+
}
8+
9+
output "user_data_md5" {
10+
description = "MD5 hash of the VM user_data script - for detecting changes"
11+
value = md5(local.windows_install_script)
12+
sensitive = true
13+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<powershell>
2+
#
3+
# Installs Tailscale, authenticates the device, and verifies the connection.
4+
#
5+
Start-Transcript -Path "$env:SystemRoot\Temp\tailscale-user-data.log" -Append
6+
7+
Write-Host "`n#`n# Beginning Tailscale installation...`n#`n"
8+
9+
# Create temp directory if it doesn't exist
10+
$tempPath = "$env:TEMP"
11+
if (-not (Test-Path $tempPath)) {
12+
New-Item -Path $tempPath -ItemType Directory -Force | Out-Null
13+
}
14+
$Installer = "$env:TEMP\tailscale.msi"
15+
16+
# https://tailscale.com/docs/install/windows/msi
17+
$downloadTailscale = @{
18+
Uri = "${tailscale_msi_url}"
19+
OutFile = $Installer
20+
}
21+
$downloadSuccess = $false
22+
for ($loop = 1; $loop -le 10; $loop++) {
23+
Write-Host "Downloading Tailscale attempt: [$loop / 10]"
24+
try {
25+
Invoke-WebRequest @downloadTailscale
26+
$downloadSuccess = $true
27+
break
28+
}
29+
catch {
30+
Write-Host "Download attempt $loop failed: $_"
31+
Start-Sleep -Seconds 2
32+
}
33+
}
34+
if (-not $downloadSuccess) {
35+
Write-Host "Tailscale download failed. Exiting."
36+
exit 1
37+
}
38+
Write-Host "Tailscale download successful."
39+
40+
Write-Host "Installing $Installer"
41+
Start-Process msiexec.exe -ArgumentList "/i `"$Installer`"", "/qn", "/norestart" -Wait
42+
Write-Host "Install complete."
43+
44+
Write-Host "Removing $Installer"
45+
Remove-Item $Installer -Force
46+
47+
# Set the password for the local account below. schtasks needs the real,
48+
# current password for this account to create a task that runs at boot.
49+
Write-Host "Setting the password for local account [${username}]"
50+
net user "${username}" "${password}" /active:yes
51+
52+
# Create a task to authenticate to the tailnet on boot, then run it now so
53+
# the device does not have to wait for a reboot to join the tailnet.
54+
Write-Host "`n#`n# Creating task to authenticate to tailnet on boot`n#`n"
55+
# /create = make a new task
56+
# /tn = task name
57+
# /tr = executable to run
58+
# /sc onstart = run on boot
59+
# /ru /rp = user and password to run the command as
60+
# /V1 /Z = delete the task after it has run
61+
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
62+
schtasks /run /tn "TailscaleUpOnce"
63+
64+
Write-Host "Waiting for Tailscale to authenticate..."
65+
$connected = $false
66+
for ($loop = 1; $loop -le 30; $loop++) {
67+
& "C:\Program Files\Tailscale\tailscale.exe" status --peers=false *> $null
68+
if ($LASTEXITCODE -eq 0) {
69+
$connected = $true
70+
break
71+
}
72+
Start-Sleep -Seconds 2
73+
}
74+
if ($connected) {
75+
Write-Host "`n#`n# Tailscale status: connected`n#`n"
76+
} else {
77+
Write-Host "`n#`n# Tailscale status: NOT connected`n#`n"
78+
}
79+
80+
Write-Host "`n#`n# Complete.`n#`n"
81+
82+
Stop-Transcript
83+
</powershell>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#
2+
# Variables for Tailscale resources
3+
#
4+
variable "tailscale_auth_key" {
5+
description = "Tailscale auth key to authenticate the device"
6+
type = string
7+
}
8+
variable "tailscale_hostname" {
9+
description = "Hostname to assign to the device"
10+
type = string
11+
}
12+
variable "tailscale_msi_url" {
13+
description = "URL to the Tailscale Windows installer (MSI) to download and install"
14+
type = string
15+
default = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"
16+
}
17+
18+
#
19+
# Variables for the local Windows account used to run the Tailscale scheduled task
20+
#
21+
variable "windows_admin_username" {
22+
description = "Local Windows account used to run the Tailscale scheduled task"
23+
type = string
24+
default = "Administrator"
25+
}
26+
variable "windows_admin_password" {
27+
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."
28+
type = string
29+
sensitive = true
30+
}

0 commit comments

Comments
 (0)