IaC AWS

Terraform Module: AWS AppStream 2.0 — fleet + stack streaming desktops in one reusable block

Quick take — Provision AWS AppStream 2.0 non-persistent application streaming with Terraform: an aws_appstream_fleet and aws_appstream_stack module with auto-scaling, storage connectors and VPC wiring. 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 "appstream" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-appstream?ref=v1.0.0"

  name_prefix = "..."  # Prefix for fleet/stack names; yields `<prefix>-fleet` a…
}

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

What this module is

AWS AppStream 2.0 is a fully managed application and desktop streaming service. You bake your Windows (or Amazon Linux) applications into an image, AppStream runs that image on a fleet of streaming instances, and users connect to a stack that exposes the apps through a browser or the native client — the pixels stream down, the data never leaves AWS. It is AWS’s answer to “I need to put a CAD package, a trading terminal, or a locked-down line-of-business app in front of contractors and BYOD users without shipping them a laptop or the source data.”

The two resources that actually matter are tightly coupled but configured separately, which makes them perfect to wrap in a module:

A bare-metal AppStream setup also needs an aws_appstream_fleet_stack_association to glue the two together, and almost always a aws_appstream_user_stack_association or SAML wiring so people can actually log in. Getting the timeouts, capacity floor, and storage-connector flags right by hand — per environment, per app — is exactly the kind of error-prone copy-paste this module removes. Define the fleet/stack pair once, expose the knobs as variables, and stamp out a consistent QA, UAT and prod streaming environment from the same code.

When to use it

If you need full persistent personal desktops that survive reboots and keep user-installed apps, look at Amazon WorkSpaces instead — AppStream is app/session streaming, not a 1:1 persistent VDI.

Module structure

terraform-module-aws-appstream/
├── 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 {
  # AppStream names allow alphanumerics, hyphen, period and underscore.
  fleet_name = "${var.name_prefix}-fleet"
  stack_name = "${var.name_prefix}-stack"

  tags = merge(
    {
      Module      = "terraform-module-aws-appstream"
      Environment = var.environment
      ManagedBy   = "terraform"
    },
    var.tags,
  )
}

resource "aws_appstream_fleet" "this" {
  name          = local.fleet_name
  display_name  = var.fleet_display_name
  description   = var.description
  instance_type = var.instance_type
  fleet_type    = var.fleet_type

  # Exactly one of image_name / image_arn must be supplied.
  image_name = var.image_arn == null ? var.image_name : null
  image_arn  = var.image_arn

  max_user_duration_in_seconds    = var.max_user_duration_in_seconds
  idle_disconnect_timeout_in_seconds = var.idle_disconnect_timeout_in_seconds
  disconnect_timeout_in_seconds   = var.disconnect_timeout_in_seconds
  stream_view                     = var.stream_view

  enable_default_internet_access = var.enable_default_internet_access

  compute_capacity {
    desired_instances = var.desired_instances
  }

  # Place fleet instances inside customer-managed networking when subnets
  # are provided; otherwise AppStream uses default internet access only.
  dynamic "vpc_config" {
    for_each = length(var.subnet_ids) > 0 ? [1] : []
    content {
      subnet_ids         = var.subnet_ids
      security_group_ids = var.security_group_ids
    }
  }

  # Join streaming instances to an Active Directory domain (optional).
  dynamic "domain_join_info" {
    for_each = var.directory_name == null ? [] : [1]
    content {
      directory_name                         = var.directory_name
      organizational_unit_distinguished_name = var.organizational_unit_distinguished_name
    }
  }

  tags = local.tags
}

resource "aws_appstream_stack" "this" {
  name         = local.stack_name
  display_name = var.stack_display_name
  description  = var.description

  embed_host_domains = var.embed_host_domains

  # Persist application settings (registry/AppData) between sessions per user.
  application_settings {
    enabled        = var.application_settings_enabled
    settings_group = var.application_settings_enabled ? var.application_settings_group : null
  }

  dynamic "storage_connectors" {
    for_each = var.storage_connectors
    content {
      connector_type      = storage_connectors.value.connector_type
      domains             = lookup(storage_connectors.value, "domains", null)
      resource_identifier = lookup(storage_connectors.value, "resource_identifier", null)
    }
  }

  dynamic "user_settings" {
    for_each = var.user_settings
    content {
      action     = user_settings.value.action
      permission = user_settings.value.permission
    }
  }

  tags = local.tags
}

# Bind the fleet to the stack so users of the stack get the fleet's apps.
resource "aws_appstream_fleet_stack_association" "this" {
  fleet_name = aws_appstream_fleet.this.name
  stack_name = aws_appstream_stack.this.name
}

variables.tf

variable "name_prefix" {
  description = "Prefix for fleet and stack names, e.g. 'cad-prod'. Produces <prefix>-fleet and <prefix>-stack."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]{1,80}$", var.name_prefix))
    error_message = "name_prefix may contain only alphanumerics, hyphen, period and underscore (max 80 chars)."
  }
}

variable "environment" {
  description = "Environment tag value (e.g. dev, uat, prod)."
  type        = string
  default     = "dev"
}

variable "description" {
  description = "Human-readable description applied to both fleet and stack."
  type        = string
  default     = "Managed by terraform-module-aws-appstream"
}

variable "fleet_display_name" {
  description = "Display name shown for the fleet in the console."
  type        = string
  default     = null
}

variable "stack_display_name" {
  description = "Display name shown to end users for the stack."
  type        = string
  default     = null
}

variable "instance_type" {
  description = "AppStream instance type, e.g. stream.standard.medium or stream.graphics-g4dn.xlarge."
  type        = string
  default     = "stream.standard.medium"
}

variable "fleet_type" {
  description = "ON_DEMAND (pay per session, slower start) or ALWAYS_ON (instant start, billed continuously)."
  type        = string
  default     = "ON_DEMAND"

  validation {
    condition     = contains(["ON_DEMAND", "ALWAYS_ON"], var.fleet_type)
    error_message = "fleet_type must be either ON_DEMAND or ALWAYS_ON."
  }
}

variable "image_name" {
  description = "Name of the AppStream image to stream. Mutually exclusive with image_arn."
  type        = string
  default     = null
}

variable "image_arn" {
  description = "ARN of the AppStream image (use for AWS-managed/shared images). Takes precedence over image_name."
  type        = string
  default     = null
}

variable "desired_instances" {
  description = "Number of streaming instances kept available in the fleet."
  type        = number
  default     = 2

  validation {
    condition     = var.desired_instances >= 1
    error_message = "desired_instances must be at least 1."
  }
}

variable "max_user_duration_in_seconds" {
  description = "Maximum length of a streaming session before forced disconnect (600-432000)."
  type        = number
  default     = 57600

  validation {
    condition     = var.max_user_duration_in_seconds >= 600 && var.max_user_duration_in_seconds <= 432000
    error_message = "max_user_duration_in_seconds must be between 600 and 432000."
  }
}

variable "disconnect_timeout_in_seconds" {
  description = "Time a disconnected session is kept alive for reconnection (60-360000)."
  type        = number
  default     = 900

  validation {
    condition     = var.disconnect_timeout_in_seconds >= 60 && var.disconnect_timeout_in_seconds <= 360000
    error_message = "disconnect_timeout_in_seconds must be between 60 and 360000."
  }
}

variable "idle_disconnect_timeout_in_seconds" {
  description = "Idle time before a user is disconnected (0 to disable, otherwise 60-3600)."
  type        = number
  default     = 900

  validation {
    condition     = var.idle_disconnect_timeout_in_seconds == 0 || (var.idle_disconnect_timeout_in_seconds >= 60 && var.idle_disconnect_timeout_in_seconds <= 3600)
    error_message = "idle_disconnect_timeout_in_seconds must be 0 (disabled) or between 60 and 3600."
  }
}

variable "stream_view" {
  description = "What is streamed: APP (single app) or DESKTOP (full desktop)."
  type        = string
  default     = "APP"

  validation {
    condition     = contains(["APP", "DESKTOP"], var.stream_view)
    error_message = "stream_view must be APP or DESKTOP."
  }
}

variable "enable_default_internet_access" {
  description = "Enable AppStream-managed default internet access. Leave false when using a NAT gateway in your VPC."
  type        = bool
  default     = false
}

variable "subnet_ids" {
  description = "Private subnet IDs (max 2, different AZs) for fleet vpc_config. Empty list disables vpc_config."
  type        = list(string)
  default     = []
}

variable "security_group_ids" {
  description = "Security group IDs attached to fleet instances when subnet_ids is set (max 5)."
  type        = list(string)
  default     = []
}

variable "directory_name" {
  description = "Active Directory domain (FQDN) to join streaming instances to. Null disables domain join."
  type        = string
  default     = null
}

variable "organizational_unit_distinguished_name" {
  description = "OU distinguished name for domain-joined instances, e.g. OU=AppStream,DC=corp,DC=local."
  type        = string
  default     = null
}

variable "embed_host_domains" {
  description = "Domains allowed to embed AppStream streaming sessions in an iframe."
  type        = list(string)
  default     = null
}

variable "application_settings_enabled" {
  description = "Persist per-user application settings (roaming profile) across sessions."
  type        = bool
  default     = true
}

variable "application_settings_group" {
  description = "Settings group name used to scope persisted application settings."
  type        = string
  default     = "default"
}

variable "storage_connectors" {
  description = <<-EOT
    List of storage connectors mounted in the stack. Each item:
      connector_type      = HOMEFOLDERS | GOOGLE_DRIVE | ONE_DRIVE
      domains             = (optional) list of accepted domains
      resource_identifier = (optional) e.g. the S3 bucket for HOMEFOLDERS
  EOT
  type = list(object({
    connector_type      = string
    domains             = optional(list(string))
    resource_identifier = optional(string)
  }))
  default = [
    { connector_type = "HOMEFOLDERS" }
  ]

  validation {
    condition = alltrue([
      for c in var.storage_connectors :
      contains(["HOMEFOLDERS", "GOOGLE_DRIVE", "ONE_DRIVE"], c.connector_type)
    ])
    error_message = "Each connector_type must be HOMEFOLDERS, GOOGLE_DRIVE or ONE_DRIVE."
  }
}

variable "user_settings" {
  description = "Clipboard/file-transfer/print permissions for the stack. Action + ENABLED|DISABLED permission."
  type = list(object({
    action     = string
    permission = string
  }))
  default = [
    { action = "CLIPBOARD_COPY_FROM_LOCAL_DEVICE", permission = "ENABLED" },
    { action = "CLIPBOARD_COPY_TO_LOCAL_DEVICE", permission = "ENABLED" },
    { action = "FILE_UPLOAD", permission = "DISABLED" },
    { action = "FILE_DOWNLOAD", permission = "DISABLED" },
    { action = "PRINTING_TO_LOCAL_DEVICE", permission = "DISABLED" },
  ]
}

variable "tags" {
  description = "Additional tags merged onto fleet and stack."
  type        = map(string)
  default     = {}
}

outputs.tf

output "fleet_id" {
  description = "ID (name) of the AppStream fleet."
  value       = aws_appstream_fleet.this.id
}

output "fleet_name" {
  description = "Name of the AppStream fleet."
  value       = aws_appstream_fleet.this.name
}

output "fleet_arn" {
  description = "ARN of the AppStream fleet."
  value       = aws_appstream_fleet.this.arn
}

output "fleet_state" {
  description = "Current state of the fleet (RUNNING, STOPPED, etc.)."
  value       = aws_appstream_fleet.this.state
}

output "stack_id" {
  description = "ID (name) of the AppStream stack."
  value       = aws_appstream_stack.this.id
}

output "stack_name" {
  description = "Name of the AppStream stack — pass this to user/SAML associations."
  value       = aws_appstream_stack.this.name
}

output "stack_arn" {
  description = "ARN of the AppStream stack."
  value       = aws_appstream_stack.this.arn
}

output "fleet_stack_association_id" {
  description = "ID of the fleet-to-stack association."
  value       = aws_appstream_fleet_stack_association.this.id
}

How to use it

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

  name_prefix = "cad-prod"
  environment = "prod"
  description = "SolidWorks streaming for external design contractors"

  # GPU instances for graphics workloads, instant-on for prod.
  instance_type = "stream.graphics-g4dn.xlarge"
  fleet_type    = "ALWAYS_ON"
  image_name    = "SolidWorks-2026-Golden-v4"

  desired_instances                  = 4
  max_user_duration_in_seconds       = 36000 # 10h shift cap
  idle_disconnect_timeout_in_seconds = 1800
  disconnect_timeout_in_seconds      = 1200
  stream_view                        = "APP"

  # Keep instances inside the data VPC; egress via the VPC NAT, not AppStream default.
  enable_default_internet_access = false
  subnet_ids                     = [aws_subnet.appstream_a.id, aws_subnet.appstream_b.id]
  security_group_ids             = [aws_security_group.appstream.id]

  # Domain-join so contractors authenticate against corporate AD.
  directory_name                         = "corp.kloudvin.internal"
  organizational_unit_distinguished_name = "OU=AppStream,OU=Contractors,DC=corp,DC=kloudvin,DC=internal"

  # Home folders on S3, no file egress to the local device for IP protection.
  storage_connectors = [
    { connector_type = "HOMEFOLDERS" }
  ]
  user_settings = [
    { action = "CLIPBOARD_COPY_TO_LOCAL_DEVICE", permission = "DISABLED" },
    { action = "FILE_DOWNLOAD", permission = "DISABLED" },
    { action = "FILE_UPLOAD", permission = "ENABLED" },
  ]

  tags = {
    CostCenter = "engineering"
    DataClass  = "confidential"
  }
}

# Downstream: grant a contractor access to the streaming stack by name.
resource "aws_appstream_user" "contractor" {
  user_name       = "jane.designer@partner.example"
  authentication_type = "USERPOOL"
  first_name      = "Jane"
  last_name       = "Designer"
}

resource "aws_appstream_user_stack_association" "contractor" {
  authentication_type = "USERPOOL"
  stack_name          = module.appstream_2_0.stack_name # output consumed here
  user_name           = aws_appstream_user.contractor.user_name
  send_email_notification = true
}

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/appstream/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name_prefix = "..."
}

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

cd live/prod/appstream && 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
name_prefix string Yes Prefix for fleet/stack names; yields <prefix>-fleet and <prefix>-stack.
environment string "dev" No Environment tag value (dev/uat/prod).
description string "Managed by terraform-module-aws-appstream" No Description applied to fleet and stack.
fleet_display_name string null No Console display name for the fleet.
stack_display_name string null No Display name shown to end users for the stack.
instance_type string "stream.standard.medium" No AppStream instance type (e.g. stream.graphics-g4dn.xlarge).
fleet_type string "ON_DEMAND" No ON_DEMAND or ALWAYS_ON.
image_name string null No AppStream image name. Mutually exclusive with image_arn.
image_arn string null No AppStream image ARN; takes precedence over image_name.
desired_instances number 2 No Streaming instances kept available (>= 1).
max_user_duration_in_seconds number 57600 No Max session length before forced disconnect (600–432000).
disconnect_timeout_in_seconds number 900 No Reconnection window after disconnect (60–360000).
idle_disconnect_timeout_in_seconds number 900 No Idle timeout; 0 disables, else 60–3600.
stream_view string "APP" No APP or DESKTOP.
enable_default_internet_access bool false No Use AppStream-managed internet access (off when using VPC NAT).
subnet_ids list(string) [] No Up to 2 subnets in different AZs for vpc_config. Empty disables it.
security_group_ids list(string) [] No Up to 5 SGs attached to fleet instances when subnet_ids is set.
directory_name string null No AD FQDN to domain-join instances. Null disables domain join.
organizational_unit_distinguished_name string null No OU DN for domain-joined instances.
embed_host_domains list(string) null No Domains permitted to embed the streaming session in an iframe.
application_settings_enabled bool true No Persist per-user application settings across sessions.
application_settings_group string "default" No Settings group scoping persisted settings.
storage_connectors list(object) [{ connector_type = "HOMEFOLDERS" }] No Storage connectors: HOMEFOLDERS, GOOGLE_DRIVE, ONE_DRIVE.
user_settings list(object) clipboard on, file/print off No Per-action ENABLED/DISABLED clipboard, file-transfer and print permissions.
tags map(string) {} No Additional tags merged onto fleet and stack.

Outputs

Name Description
fleet_id ID (name) of the AppStream fleet.
fleet_name Name of the AppStream fleet.
fleet_arn ARN of the AppStream fleet.
fleet_state Current fleet state (RUNNING, STOPPED, etc.).
stack_id ID (name) of the AppStream stack.
stack_name Stack name — pass to user/SAML stack associations.
stack_arn ARN of the AppStream stack.
fleet_stack_association_id ID of the fleet-to-stack association.

Enterprise scenario

A precision-engineering firm onboards 40 overseas design contractors who must run a licensed SolidWorks build against confidential CAD assemblies, but the firm’s security policy forbids the models ever landing on a contractor laptop. They stamp out this module per project with instance_type = "stream.graphics-g4dn.xlarge", fleet_type = "ALWAYS_ON", the fleet pinned into the private data VPC, and user_settings that disable clipboard-to-local and file-download while leaving upload on. Contractors stream the app through the browser, the drawings stay on S3 home folders inside the account, and when a contract ends the team simply terraform destroys that project’s instance and the access disappears with it.

Best practices

TerraformAWSAppStream 2.0ModuleIaC
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