IaC AWS

Terraform Module: AWS WorkSpaces — managed virtual desktops with a registered directory

Quick take — Provision AWS WorkSpaces end-to-end with Terraform: register a Directory Service directory, set self-service and access controls, and roll out per-user WorkSpaces with running-mode and root/user volume sizing. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "workspaces" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-workspaces?ref=v1.0.0"

  directory_id      = "..."           # Directory Service directory ID to register with WorkSpa…
  directory_name    = "..."           # Short name for the directory; used to name the IP acces…
  subnet_ids        = ["...", "..."]  # Exactly two subnet IDs in two different, WorkSpaces-sup…
  default_bundle_id = "..."           # Default WorkSpaces bundle ID (wsb-...) for users that d…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon WorkSpaces is AWS’s managed Desktop-as-a-Service (DaaS) offering. It hands a user a persistent Windows or Amazon Linux cloud desktop that streams over PCoIP or the WorkSpaces Streaming Protocol (WSP/DCV), so you never image, ship, or patch a physical laptop again. The catch is that a WorkSpace is never a standalone object: every desktop must be bound to a registered directory (AWS Managed Microsoft AD, AD Connector, or Simple AD) that owns the user identities, and that directory has to be registered with the WorkSpaces service — with subnets selected, self-service permissions decided, and IP-based access control rules attached — before a single aws_workspaces_workspace will provision.

Wiring all of that by hand in the console is exactly the kind of multi-step, easy-to-get-wrong setup that belongs behind a reusable module. This module wraps aws_workspaces_directory (the registration plus self-service, access, and workspace-creation properties) together with a for_each over aws_workspaces_workspace, so a team consumes one block — directory ID, a couple of subnets, and a map of users — and gets a consistent, policy-controlled fleet of desktops with predictable volume sizes, running modes, and tags.

When to use it

Reach for the full WorkStreams stack (WorkSpaces + AppStream) or a third-party VDI instead only if you need ephemeral, non-persistent sessions; WorkSpaces desktops are persistent by design.

Module structure

terraform-module-aws-workspaces/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # WorkSpaces is only available in a subset of Regions/AZs; the caller passes
  # exactly the subnets that live in supported AZs. We dedupe defensively.
  subnet_ids = distinct(var.subnet_ids)

  # Normalise the user map so every workspace inherits fleet defaults unless
  # the caller overrides a field per user.
  workspaces = {
    for username, cfg in var.workspaces : username => {
      bundle_id                       = coalesce(cfg.bundle_id, var.default_bundle_id)
      root_volume_size_gib            = coalesce(cfg.root_volume_size_gib, var.default_root_volume_size_gib)
      user_volume_size_gib            = coalesce(cfg.user_volume_size_gib, var.default_user_volume_size_gib)
      compute_type_name               = coalesce(cfg.compute_type_name, var.default_compute_type_name)
      running_mode                    = coalesce(cfg.running_mode, var.default_running_mode)
      running_mode_auto_stop_timeout  = coalesce(cfg.running_mode_auto_stop_timeout_in_minutes, var.default_auto_stop_timeout_in_minutes)
      root_volume_encryption_enabled  = var.volume_encryption_enabled
      user_volume_encryption_enabled  = var.volume_encryption_enabled
    }
  }
}

# 1. Register the existing Directory Service directory with WorkSpaces and set
#    the fleet-wide self-service, access-control and creation properties.
resource "aws_workspaces_directory" "this" {
  directory_id = var.directory_id
  subnet_ids   = local.subnet_ids

  # Restrict which client platforms (and only the platforms) may connect.
  workspace_access_properties {
    device_type_windows    = var.allow_windows_client ? "ALLOW" : "DENY"
    device_type_osx        = var.allow_macos_client ? "ALLOW" : "DENY"
    device_type_web        = var.allow_web_client ? "ALLOW" : "DENY"
    device_type_android    = var.allow_mobile_client ? "ALLOW" : "DENY"
    device_type_ios        = var.allow_mobile_client ? "ALLOW" : "DENY"
    device_type_chromeos   = var.allow_chromeos_client ? "ALLOW" : "DENY"
    device_type_zeroclient = var.allow_zero_client ? "ALLOW" : "DENY"
    device_type_linux      = var.allow_linux_client ? "ALLOW" : "DENY"
  }

  # What end users may do for themselves from the WorkSpaces client.
  self_service_permissions {
    change_compute_type  = var.self_service.change_compute_type
    increase_volume_size = var.self_service.increase_volume_size
    rebuild_workspace    = var.self_service.rebuild_workspace
    restart_workspace    = var.self_service.restart_workspace
    switch_running_mode  = var.self_service.switch_running_mode
  }

  # Defaults applied to WorkSpaces created from the console/self-service.
  workspace_creation_properties {
    enable_internet_access              = var.enable_internet_access
    enable_maintenance_mode             = var.enable_maintenance_mode
    user_enabled_as_local_administrator = var.user_enabled_as_local_administrator
    default_ou                          = var.default_ou
    custom_security_group_id            = var.custom_security_group_id
  }

  tags = var.tags
}

# 2. (Optional) Attach an IP access-control group so only trusted CIDRs can
#    reach the desktops. Created only when rules are supplied.
resource "aws_workspaces_ip_group" "this" {
  count = length(var.trusted_cidrs) > 0 ? 1 : 0

  name        = "${var.directory_name}-trusted"
  description = "Trusted source CIDRs allowed to connect to ${var.directory_name} WorkSpaces"

  dynamic "rules" {
    for_each = var.trusted_cidrs
    content {
      source      = rules.value.cidr
      description = rules.value.description
    }
  }

  tags = var.tags
}

# Bind the IP group to the registered directory.
resource "aws_workspaces_directory_ip_group_association" "this" {
  count = length(var.trusted_cidrs) > 0 ? 1 : 0

  directory_id = aws_workspaces_directory.this.id
  ip_group_id  = aws_workspaces_ip_group.this[0].id
}

# 3. Provision one persistent WorkSpace per user in the map.
resource "aws_workspaces_workspace" "this" {
  for_each = local.workspaces

  directory_id = aws_workspaces_directory.this.id
  bundle_id    = each.value.bundle_id
  user_name    = each.key

  root_volume_encryption_enabled = each.value.root_volume_encryption_enabled
  user_volume_encryption_enabled = each.value.user_volume_encryption_enabled
  volume_encryption_key          = var.volume_encryption_enabled ? var.volume_encryption_key : null

  workspace_properties {
    compute_type_name                         = each.value.compute_type_name
    running_mode                              = each.value.running_mode
    running_mode_auto_stop_timeout_in_minutes = each.value.running_mode == "AUTO_STOP" ? each.value.running_mode_auto_stop_timeout : null
    root_volume_size_gib                      = each.value.root_volume_size_gib
    user_volume_size_gib                      = each.value.user_volume_size_gib
  }

  tags = merge(var.tags, { "workspaces:user" = each.key })

  # The directory must be REGISTERED (and the IP group bound) before any
  # workspace can be created against it.
  depends_on = [
    aws_workspaces_directory.this,
    aws_workspaces_directory_ip_group_association.this,
  ]
}

variables.tf

variable "directory_id" {
  description = "ID of the existing Directory Service directory (AWS Managed Microsoft AD, AD Connector, or Simple AD) to register with WorkSpaces."
  type        = string

  validation {
    condition     = can(regex("^d-[0-9a-f]{10}$", var.directory_id))
    error_message = "directory_id must look like a Directory Service ID, e.g. d-1234567890."
  }
}

variable "directory_name" {
  description = "Short, human-friendly name for the directory; used to name the IP access-control group."
  type        = string
}

variable "subnet_ids" {
  description = "Exactly two subnet IDs in DIFFERENT AZs, both in WorkSpaces-supported Availability Zones, used to register the directory."
  type        = list(string)

  validation {
    condition     = length(distinct(var.subnet_ids)) == 2
    error_message = "Provide exactly two distinct subnet IDs in two different Availability Zones."
  }
}

variable "default_bundle_id" {
  description = "Default WorkSpaces bundle ID (e.g. wsb-... for Standard Windows 10/11) applied to users that do not override it."
  type        = string

  validation {
    condition     = can(regex("^wsb-[0-9a-z]+$", var.default_bundle_id))
    error_message = "default_bundle_id must be a WorkSpaces bundle ID like wsb-xxxxxxxxx."
  }
}

