VDI (Virtual Desktop Infrastructure) Module¶
ℹ️ Prerequisites: You need a Windows Server AMI. The examples use Packer-built AMIs from this repo's Packer templates (
lightweight/andue-gamedev/), but any Windows Server 2019/2022/2025 AMI works. See Amazon DCV Documentation for complete setup guidance.
Features¶
- Amazon DCV Integration - High-performance streaming protocol optimized for graphics workloads
- Dual Connectivity - Public internet or private VPN access with custom DNS support
- Game Development Ready - GPU instances, high-performance storage, UE-optimized AMIs
- Intelligent Drive Management - Automatic Windows drive letter assignment and volume lifecycle
- Complete VDI Infrastructure - EC2 workstations, security, IAM, and user management
- Security by Default - Least privilege IAM, encrypted storage, restricted network access, termination protection
- Flexible Authentication - EC2 key pairs (emergency) and Secrets Manager (production)
- Runtime Software Installation - Automated package installation via SSM and Chocolatey
Connectivity Patterns¶
Public Connectivity¶
When: Workstations in public subnets with Internet Gateway routes Access: Direct internet with IP restrictions
workstations = {
"vdi-001" = {
preset_key = "my-preset"
assigned_user = "naruto-uzumaki"
subnet_id = aws_subnet.public_subnet.id
allowed_cidr_blocks = ["198.51.100.1/32"] # Replace with user's public IP
}
}
Private Connectivity¶
When: Workstations in private subnets with NAT Gateway routes
Access: Via VPN, Direct Connect, or Site-to-Site VPN
DNS Requirement: AWS Client VPN connection required to resolve private DNS names (username.vdi.internal)
module "vdi" {
create_client_vpn = true # Creates Client VPN infrastructure
users = {
"naruto-uzumaki" = {
given_name = "Naruto"
family_name = "Uzumaki"
email = "naruto@example.com"
type = "administrator"
use_client_vpn = true # Gets VPN access + certificates
}
}
workstations = {
"vdi-001" = {
preset_key = "my-preset"
assigned_user = "naruto-uzumaki"
subnet_id = aws_subnet.private_subnet.id
allowed_cidr_blocks = ["10.0.0.0/16"] # VPC CIDR only
}
}
}
Architecture¶
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Remote User │ │ VPN Client │ │ VDI Workstation│
│ Web Browser │───▶│ (Private Mode) │───▶│ EC2 Instance │
│ │ │ .ovpn file │ │ Windows + DCV │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ (Public Mode) │ │
└───────────────────────┼───────────────────────┘
│
┌─────────────▼─────────────┐
│ AWS VPC │
│ Security Groups, IAM, │
│ SSM, S3, Secrets Mgr │
└───────────────────────────┘
Account Creation Pattern¶
| Account Type | Created When | Password Storage | Scope | Use Case |
|---|---|---|---|---|
| 🆘 Administrator (built-in) | Windows boot | EC2 Key Pair | All workstations | Emergency break-glass only |
🛡️ Fleet Admin (fleet_administrator) |
SSM (if defined) | Secrets Manager | All workstations | Fleet management |
🔧 Local Admin (administrator) |
SSM (if defined) | Secrets Manager | Assigned workstation | Local administration |
💻 Standard User (user) |
SSM (if defined) | Secrets Manager | Assigned workstation | Daily usage |
Key Point: Only the built-in Administrator exists automatically. All other accounts must be explicitly defined in the users variable.
Prerequisites¶
-
AWS Account Setup
-
AWS CLI configured with deployment permissions
- VPC with public and private subnets
-
Network Planning
- Public connectivity: User public IP addresses for security group access
- Private connectivity: VPN setup and VPC CIDR planning
Cost Estimates¶
⚠️ Cost Warning: These examples deploy expensive GPU instances (~$1,430/month per workstation). Review costs before deployment.
Example Configuration Costs (per workstation/month):
- g4dn.4xlarge instance: ~$1,200/month
- EBS storage (300GB root + 2TB projects with 3000 IOPS): ~$230/month
- Total per workstation: ~$1,430/month
3-workstation example total: ~$4,290/month
Cost optimization options:
- Reduce volume sizes for development/testing
- Use smaller instance types (g4dn.xlarge, g4dn.2xlarge)
- Leverage Spot instances for non-production workloads
- Stop instances manually via AWS Console/CLI when not in use (EBS storage costs continue)
For accurate pricing: Use the AWS Pricing Calculator with your specific requirements and region.
Examples¶
For a quickstart, please review the examples. They provide complete Terraform configuration with VPC setup, security groups, and detailed connection instructions.
Available Examples:
- Public Connectivity - Direct internet access with IP restrictions
- Private Connectivity - AWS Client VPN with internal DNS
Quick Start¶
1. Clone Repository¶
git clone https://github.com/aws-games/cloud-game-development-toolkit.git
cd cloud-game-development-toolkit
2. Choose AMI¶
- Option A: Build a toolkit AMI (e.g., UE GameDev for game development)
- Option B: Use any existing Windows Server AMI
Example - To build the UE GameDev AMI:
cd assets/packer/virtual-workstations/ue-gamedev/
packer build windows-server-2025-ue-gamedev.pkr.hcl
3. Deploy Example¶
cd modules/vdi/examples/public-connectivity/ # or private-connectivity/
terraform init
terraform plan
terraform apply
4. Get Connection Info¶
terraform output connection_info
5. Connect (Private Only)¶
AWS Client VPN Setup (Private Connectivity)¶
Why AWS Client VPN is Required:
For private connectivity, AWS Client VPN is essential because:
- Custom DNS Resolution: Private workstation URLs like
https://username.vdi.internal:8443only resolve when connected to the VPN - Network Access: Private subnets are not accessible from the internet - VPN provides secure tunnel access
- Security: Eliminates need to expose workstations to public internet
Setup Process:
- Deploy with VPN enabled: Set
create_client_vpn = truein your Terraform configuration - Download VPN configuration: The module automatically generates
.ovpnfiles in S3 - Install VPN client: Download AWS VPN Client (recommended)
- Import configuration: Use the
.ovpnfile generated for your user
Using the Generated .ovpn File:
# Download your VPN configuration
aws s3 cp s3://cgd-vdi-vpn-configs-XXXXXXXX/your-username/your-username.ovpn ~/Downloads/
# Import into AWS VPN Client or OpenVPN
# AWS VPN Client: File → Manage Profiles → Add Profile
# OpenVPN: Import the .ovpn file directly
For detailed setup instructions: See AWS Client VPN User Guide
Connection Flow:
- Connect to AWS Client VPN using your
.ovpnfile - Access workstation via private DNS:
https://username.vdi.internal:8443 - Login with credentials from Secrets Manager
Connection Guide¶
⚠️ CRITICAL: Wait for Windows Boot¶
After terraform apply completes, wait 5-10 minutes for Windows initialization before attempting login.
During boot, you'll see:
- "Wrong username or password" errors (expected)
- DCV connection failures (expected)
- Certificate warnings (expected)
Check boot status:
aws ec2 get-console-output --instance-id $(terraform output -json connection_info | jq -r '."vdi-001".instance_id') --latest
Ready when you see:
EC2Launch: EC2 Launch has completed- User creation script completion
- DCV service startup messages
Get Credentials¶
# Get connection info
terraform output connection_info
# Get user password from Secrets Manager
aws secretsmanager get-secret-value \
--secret-id "cgd/vdi-001/users/your-username" \
--query SecretString --output text | jq .
Connect via DCV¶
- Download DCV Client (recommended) or use web browser
- Connect to VPN (private connectivity only)
- Required for private DNS: To access workstations via
https://username.vdi.internal:8443, you must be connected to AWS Client VPN - Private DNS resolution: Custom DNS names only resolve when connected to the VPN
- Open DCV:
- Public:
https://workstation-public-ip:8443 - Private (VPN required):
https://username.vdi.internal:8443orhttps://workstation-private-ip:8443 - Accept certificate warning (self-signed certificates)
- Login with credentials from Secrets Manager
Emergency Access¶
# Get Administrator password
terraform output -json private_keys | jq -r '."vdi-001"' > temp_key.pem
chmod 600 temp_key.pem
aws ec2 get-password-data \
--instance-id $(terraform output -json connection_info | jq -r '."vdi-001".instance_id') \
--priv-launch-key temp_key.pem --query 'PasswordData' --output text
rm temp_key.pem
Password Management¶
Password Details¶
- Auto-generated: 16-character secure passwords (letters + numbers + special characters)
- Initial storage: AWS Secrets Manager (source of truth for first login only)
- User changes: Users can change passwords in Windows - Secrets Manager will not update without additional configuration/custom logic (out of scope for this module)
- Lifecycle: Users can manage passwords in Windows or continue using Secrets Manager passwords
Automatic Script Re-execution¶
The module automatically re-runs configuration scripts when you modify infrastructure. Changes to volumes, users, or software packages trigger the appropriate scripts to run via AWS SSM - no manual intervention required.
Scripts only execute when infrastructure actually changes, providing clean Terraform plans and predictable behavior.
Manual Alternative: For immediate results or troubleshooting, you can RDP to the instance as Administrator and run operations manually.
Volume Configuration¶
EBS Volume Management¶
Volume changes do NOT trigger instance replacement. Instances continue running during volume operations.
Required Root Volume¶
volumes = {
Root = { # ← MUST be exactly "Root" (case-sensitive)
capacity = 256 # ← Root volume automatically gets C: drive
type = "gp3"
}
# Add additional drives as needed (auto-assigned D:, E:, F:, etc.)
# Projects = { capacity = 1000, type = "gp3" }
# Cache = { capacity = 500, type = "gp3" }
}
Drive Letter Assignment¶
Automatic Assignment: The module uses Windows auto-assignment for all drive letters:
- Root Volume → C: drive (Windows boot requirement)
- EBS Volumes → Auto-assigned (typically D:, E:, F:, etc.)
- Instance Store → Auto-assigned (typically next available letter)
G4dn Instance Store Sizes:
g4dn.xlarge: 125GB NVMe SSD (auto-assigned)g4dn.2xlarge: 225GB NVMe SSD (auto-assigned)g4dn.4xlarge: 225GB NVMe SSD (auto-assigned)g4dn.8xlarge: 900GB NVMe SSD (auto-assigned)
Benefits: Simple configuration with no drive letter conflicts, Windows native behavior, user customizable via Disk Management, and cost efficiency by utilizing included instance store.
Volume Change Lifecycle¶
| Change Type | Automatic Handling | Data Safety | User Action Required |
|---|---|---|---|
| Add Volume | ✅ Fully automatic | ✅ Safe | Wait 5-10 minutes after apply |
| Increase Size | ✅ Fully automatic | ✅ Safe | Wait for AWS optimization + SSM (5-15 min) |
| Reduce Size | ❌ BLOCKED BY AWS | ⚠️ Not Supported | See Volume Size Reduction |
| Remove Volume | ✅ Immediate and reliable | ❌ Volume data lost | None (drive letters cleaned up) |
| Change Volume Type | ✅ Auto-applied | ✅ Safe | Wait for optimization (5-15 min typical, up to 6 hrs) |
| Rename Volume | ✅ Terraform only | ✅ Safe | None |
Volume Limitations¶
Volume Size Reduction: Not supported by AWS - EBS volumes cannot be reduced in size.
Volume Modification Rate Limit: AWS enforces a 6-hour wait between volume modifications. This is a hard platform limitation that cannot be overridden.
Advanced Configuration¶
On-Demand Capacity Reservations (ODCR)¶
Use existing capacity reservations if available. See AWS ODCR Documentation for details.
# Module-level default (applies to all workstations)
module "vdi" {
capacity_reservation_preference = "open" # Use ODCR if available
workstations = {
"ws1" = { subnet_id = "subnet-123" } # Inherits "open"
"ws2" = { subnet_id = "subnet-456" } # Inherits "open"
}
}
# Per-workstation control
workstations = {
"prod-ws" = {
capacity_reservation_preference = "open" # Use ODCR
subnet_id = "subnet-123"
}
"dev-ws" = {
# capacity_reservation_preference = "none" # Optional - omit if you don't use ODCR
subnet_id = "subnet-456"
}
}
Software Installation¶
Available packages: Any valid Chocolatey package. Common examples: git, vscode, notepadplusplus, 7zip
presets = {
"ue-dev" = {
instance_type = "g4dn.2xlarge"
software_packages = ["git", "vscode", "notepadplusplus"]
}
}
Troubleshooting¶
Common Issues¶
Instance Launch Failures¶
- Verify AMI exists:
aws ec2 describe-images --owners self --filters "Name=name,Values=*windows-server-2025*" - Check AMI is in correct region
- Ensure Packer build completed successfully
Drive Letter Issues¶
- Check drive assignment:
Get-Disk | Format-Table Number, Size, BusType - Volume scripts re-run automatically when volume configuration changes
Connection Timeouts¶
- Check security group allows your IP:
curl https://checkip.amazonaws.com/ - Verify instance is running:
aws ec2 describe-instances - Test port connectivity:
telnet <instance-ip> 8443
Password Retrieval Issues¶
- Wait 5-10 minutes after instance launch for password generation
- Check Secrets Manager if user passwords not available
- Use S3 backup key if Terraform output fails
DCV "Connecting" Spinner¶
- Connect via SSM:
aws ssm start-session --target <instance-id> - Check DCV sessions:
dcv list-sessions - Restart DCV service:
Restart-Service dcvserver
VPN Connection Issues¶
- Check VPN endpoint DNS resolves:
nslookup [endpoint].prod.clientvpn.us-east-1.amazonaws.com - Wait 5-15 minutes for AWS to activate endpoint
- Check for CIDR conflicts with local network
- Disconnect from other VPNs
User Accounts Not Created¶
- Check SSM command status:
aws ssm list-command-invocations --instance-id <id> - Check user creation status:
aws ssm get-parameter --name "/{project}/{workstation}/users/{username}/status_user_creation" - Scripts re-run automatically when user configuration changes
Volume Initialization Issues¶
- Check volume status:
aws ssm get-parameter --name "/{project}/{workstation}/volume_status" - Check volume messages:
aws ssm get-parameter --name "/{project}/{workstation}/volume_message" - Scripts re-run automatically when volume configuration changes
Volume Resize Issues¶
- Check disk sizes vs partition sizes:
Get-Disk | Format-Table Number, Size, BusType - Check partition sizes:
Get-Partition | Format-Table DiskNumber, DriveLetter, Size - Manual partition extension:
Resize-Partition -DriveLetter F -Size (Get-PartitionSupportedSize -DriveLetter F).SizeMax
Manual Volume Initialization (if SSM script failed)¶
# Initialize any RAW disks
Get-Disk | Where-Object { $_.PartitionStyle -eq 'RAW' } |
Initialize-Disk -PartitionStyle MBR -PassThru |
New-Partition -AssignDriveLetter -UseMaximumSize |
Format-Volume -FileSystem NTFS -Confirm:$false
Software Installation Problems¶
- Check software status:
aws ssm get-parameter --name "/{project}/{workstation}/software_status" - Check failed packages:
aws ssm get-parameter --name "/{project}/{workstation}/software_message" - Scripts re-run automatically when software configuration changes
Debug Commands¶
# Basic connectivity
curl https://checkip.amazonaws.com/
telnet <instance-ip> 8443
# SSM access (no network needed)
aws ssm start-session --target <instance-id>
# VPN testing
ping naruto-uzumaki.vdi.internal
nslookup naruto-uzumaki.vdi.internal
# Volume troubleshooting
INSTANCE_ID=$(terraform output -json connection_info | jq -r '."vdi-001".instance_id')
aws ssm list-command-invocations --instance-id $INSTANCE_ID --filters Key=DocumentName,Values=cgd-dev-initialize-volumes
aws ssm get-command-invocation --command-id <COMMAND_ID> --instance-id $INSTANCE_ID
Password Retrieval¶
# Administrator password
terraform output -json private_keys | jq -r '."vdi-001"' > temp_key.pem
aws ec2 get-password-data --instance-id <id> --priv-launch-key temp_key.pem
# User passwords
aws secretsmanager get-secret-value --secret-id "cgd/vdi-001/users/naruto-uzumaki"
Contributing¶
See the Contributing Guidelines for information on how to contribute to this project.
License¶
This project is licensed under the MIT-0 License. See the LICENSE file for details.
Requirements¶
| Name | Version |
|---|---|
| terraform | >= 1.13 |
| aws | ~> 6.0 |
| awscc | ~> 1.0 |
| http | ~> 3.0 |
| null | ~> 3.0 |
| random | ~> 3.0 |
| time | ~> 0.9 |
| tls | ~> 4.0 |
Providers¶
| Name | Version |
|---|---|
| aws | ~> 6.0 |
| awscc | ~> 1.0 |
| random | ~> 3.0 |
| time | ~> 0.9 |
| tls | ~> 4.0 |
Modules¶
No modules.
Resources¶
Inputs¶
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| region | AWS region for deployment | string |
n/a | yes |
| vpc_id | VPC ID where VDI instances will be deployed | string |
n/a | yes |
| capacity_reservation_preference | Capacity reservation preference for EC2 instances | string |
null |
no |
| client_vpn_config | Client VPN configuration for private connectivity | object({ |
{} |
no |
| create_client_vpn | Create AWS Client VPN endpoint infrastructure (VPN endpoint, certificates, S3 bucket for configs) | bool |
false |
no |
| create_default_security_groups | Create default security groups for VDI workstations | bool |
true |
no |
| debug | Enable debug mode. When true, disables termination protection for CI/CD environments. When false, enables termination protection for production environments. | bool |
false |
no |
| ebs_kms_key_id | KMS key ID for EBS encryption (if encryption enabled) | string |
null |
no |
| enable_centralized_logging | Enable centralized logging with CloudWatch log groups following CGD Toolkit patterns | bool |
false |
no |
| environment | Environment name (dev, staging, prod, etc.) | string |
"dev" |
no |
| log_retention_days | CloudWatch log retention period in days | number |
30 |
no |
| presets | Configuration blueprints defining instance types and named volumes with Windows drive mapping. KEY BECOMES PRESET NAME: The map key (e.g., "ue-developer") becomes the preset name referenced by workstations. Presets provide reusable configurations that can be referenced by multiple workstations via preset_key. Example: presets = { "ue-developer" = { # ← This key becomes the preset name instance_type = "g4dn.2xlarge" gpu_enabled = true volumes = { Root = { capacity = 256, type = "gp3" } # Root volume automatically gets C: Projects = { capacity = 1024, type = "gp3", windows_drive = "Z:" } # Specify drive letter Cache = { capacity = 500, type = "gp3" } # Auto-assigned high-alphabet letter (Y:, X:, etc.) } } "basic-workstation" = { # ← Another preset name instance_type = "g4dn.xlarge" gpu_enabled = true volumes = { Root = { capacity = 200, type = "gp3" } # Root volume automatically gets C: UserData = { capacity = 500, type = "gp3" } # Auto-assigned high-alphabet letter } } } # Referenced by workstations: workstations = { "alice-ws" = { preset_key = "ue-developer" # ← References preset by key } } Valid volume types: "gp2", "gp3", "io1", "io2" Drive letters are auto-assigned by Windows (typically C: for root, D:, E:, F:, etc. for additional volumes). additional_policy_arns: List of additional IAM policy ARNs to attach to the VDI instance role. Example: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", "arn:aws:iam::123456789012:policy/MyCustomPolicy"] |
map(object({ |
{} |
no |
| project_prefix | Prefix for resource names | string |
"cgd" |
no |
| tags | Tags to apply to resources. | map(any) |
{ |
no |
| users | Local Windows user accounts with Windows group types and network connectivity (managed via Secrets Manager) KEY BECOMES WINDOWS USERNAME: The map key (e.g., "john-doe") becomes the actual Windows username created on VDI instances. type options (Windows groups): - "fleet_administrator": User added to Windows Administrators group, created on ALL workstations (fleet management) - "administrator": User added to Windows Administrators group, created only on assigned workstation - "user": User added to Windows Users group, created only on assigned workstation use_client_vpn options (VPN access): - false: User accesses VDI via public internet or external VPN (default) - true: User accesses VDI via module's Client VPN (generates VPN config) Example: users = { "vdiadmin" = { # ← This key becomes Windows username "vdiadmin" given_name = "VDI" family_name = "Administrator" email = "admin@example.com" type = "fleet_administrator" # Windows Administrators group on ALL workstations use_client_vpn = false # Accesses via public internet/external VPN } "alice" = { # ← Public connectivity user given_name = "Alice" family_name = "Smith" email = "alice@example.com" type = "user" # Windows Users group use_client_vpn = false # Accesses via public internet (allowed_cidr_blocks) } "bob" = { # ← Private connectivity user given_name = "Bob" family_name = "Johnson" email = "bob@example.com" type = "user" # Windows Users group use_client_vpn = true # Accesses via module's Client VPN } } # User assignment is now direct: # assigned_user = "naruto-uzumaki" # References users{} key directly in workstation |
map(object({ |
{} |
no |
| workstations | Physical infrastructure instances with template references and placement configuration. KEY BECOMES WORKSTATION NAME: The map key (e.g., "alice-workstation") becomes the workstation identifier used throughout the module. Workstations inherit configuration from templates via preset_key reference. Example: workstations = { # Public connectivity - user accesses via internet "alice-workstation" = { preset_key = "ue-developer" subnet_id = "subnet-public-123" # Public subnet security_groups = ["sg-vdi-public"] assigned_user = "alice" allowed_cidr_blocks = ["203.0.113.1/32"] # Alice's home IP } # Private connectivity - user accesses via VPN "bob-workstation" = { preset_key = "basic-workstation" subnet_id = "subnet-private-456" # Private subnet security_groups = ["sg-vdi-private"] assigned_user = "bob" # No allowed_cidr_blocks - accessed via Client VPN } # Additional volumes at workstation level "dev-workstation" = { preset_key = "basic-workstation" subnet_id = "subnet-private-789" security_groups = ["sg-vdi-private"] volumes = { ExtraStorage = { capacity = 2000, type = "gp3", windows_drive = "Y:" } } } } # User assignment is now direct: # assigned_user = "alice" # References users{} key directly in workstation Drive letters are auto-assigned by Windows. Users can reassign them via Disk Management if needed. additional_policy_arns: List of additional IAM policy ARNs to attach to the VDI instance role. Example: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", "arn:aws:iam::123456789012:policy/MyCustomPolicy"] |
map(object({ |
{} |
no |
Outputs¶
| Name | Description |
|---|---|
| ami_id | AMI ID used for workstations |
| connection_info | Complete connection information for VDI workstations |
| emergency_key_paths | S3 paths for emergency private keys |
| private_keys | Private keys for emergency access (sensitive) |
| private_zone_id | Private hosted zone ID for creating additional VPC associations |
| private_zone_name | Private hosted zone name |
| public_ips | Map of workstation public IP addresses |
| vpn_configs_bucket | S3 bucket name for VPN configuration files |
Volume Management¶
Dynamic Volume Operations¶
Adding/Resizing Volumes:
- Add or modify volumes in Terraform configuration
- Run
terraform apply - Wait 5-10 minutes for automatic SSM volume script execution
- Verify volumes are initialized via RDP
How it works:
- ✅ Fully automated - Lifecycle rules handle all triggering
- ✅ Reliable triggering - Automatically detects volume changes
- ✅ Predictable timing - 5-10 minutes for script execution
- ✅ Proper cleanup - Drive letters managed automatically
- ✅ Clean plans - No continuous Terraform drift
Alternative: Manual Administration¶
For immediate results, you can skip waiting for SSM and manually initialize volumes:
- RDP to instance as Administrator after
terraform apply - Run PowerShell commands to initialize volumes immediately
- Complete in under 2 minutes with full control
Volume Limitations¶
Volume Size Reduction - NOT SUPPORTED¶
AWS Limitation: EBS volumes cannot be reduced in size. This is an AWS platform limitation, not a module limitation.
What Happens: If you reduce volume capacity in Terraform (e.g., 500GB → 200GB):
terraform apply
# ❌ Error: InvalidParameterValue: Cannot decrease volume size from 500 to 200
# ❌ The apply will FAIL IMMEDIATELY - no waiting required
Workaround for Size Reduction:
- Create new smaller volume in Terraform config
- Manually migrate data from old to new volume via RDP
- Remove old volume from Terraform config
- Apply changes - old volume will be deleted