IaC AWS

Terraform Module: AWS Amazon Connect — a contact-center instance with storage, hours, and a base contact flow wired up

Quick take — A reusable Terraform module for Amazon Connect: provisions an aws_connect_instance with directory + telephony options, S3/Kinesis storage configs for recordings and CTRs, hours of operation, and a starter contact flow. 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 "connect" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-connect?ref=v1.0.0"

  name_prefix            = "..."  # Prefix for the instance alias and child resource names …
  instance_alias         = "..."  # Globally unique Connect instance alias (validated, 1-45…
  recordings_bucket_name = "..."  # Existing S3 bucket for recordings and chat transcripts.
  storage_kms_key_arn    = "..."  # KMS key ARN encrypting recordings/transcripts (validate…
}

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

What this module is

Amazon Connect is AWS’s cloud contact-center service: a managed, omnichannel platform for voice, chat, and tasks that you provision and operate entirely through APIs instead of racking PBX hardware. The root of everything is the instance (aws_connect_instance) — a tenant boundary that owns your claimed phone numbers, queues, routing profiles, agents, contact flows, and the call/chat/metrics data the instance generates. Creating the instance is a single API call, but a usable instance is never just that one resource. You also have to decide how identity works (Connect-managed directory vs. SAML vs. existing AWS Directory Service), turn the right channels on, and — critically — tell Connect where to put data: call recordings and chat transcripts land in S3, and Contact Trace Records (CTRs) plus real-time agent events stream to Kinesis. Without those storage configs, recordings silently never persist.

This module wraps that whole bring-up into one opinionated unit. You pass an instance alias, choose an identity type, flip channels on, and hand it an S3 bucket and (optionally) a Kinesis stream; the module returns a ready instance with CALL_RECORDINGS and CHAT_TRANSCRIPTS writing to S3, CONTACT_TRACE_RECORDS streaming to Kinesis, a default hours of operation, and a starter inbound contact flow so the instance can actually answer a call on day one. Wrapping it as a module means every contact center is stood up the same way — recordings encrypted with your KMS key, CTR streaming always on for analytics, contact-flow logs enabled — instead of someone clicking through the Connect console and forgetting to attach storage until the first “where are our recordings?” incident.

When to use it

Module structure

terraform-module-aws-connect/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # instance, storage configs, hours, base contact flow
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # instance identifiers, ARNs, service-role, child IDs

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Connect instance alias is globally unique and DNS-like; lowercased here.
  instance_alias = lower("${var.name_prefix}-${var.instance_alias}")

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

# ---------------------------------------------------------------------------
# The Amazon Connect instance (tenant boundary for the contact center)
# ---------------------------------------------------------------------------
resource "aws_connect_instance" "this" {
  identity_management_type = var.identity_management_type
  instance_alias           = local.instance_alias

  # Directory ID is required only when using EXISTING_DIRECTORY.
  directory_id = var.identity_management_type == "EXISTING_DIRECTORY" ? var.directory_id : null

  inbound_calls_enabled    = var.inbound_calls_enabled
  outbound_calls_enabled   = var.outbound_calls_enabled
  contact_flow_logs_enabled = var.contact_flow_logs_enabled
  contact_lens_enabled     = var.contact_lens_enabled
  auto_resolve_best_voices_enabled = var.auto_resolve_best_voices_enabled
  early_media_enabled      = var.early_media_enabled
  multi_party_conference_enabled = var.multi_party_conference_enabled
}

# Note: aws_connect_instance does not accept tags directly; downstream
# resources and the surrounding stack carry local.tags instead.

# ---------------------------------------------------------------------------
# Storage: call recordings -> S3 (encrypted with KMS)
# ---------------------------------------------------------------------------
resource "aws_connect_instance_storage_config" "call_recordings" {
  instance_id   = aws_connect_instance.this.id
  resource_type = "CALL_RECORDINGS"

  storage_config {
    storage_type = "S3"

    s3_config {
      bucket_name   = var.recordings_bucket_name
      bucket_prefix = "${var.storage_prefix}/call-recordings"

      encryption_config {
        encryption_type = "KMS"
        key_id          = var.storage_kms_key_arn
      }
    }
  }
}

# ---------------------------------------------------------------------------
# Storage: chat transcripts -> S3 (same bucket, separate prefix)
# ---------------------------------------------------------------------------
resource "aws_connect_instance_storage_config" "chat_transcripts" {
  count = var.chat_transcripts_enabled ? 1 : 0

  instance_id   = aws_connect_instance.this.id
  resource_type = "CHAT_TRANSCRIPTS"

  storage_config {
    storage_type = "S3"

    s3_config {
      bucket_name   = var.recordings_bucket_name
      bucket_prefix = "${var.storage_prefix}/chat-transcripts"

      encryption_config {
        encryption_type = "KMS"
        key_id          = var.storage_kms_key_arn
      }
    }
  }
}