variable "default_compute_type_name" {
  description = "Default compute type for new WorkSpaces."
  type        = string
  default     = "STANDARD"

  validation {
    condition = contains(
      ["VALUE", "STANDARD", "PERFORMANCE", "POWER", "POWERPRO", "GRAPHICS", "GRAPHICSPRO", "GRAPHICS_G4DN", "GRAPHICSPRO_G4DN"],
      var.default_compute_type_name
    )
    error_message = "default_compute_type_name must be a valid WorkSpaces compute type."
  }
}

variable "default_running_mode" {
  description = "Default running mode: AUTO_STOP (hourly, billed when used) or ALWAYS_ON (monthly)."
  type        = string
  default     = "AUTO_STOP"

  validation {
    condition     = contains(["AUTO_STOP", "ALWAYS_ON"], var.default_running_mode)
    error_message = "default_running_mode must be AUTO_STOP or ALWAYS_ON."
  }
}

variable "default_auto_stop_timeout_in_minutes" {
  description = "Idle timeout (minutes) before an AUTO_STOP WorkSpace stops. Must be a multiple of 60."
  type        = number
  default     = 60

  validation {
    condition     = var.default_auto_stop_timeout_in_minutes % 60 == 0 && var.default_auto_stop_timeout_in_minutes >= 60
    error_message = "default_auto_stop_timeout_in_minutes must be a positive multiple of 60."
  }
}

variable "default_root_volume_size_gib" {
  description = "Default root (C:) volume size in GiB. Must be compatible with the chosen bundle."
  type        = number
  default     = 80

  validation {
    condition     = var.default_root_volume_size_gib >= 80 && var.default_root_volume_size_gib <= 2000
    error_message = "default_root_volume_size_gib must be between 80 and 2000 GiB."
  }
}

variable "default_user_volume_size_gib" {
  description = "Default user (D:) volume size in GiB."
  type        = number
  default     = 50

  validation {
    condition     = contains([10, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 1000, 2000], var.default_user_volume_size_gib)
    error_message = "default_user_volume_size_gib must be one of the AWS-allowed sizes (10, 50, 100 ... 2000)."
  }
}

variable "workspaces" {
  description = <<-EOT
    Map of WorkSpaces keyed by Active Directory username. Each value may override
    fleet defaults. Leave a field null to inherit the corresponding default_* value.
  EOT
  type = map(object({
    bundle_id                                 = optional(string)
    compute_type_name                         = optional(string)
    running_mode                              = optional(string)
    running_mode_auto_stop_timeout_in_minutes = optional(number)
    root_volume_size_gib                      = optional(number)
    user_volume_size_gib                      = optional(number)
  }))
  default = {}
}

variable "volume_encryption_enabled" {
  description = "Encrypt the root and user volumes of every WorkSpace with a KMS key."
  type        = bool
  default     = true
}

variable "volume_encryption_key" {
  description = "KMS key ARN/alias used to encrypt WorkSpace volumes. Required when volume_encryption_enabled is true."
  type        = string
  default     = null

  validation {
    condition     = var.volume_encryption_key == null || can(regex("^(arn:aws:kms:|alias/)", var.volume_encryption_key))
    error_message = "volume_encryption_key must be a KMS key ARN or an alias/... string."
  }
}

variable "enable_internet_access" {
  description = "Auto-assign a public IP so AUTO_STOP WorkSpaces in public subnets get internet egress. Keep false for locked-down desktops behind a NAT gateway."
  type        = bool
  default     = false
}

variable "enable_maintenance_mode" {
  description = "Enable AWS-managed maintenance windows that apply OS and agent updates to the WorkSpaces."
  type        = bool
  default     = true
}

variable "user_enabled_as_local_administrator" {
  description = "Grant the WorkSpace user local administrator rights on their desktop."
  type        = bool
  default     = false
}

variable "default_ou" {
  description = "Distinguished name of the OU in which WorkSpace computer objects are created, e.g. OU=Workspaces,DC=corp,DC=example,DC=com. Null uses the directory default."
  type        = string
  default     = null
}

variable "custom_security_group_id" {
  description = "Additional security group attached to every WorkSpace ENI for east-west control. Null to omit."
  type        = string
  default     = null
}

variable "self_service" {
  description = "Self-service actions exposed to end users in the WorkSpaces client."
  type = object({
    change_compute_type  = optional(bool, false)
    increase_volume_size = optional(bool, false)
    rebuild_workspace    = optional(bool, true)
    restart_workspace    = optional(bool, true)
    switch_running_mode  = optional(bool, true)
  })
  default = {}
}

variable "allow_windows_client"  { type = bool, default = true }
variable "allow_macos_client"    { type = bool, default = true }
variable "allow_web_client"      { type = bool, default = false }
variable "allow_linux_client"    { type = bool, default = false }
variable "allow_mobile_client"   { type = bool, default = false }
variable "allow_chromeos_client" { type = bool, default = false }
variable "allow_zero_client"     { type = bool, default = false }

variable "trusted_cidrs" {
  description = "Source CIDRs allowed to connect. When non-empty, an IP access-control group is created and bound to the directory; all other sources are blocked."
  type = list(object({
    cidr        = string
    description = string
  }))
  default = []
}

variable "tags" {
  description = "Tags applied to the directory registration, IP group, and every WorkSpace."
  type        = map(string)
  default     = {}
}

outputs.tf

output "directory_id" {
  description = "ID of the directory registered with WorkSpaces."
  value       = aws_workspaces_directory.this.id
}

output "directory_registration_code" {
  description = "Registration code users enter in the WorkSpaces client to enrol (e.g. SLiad+ABC123)."
  value       = aws_workspaces_directory.this.registration_code
}

output "directory_dns_ip_addresses" {
  description = "DNS IP addresses of the registered directory."
  value       = aws_workspaces_directory.this.dns_ip_addresses
}

output "workspace_ids" {
  description = "Map of AD username => WorkSpace ID (ws-...)."
  value       = { for u, w in aws_workspaces_workspace.this : u => w.id }
}

output "workspace_ip_addresses" {
  description = "Map of AD username => private IP address assigned to the WorkSpace."
  value       = { for u, w in aws_workspaces_workspace.this : u => w.ip_address }
}

output "workspace_computer_names" {
  description = "Map of AD username => the WorkSpace computer (NetBIOS) name in the directory."
  value       = { for u, w in aws_workspaces_workspace.this : u => w.computer_name }
}

output "ip_group_id" {
  description = "ID of the IP access-control group, or null when no trusted_cidrs were supplied."
  value       = try(aws_workspaces_ip_group.this[0].id, null)
}

How to use it

module "workspaces" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-workspaces?ref=v1.0.0"

  directory_id   = aws_directory_service_directory.corp.id
  directory_name = "corp-prod"

  # Two private subnets in two AZs that are WorkSpaces-supported.
  subnet_ids = [
    aws_subnet.workspaces_a.id,
    aws_subnet.workspaces_b.id,
  ]

  # Standard Windows bundle; encrypt volumes with the corp CMK.
  default_bundle_id         = "wsb-bh8rsxt14" # Standard with Windows 11
  default_compute_type_name = "STANDARD"
  volume_encryption_enabled = true
  volume_encryption_key     = "alias/corp-workspaces"

  # Locked-down desktops: no internet egress, AWS maintenance on.
  enable_internet_access = false
  default_running_mode   = "AUTO_STOP"

  # Only allow connections from the office and VPN ranges.
  trusted_cidrs = [
    { cidr = "203.0.113.0/24", description = "HQ office" },
    { cidr = "198.51.100.10/32", description = "VPN egress IP" },
  ]

  # Restrict clients to managed Windows and macOS only.
  allow_windows_client = true
  allow_macos_client   = true
  allow_web_client     = false

  workspaces = {
    "arjun.nair" = {} # inherits all fleet defaults
    "priya.menon" = {
      compute_type_name    = "PERFORMANCE"
      user_volume_size_gib = 100
    }
    "ci-runner.svc" = {
      running_mode = "ALWAYS_ON" # build agent must stay up
    }
  }

  tags = {
    Environment = "prod"
    CostCenter  = "IT-EUC"
    ManagedBy   = "terraform"
  }
}

