Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions terraform/aws/aws-ec2-instance-windows-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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, unless you provide the `tailscale_auth_key` input variable

## 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.

## 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=<a-strong-password>"
```

## To destroy

```shell
terraform destroy
```
94 changes: 94 additions & 0 deletions terraform/aws/aws-ec2-instance-windows-server/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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"

# 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.
module "vpc" {
source = "../internal-modules/aws-vpc"

name = local.name
tags = local.aws_tags
}

resource "tailscale_tailnet_key" "main" {
count = var.tailscale_auth_key == null ? 1 : 0

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 = local.tailscale_auth_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]
}
25 changes: 25 additions & 0 deletions terraform/aws/aws-ec2-instance-windows-server/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions terraform/aws/aws-ec2-instance-windows-server/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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
}

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
}
14 changes: 14 additions & 0 deletions terraform/aws/aws-ec2-instance-windows-server/versions.tf
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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-*"]
values = ["Windows_Server-2025-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,
]
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<powershell>
#
# 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
</powershell>
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading