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
- You are replacing physical laptops or VDI for remote staff, contractors, or a seasonal call-centre and want desktops that follow your existing Active Directory identities.
- You need to give third-party developers or auditors a locked-down, non-internet-egress desktop inside your VPC that can reach internal apps but not exfiltrate data to a personal machine.
- You run a regulated workload (finance, healthcare, public sector) where the endpoint must live in AWS, with IP-allowlisted access and encrypted volumes, rather than on unmanaged hardware.
- You want
AutoStopdesktops that bill by the hour for occasional users, andAlwaysOndesktops for power users — declared as data, not clicked in a console. - You are standardising a multi-account landing zone and want every business unit to register its directory and roll out WorkSpaces the same way.
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 config — live/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 config — live/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
- Keep AUTO_STOP as the default and reserve ALWAYS_ON for genuine power/build users. Hourly billing on idle desktops is dramatically cheaper; tune
default_auto_stop_timeout_in_minutes(always a multiple of 60) to balance reconnection latency against cost. - Always encrypt both volumes with a customer-managed KMS key. Set
volume_encryption_enabled = truewith a dedicatedvolume_encryption_key; encryption can only be chosen at WorkSpace creation, so getting it wrong means a rebuild. - Lock down the blast radius with
trusted_cidrsand the client allow-list. Bind an IP access-control group to the directory and deny web/mobile clients unless a use case demands them — this stops connections from any untrusted network or device class. - Right-size the bundle and volumes per user, not per fleet. Use the per-user
workspacesoverrides for compute type anduser_volume_size_gibso designers get GRAPHICS_G4DN while call-centre staff stay on VALUE/STANDARD; oversizing every desktop is the most common WorkSpaces cost leak. - Pin the directory placement. Pass exactly two subnets in two supported AZs and set
default_outo a dedicated Workspaces OU so computer objects land in a managed, GPO-scoped container rather than the directory default. - Tag for chargeback and keep usernames as the map key. The module tags every WorkSpace with
workspaces:user; combine that withCostCenter/Environmenttags so finance can attribute per-desktop spend and so removing a user from the map cleanly destroys only that person’s desktop.