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
- You are standing up a net-new Amazon Connect contact center (customer support, collections, appointment lines) and want the instance, identity, channels, and data storage codified rather than click-opsed.
- You need recordings and chat transcripts persisted to S3 and CTRs/agent events streamed to Kinesis for QA, compliance retention, and downstream analytics (Athena, Contact Lens export, a data lake) from the very first call.
- You run multiple environments or brands (
dev,prod, separate business units) and want each Connect instance provisioned identically with per-instance KMS keys and storage prefixes. - You want the instance’s default contact flow and hours of operation to exist as code so a phone number can be claimed and attached immediately after apply.
- Reach for something else when: you only need to author routing logic on an existing instance (manage
aws_connect_contact_flow/aws_connect_queuedirectly, not the whole instance), you require a feature with no Terraform coverage yet such as Cases or detailed Contact Lens rules (do the instance here, layer those via the AWS SDK/console), or your org mandates SAML SSO — this module supportsSAMLidentity but the IdP federation andaws_connect_usermapping live outside 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 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/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
- Always encrypt recordings and transcripts with a customer-managed KMS key (
storage_kms_key_arn), not the default S3 SSE — contact recordings are sensitive PII/PCI data, and a CMK lets you scopekms:Decryptto QA roles only and prove key rotation in an audit. Pair it with an S3 bucket policy that denies unencrypted puts. - Turn CTR streaming on from day one via
ctr_kinesis_stream_arn. Connect does not backfill Contact Trace Records, so any contact that happens before the storage config exists is lost to your analytics pipeline forever — there is no replay. - Keep
contact_flow_logs_enabled = truein every environment. Contact-flow failures (a bad Lambda invoke, an unmatched routing condition) are nearly undebuggable without the CloudWatch flow logs, and the volume is trivial compared to the time saved during an IVR incident. - Pick the identity model deliberately and stick with it. Changing
identity_management_typeforces the instance to be replaced, which destroys queues, users, and contact flows; decideCONNECT_MANAGEDvs.SAMLvs.EXISTING_DIRECTORYbefore the first apply, and gate the prod instance withprevent_destroyin the consuming stack. - Mind the per-account Connect quotas and treat the instance as long-lived. Concurrent calls, instances per region, and phone numbers are soft-capped; right-size and raise limits ahead of launch rather than discovering them under peak load, and watch the
ConcurrentCallsPercentageCloudWatch metric so you scale before customers hit busy tones. - Name everything
${name_prefix}-${instance_alias}-*so the instance, its hours of operation, its contact flows, and the S3 prefixes line up acrossdev/prodaccounts — and only attach the storage bucket and Kinesis stream that belong to that same environment, never a shared prod bucket from a lower environment.