# ---------------------------------------------------------------------------
# Storage: Contact Trace Records (CTRs) -> Kinesis stream
# Drives analytics/data-lake pipelines; one record per contact leg.
# ---------------------------------------------------------------------------
resource "aws_connect_instance_storage_config" "contact_trace_records" {
  count = var.ctr_kinesis_stream_arn != null ? 1 : 0

  instance_id   = aws_connect_instance.this.id
  resource_type = "CONTACT_TRACE_RECORDS"

  storage_config {
    storage_type = "KINESIS_STREAM"

    kinesis_stream_config {
      stream_arn = var.ctr_kinesis_stream_arn
    }
  }
}

# ---------------------------------------------------------------------------
# Hours of operation (referenced by queues; required for a working queue)
# ---------------------------------------------------------------------------
resource "aws_connect_hours_of_operation" "default" {
  instance_id = aws_connect_instance.this.id
  name        = "${local.instance_alias}-default-hours"
  description = "Default business hours created by the Connect module."
  time_zone   = var.hours_time_zone

  dynamic "config" {
    for_each = var.hours_of_operation_days
    content {
      day = config.value

      start_time {
        hours   = var.hours_start_hour
        minutes = 0
      }

      end_time {
        hours   = var.hours_end_hour
        minutes = 0
      }
    }
  }

  tags = local.tags
}

# ---------------------------------------------------------------------------
# Starter inbound contact flow so the instance can answer a call on day one.
# content is the Connect flow JSON (Amazon Connect Flow Language).
# ---------------------------------------------------------------------------
resource "aws_connect_contact_flow" "inbound" {
  instance_id = aws_connect_instance.this.id
  name        = "${local.instance_alias}-inbound"
  description = "Starter inbound flow: play a prompt then disconnect."
  type        = "CONTACT_FLOW"

  content = jsonencode({
    Version     = "2019-10-30"
    StartAction = "play-greeting"
    Actions = [
      {
        Identifier = "play-greeting"
        Type       = "MessageParticipant"
        Parameters = {
          Text = var.welcome_prompt_text
        }
        Transitions = {
          NextAction = "disconnect"
          Errors     = []
          Conditions = []
        }
      },
      {
        Identifier  = "disconnect"
        Type        = "DisconnectParticipant"
        Parameters  = {}
        Transitions = {}
      }
    ]
  })

  tags = local.tags
}

variables.tf

variable "name_prefix" {
  description = "Prefix for the instance alias and child resource names (env or business unit)."
  type        = string
}

variable "instance_alias" {
  description = "Globally unique Connect instance alias (lowercased, DNS-like, no trailing dot)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,44}$", var.instance_alias))
    error_message = "instance_alias must be alphanumeric/hyphens, 1-45 chars, and not start with a hyphen."
  }
}

variable "identity_management_type" {
  description = "How agents authenticate: CONNECT_MANAGED, SAML, or EXISTING_DIRECTORY."
  type        = string
  default     = "CONNECT_MANAGED"

  validation {
    condition     = contains(["CONNECT_MANAGED", "SAML", "EXISTING_DIRECTORY"], var.identity_management_type)
    error_message = "identity_management_type must be CONNECT_MANAGED, SAML, or EXISTING_DIRECTORY."
  }
}

variable "directory_id" {
  description = "AWS Directory Service directory ID; required only when identity_management_type = EXISTING_DIRECTORY."
  type        = string
  default     = null
}

variable "inbound_calls_enabled" {
  description = "Allow inbound calls to the instance."
  type        = bool
  default     = true
}

variable "outbound_calls_enabled" {
  description = "Allow outbound calls from the instance."
  type        = bool
  default     = true
}

variable "contact_flow_logs_enabled" {
  description = "Stream contact-flow execution logs to CloudWatch Logs (recommended for debugging)."
  type        = bool
  default     = true
}

variable "contact_lens_enabled" {
  description = "Enable Contact Lens (conversational analytics, sentiment, transcription)."
  type        = bool
  default     = true
}

variable "auto_resolve_best_voices_enabled" {
  description = "Let Connect auto-pick the best Amazon Polly voice for text-to-speech."
  type        = bool
  default     = true
}

variable "early_media_enabled" {
  description = "Enable early media for outbound calls (hear ringback/announcements before connect)."
  type        = bool
  default     = true
}

variable "multi_party_conference_enabled" {
  description = "Allow multi-party (up to 6-leg) conference calls."
  type        = bool
  default     = false
}