# Downstream: publish each user's registration target + WorkSpace ID to SSM so
# the onboarding automation can email enrolment instructions.
resource "aws_ssm_parameter" "workspace_registration" {
  name  = "/euc/workspaces/registration-code"
  type  = "String"
  value = module.workspaces.directory_registration_code
}

resource "aws_ssm_parameter" "workspace_ids" {
  name  = "/euc/workspaces/ids"
  type  = "String"
  value = jsonencode(module.workspaces.workspace_ids)
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/workspaces/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-workspaces?ref=v1.0.0"
}

inputs = {
  directory_id = "..."
  directory_name = "..."
  subnet_ids = ["...", "..."]
  default_bundle_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/workspaces && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
directory_id string yes Directory Service directory ID to register with WorkSpaces (d-xxxxxxxxxx).
directory_name string yes Short name for the directory; used to name the IP access-control group.
subnet_ids list(string) yes Exactly two subnet IDs in two different, WorkSpaces-supported AZs.
default_bundle_id string yes Default WorkSpaces bundle ID (wsb-…) for users that do not override it.
default_compute_type_name string "STANDARD" no Default compute type (VALUE, STANDARD, PERFORMANCE, POWER, GRAPHICS_G4DN, …).
default_running_mode string "AUTO_STOP" no AUTO_STOP (hourly) or ALWAYS_ON (monthly).
default_auto_stop_timeout_in_minutes number 60 no Idle minutes before AUTO_STOP; must be a multiple of 60.
default_root_volume_size_gib number 80 no Default root (C:) volume size, 80–2000 GiB.
default_user_volume_size_gib number 50 no Default user (D:) volume size; AWS-allowed sizes only.
workspaces map(object) {} no Per-user WorkSpaces keyed by AD username; each value may override fleet defaults.
volume_encryption_enabled bool true no Encrypt root and user volumes with KMS.
volume_encryption_key string null no KMS key ARN/alias for volume encryption; required when encryption is enabled.
enable_internet_access bool false no Auto-assign public IP for internet egress on AUTO_STOP WorkSpaces.
enable_maintenance_mode bool true no Enable AWS-managed OS/agent maintenance windows.
user_enabled_as_local_administrator bool false no Grant the WorkSpace user local admin rights.
default_ou string null no OU distinguished name for WorkSpace computer objects.
custom_security_group_id string null no Extra security group attached to each WorkSpace ENI.
self_service object {} no Self-service client actions (rebuild/restart/switch running mode, etc.).
allow_windows_client bool true no Allow connections from the Windows client.
allow_macos_client bool true no Allow connections from the macOS client.
allow_web_client bool false no Allow connections from the web client.
allow_linux_client bool false no Allow connections from the Linux client.
allow_mobile_client bool false no Allow connections from iOS/Android clients.
allow_chromeos_client bool false no Allow connections from the ChromeOS client.
allow_zero_client bool false no Allow connections from PCoIP zero clients.
trusted_cidrs list(object) [] no Source CIDRs allowed to connect; non-empty creates and binds an IP access-control group.
tags map(string) {} no Tags applied to the directory, IP group, and every WorkSpace.

Outputs

Name Description
directory_id ID of the directory registered with WorkSpaces.
directory_registration_code Registration code users enter in the WorkSpaces client to enrol.
directory_dns_ip_addresses DNS IP addresses of the registered directory.
workspace_ids Map of AD username => WorkSpace ID (ws-…).
workspace_ip_addresses Map of AD username => private IP address of the WorkSpace.
workspace_computer_names Map of AD username => WorkSpace computer (NetBIOS) name.
ip_group_id ID of the IP access-control group, or null when no trusted_cidrs were supplied.

Enterprise scenario

A financial-services firm onboards 40 offshore contractors who must work on client data but are never allowed to copy it to personal hardware. The platform team calls this module once per delivery squad: each gets AUTO_STOP STANDARD desktops with enable_internet_access = false (egress only via a controlled NAT/proxy), KMS-encrypted volumes, and a trusted_cidrs list pinned to the VPN egress IPs, so a stolen credential is useless from any other network. When a contract ends, the squad’s entry is removed from the workspaces map and terraform apply deprovisions exactly those desktops while the directory registration and IP allowlist stay intact for the rest of the team.

Best practices

TerraformAWSWorkSpacesModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading