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:
aws_appstream_fleet— the compute layer: instance type, fleet type (ON_DEMANDvsALWAYS_ON), capacity, the image to run, idle/disconnect timeouts, and the VPC/subnets the instances live in.aws_appstream_stack— the user-facing layer: which storage connectors (home folders on S3, Google Drive, OneDrive) are mounted, clipboard/file-transfer permissions, application settings persistence, and embed/redirect URLs.
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
- You need to deliver GPU or graphics-heavy desktop apps (CAD, GIS, rendering, EDA) to thin clients without provisioning workstations.
- You are giving third parties, contractors, or auditors temporary access to an internal app while keeping data inside your VPC.
- You want non-persistent, ephemeral desktops that reset on logout for security or regulatory reasons (PCI, HIPAA workspaces).
- You are running an education or training lab where many users need identical pre-loaded software for a fixed window, then it tears down.
- You want auto-scaling streaming capacity tied to demand instead of a fixed VDI estate, with usage you can stop on
ON_DEMANDfleets to control spend.
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 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/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
- Match fleet_type to the workload, not the default.
ALWAYS_ONgives instant session start but bills every running instance 24/7 — reserve it for prod-facing apps. UseON_DEMANDplus tightidle_disconnect_timeout_in_secondsand a fleet auto-scaling stop schedule for bursty or training fleets to slash cost. - Lock down data egress with user_settings. For any confidential workload, disable
CLIPBOARD_COPY_TO_LOCAL_DEVICE,FILE_DOWNLOADandPRINTING_TO_LOCAL_DEVICE. AppStream’s whole value proposition is that data stays server-side — a single enabled flag undoes it. - Keep fleets in your VPC with
enable_default_internet_access = false. Route egress through a controlled NAT gateway and a restrictive security group so streaming instances reach only the licence servers and endpoints they need, and so traffic is logged. - Pin images by version in
image_name/image_arnand roll forward deliberately. Treat golden images like AMIs: buildApp-2026-Golden-v4, test it on a UAT fleet, then bump the variable in prod — never point prod at a mutable “latest” image. - Right-size
desired_instancesto concurrency, and use graphics instance families only when needed. Astream.standardfamily is far cheaper thanstream.graphics-g4dn; only graphics/GPU workloads justify the latter. Floor capacity to expected concurrent sessions, not headcount. - Standardise naming via
name_prefixand tag for chargeback. A<team>-<env>prefix keeps fleet and stack names predictable across QA/UAT/prod, and mergedCostCenter/DataClasstags let finance attribute AppStream’s per-instance and per-user fees back to the right project.