# --- Storage --------------------------------------------------------------

variable "recordings_bucket_name" {
  description = "Existing S3 bucket name for call recordings and chat transcripts."
  type        = string
}

variable "storage_prefix" {
  description = "Key prefix inside the S3 bucket; recordings/transcripts are nested under it."
  type        = string
  default     = "connect"
}

variable "storage_kms_key_arn" {
  description = "KMS key ARN used to encrypt recordings and transcripts in S3."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-zA-Z-]*:kms:", var.storage_kms_key_arn))
    error_message = "storage_kms_key_arn must be a valid KMS key ARN."
  }
}

variable "chat_transcripts_enabled" {
  description = "Provision the CHAT_TRANSCRIPTS storage config (set false for voice-only instances)."
  type        = bool
  default     = true
}

variable "ctr_kinesis_stream_arn" {
  description = "Kinesis Data Stream ARN for Contact Trace Records; null disables CTR streaming."
  type        = string
  default     = null
}

# --- Hours of operation ---------------------------------------------------

variable "hours_time_zone" {
  description = "IANA time zone for the default hours of operation (e.g. Asia/Kolkata)."
  type        = string
  default     = "Asia/Kolkata"
}

variable "hours_of_operation_days" {
  description = "Days the default hours of operation cover."
  type        = list(string)
  default     = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"]

  validation {
    condition = alltrue([
      for d in var.hours_of_operation_days :
      contains(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"], d)
    ])
    error_message = "hours_of_operation_days entries must be uppercase weekday names."
  }
}

variable "hours_start_hour" {
  description = "Start hour (0-23, local to hours_time_zone) for the default hours."
  type        = number
  default     = 9

  validation {
    condition     = var.hours_start_hour >= 0 && var.hours_start_hour <= 23
    error_message = "hours_start_hour must be between 0 and 23."
  }
}

variable "hours_end_hour" {
  description = "End hour (0-23, local to hours_time_zone) for the default hours."
  type        = number
  default     = 18

  validation {
    condition     = var.hours_end_hour >= 0 && var.hours_end_hour <= 23
    error_message = "hours_end_hour must be between 0 and 23."
  }
}

# --- Contact flow ---------------------------------------------------------

variable "welcome_prompt_text" {
  description = "Text-to-speech greeting played by the starter inbound contact flow."
  type        = string
  default     = "Thank you for calling. Please hold while we connect you to an agent."
}

variable "tags" {
  description = "Tags merged onto taggable child resources (hours, contact flow)."
  type        = map(string)
  default     = {}
}

outputs.tf

output "instance_id" {
  description = "Amazon Connect instance ID (used by queues, routing profiles, users)."
  value       = aws_connect_instance.this.id
}

output "instance_arn" {
  description = "Amazon Connect instance ARN."
  value       = aws_connect_instance.this.arn
}

output "instance_alias" {
  description = "Resolved (lowercased, prefixed) instance alias."
  value       = aws_connect_instance.this.instance_alias
}

output "service_role_arn" {
  description = "ARN of the IAM service-linked role Connect created for the instance."
  value       = aws_connect_instance.this.service_role
}

output "call_recordings_storage_id" {
  description = "Association ID of the CALL_RECORDINGS storage config."
  value       = aws_connect_instance_storage_config.call_recordings.association_id
}

output "ctr_storage_id" {
  description = "Association ID of the CONTACT_TRACE_RECORDS storage config, or null if disabled."
  value       = try(aws_connect_instance_storage_config.contact_trace_records[0].association_id, null)
}

output "default_hours_of_operation_id" {
  description = "ID of the default hours of operation (attach to queues)."
  value       = aws_connect_hours_of_operation.default.hours_of_operation_id
}

output "default_hours_of_operation_arn" {
  description = "ARN of the default hours of operation."
  value       = aws_connect_hours_of_operation.default.arn
}

output "inbound_contact_flow_id" {
  description = "ID of the starter inbound contact flow (associate with a claimed phone number)."
  value       = aws_connect_contact_flow.inbound.contact_flow_id
}

output "inbound_contact_flow_arn" {
  description = "ARN of the starter inbound contact flow."
  value       = aws_connect_contact_flow.inbound.arn
}

How to use it

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

  name_prefix    = "prod"
  instance_alias = "support-center"

  identity_management_type = "CONNECT_MANAGED"

  inbound_calls_enabled  = true
  outbound_calls_enabled = true
  contact_lens_enabled   = true

  # Recordings + transcripts land in this bucket, encrypted with our CMK.
  recordings_bucket_name = aws_s3_bucket.connect_recordings.id
  storage_prefix         = "support-center"
  storage_kms_key_arn    = aws_kms_key.connect.arn

  # CTRs stream to Kinesis for the analytics data lake.
  ctr_kinesis_stream_arn = aws_kinesis_stream.connect_ctr.arn

  hours_time_zone  = "Asia/Kolkata"
  hours_start_hour = 8
  hours_end_hour   = 20

  welcome_prompt_text = "Thank you for calling KloudVin support. Please stay on the line."

  tags = {
    Environment = "prod"
    CostCenter  = "customer-success"
  }
}

# Downstream: claim a phone number and route it to the module's starter flow.
resource "aws_connect_phone_number" "support_did" {
  target_arn   = module.amazon_connect.instance_arn
  country_code = "IN"
  type         = "DID"

  tags = {
    Environment = "prod"
  }
}

# Downstream: build a real queue on the instance using the module's hours.
resource "aws_connect_queue" "tier1" {
  instance_id           = module.amazon_connect.instance_id
  name                  = "tier1-support"
  description           = "First-line support queue."
  hours_of_operation_id = module.amazon_connect.default_hours_of_operation_id
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name_prefix = "..."
  instance_alias = "..."
  recordings_bucket_name = "..."
  storage_kms_key_arn = "..."
}

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

cd live/prod/connect && 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 the instance alias and child resource names (env or business unit).
instance_alias string Yes Globally unique Connect instance alias (validated, 1-45 chars).
identity_management_type string CONNECT_MANAGED No CONNECT_MANAGED, SAML, or EXISTING_DIRECTORY (validated).
directory_id string null No Directory Service ID; required only for EXISTING_DIRECTORY.
inbound_calls_enabled bool true No Allow inbound calls to the instance.
outbound_calls_enabled bool true No Allow outbound calls from the instance.
contact_flow_logs_enabled bool true No Stream contact-flow logs to CloudWatch Logs.
contact_lens_enabled bool true No Enable Contact Lens analytics/transcription.
auto_resolve_best_voices_enabled bool true No Auto-select the best Amazon Polly voice for TTS.
early_media_enabled bool true No Enable early media on outbound calls.
multi_party_conference_enabled bool false No Allow multi-party conference calls.
recordings_bucket_name string Yes Existing S3 bucket for recordings and chat transcripts.
storage_prefix string connect No Key prefix inside the S3 bucket.
storage_kms_key_arn string Yes KMS key ARN encrypting recordings/transcripts (validated).
chat_transcripts_enabled bool true No Provision the CHAT_TRANSCRIPTS storage config.
ctr_kinesis_stream_arn string null No Kinesis stream ARN for CTRs; null disables CTR streaming.
hours_time_zone string Asia/Kolkata No IANA time zone for the default hours of operation.
hours_of_operation_days list(string) Mon–Fri No Days the default hours cover (validated weekday names).
hours_start_hour number 9 No Start hour 0-23, local to the time zone (validated).
hours_end_hour number 18 No End hour 0-23, local to the time zone (validated).
welcome_prompt_text string “Thank you for calling…” No TTS greeting for the starter inbound flow.
tags map(string) {} No Tags merged onto taggable child resources.

Outputs

Name Description
instance_id Amazon Connect instance ID (used by queues, routing profiles, users).
instance_arn Amazon Connect instance ARN (phone-number target).
instance_alias Resolved (lowercased, prefixed) instance alias.
service_role_arn ARN of the service-linked role Connect created for the instance.
call_recordings_storage_id Association ID of the CALL_RECORDINGS storage config.
ctr_storage_id Association ID of the CONTACT_TRACE_RECORDS storage config, or null when disabled.
default_hours_of_operation_id ID of the default hours of operation (attach to queues).
default_hours_of_operation_arn ARN of the default hours of operation.
inbound_contact_flow_id ID of the starter inbound contact flow.
inbound_contact_flow_arn ARN of the starter inbound contact flow.

Enterprise scenario

A retail bank runs separate Amazon Connect instances for collections, card-services, and general-support, each in its own AWS account behind Control Tower. Every team instantiates this module with its own alias, a dedicated KMS key, and a per-instance S3 prefix, so call recordings and chat transcripts are encrypted with keys the security team can rotate and audit independently, while CTRs from all three instances stream into per-account Kinesis streams that feed a central analytics data lake for handle-time and first-contact-resolution dashboards. Because Contact Lens and contact-flow logs are on by default, the QA team gets sentiment-scored transcripts and the platform team can debug a misrouted IVR from CloudWatch — all without anyone touching the Connect console. When the bank’s retention policy changes from 90 to 365 days, they adjust the S3 lifecycle rules on the buckets and re-tag a new module version, and every instance inherits it on the next apply.

Best practices

TerraformAWSAmazon ConnectModuleIaC
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