IaC AWS

Terraform Module: AWS Interactive Video (IVS) — managed live-streaming channels with recording in one call

Quick take — A reusable Terraform module for AWS IVS: an aws_ivs_channel with ingest/playback endpoints, optional S3 recording via aws_ivs_recording_configuration, and a playback key pair for private channels. 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 "ivs" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ivs?ref=v1.0.0"

  name = "..."  # Name of the IVS channel and prefix for companion resour…
}

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

What this module is

Amazon Interactive Video Service (IVS) is a fully managed live-streaming service built on the same technology that powers Twitch. You push an RTMPS feed at an ingest endpoint, and IVS transcodes, packages, and delivers it as low-latency HLS to a global audience through a single playback URL — no media servers, transcoding fleet, or CDN configuration to run yourself. A real-time IVS channel can hold latency under three seconds, glass-to-glass.

The core building block is the channel: it owns the ingest/playback endpoints, the latency mode, the transcode preset, and (optionally) authorization for private playback. On its own a channel is a handful of clicks, but a production deployment is never just the channel. You almost always need a recording configuration that archives every broadcast to S3 (for VOD, compliance, or moderation), and for paid or gated content you need a playback key pair so only viewers holding a signed JWT can watch. Wrapping all three in one Terraform module means every stream your platform creates is consistently recorded, tagged, and (where required) locked down — instead of someone forgetting to attach recording to the fifth channel they spin up.

This module provisions an aws_ivs_channel and wires in two of the most common production companions: an aws_ivs_recording_configuration (S3 archival with a configurable thumbnail interval) and an optional aws_ivs_playback_key_pair for private channels.

When to use it

If you only need WebRTC many-to-many conferencing, look at IVS real-time stages (aws_ivs_stage) instead — this module targets the standard/RTMPS broadcast channel.

Module structure

terraform-module-aws-ivs/
├── 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 {
  # Recording is enabled only when a destination bucket is supplied.
  recording_enabled = var.recording_s3_bucket_name != null

  common_tags = merge(
    {
      Name      = var.name
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-ivs"
    },
    var.tags,
  )
}

# ---------------------------------------------------------------------------
# Optional: archive every broadcast to an S3 bucket.
# ---------------------------------------------------------------------------
resource "aws_ivs_recording_configuration" "this" {
  count = local.recording_enabled ? 1 : 0

  name = "${var.name}-recording"

  destination_configuration {
    s3 {
      bucket_name = var.recording_s3_bucket_name
    }
  }

  # Capture thumbnails alongside the recording for VOD/preview use.
  thumbnail_configuration {
    recording_mode      = var.thumbnail_recording_mode
    target_interval_seconds = var.thumbnail_recording_mode == "INTERVAL" ? var.thumbnail_target_interval_seconds : null
  }

  tags = local.common_tags
}

# ---------------------------------------------------------------------------
# Optional: key pair used to verify signed playback JWTs on private channels.
# Supply an ECDSA public key (PEM) generated out-of-band; never the private key.
# ---------------------------------------------------------------------------
resource "aws_ivs_playback_key_pair" "this" {
  count = var.authorized && var.playback_public_key_pem != null ? 1 : 0

  name       = "${var.name}-playback-key"
  public_key = var.playback_public_key_pem

  tags = local.common_tags
}

# ---------------------------------------------------------------------------
# The IVS channel itself.
# ---------------------------------------------------------------------------
resource "aws_ivs_channel" "this" {
  name                          = var.name
  type                          = var.type
  latency_mode                  = var.latency_mode
  authorized                    = var.authorized
  insecure_ingest               = var.insecure_ingest
  recording_configuration_arn   = local.recording_enabled ? aws_ivs_recording_configuration.this[0].arn : null

  tags = local.common_tags
}

variables.tf

variable "name" {
  description = "Name of the IVS channel and the prefix for its companion resources."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-_]{1,128}$", var.name))
    error_message = "name must be 1-128 characters and contain only letters, numbers, hyphens, and underscores."
  }
}

variable "type" {
  description = "Channel type. BASIC (up to 480p/1.5 Mbps passthrough), STANDARD (transcoded ABR, audio-only rendition), or ADVANCED_SD/ADVANCED_HD (enhanced transcode tiers)."
  type        = string
  default     = "STANDARD"

  validation {
    condition     = contains(["BASIC", "STANDARD", "ADVANCED_SD", "ADVANCED_HD"], var.type)
    error_message = "type must be one of BASIC, STANDARD, ADVANCED_SD, or ADVANCED_HD."
  }
}

variable "latency_mode" {
  description = "Channel latency mode. LOW targets sub-3s glass-to-glass; NORMAL trades latency for a larger viewer buffer."
  type        = string
  default     = "LOW"

  validation {
    condition     = contains(["LOW", "NORMAL"], var.latency_mode)
    error_message = "latency_mode must be either LOW or NORMAL."
  }
}

variable "authorized" {
  description = "If true, the channel is private and playback requires a signed JWT verified against a playback key pair."
  type        = bool
  default     = false
}

variable "insecure_ingest" {
  description = "If true, allows insecure RTMP ingest in addition to RTMPS. Leave false to require encrypted ingest."
  type        = bool
  default     = false
}

variable "playback_public_key_pem" {
  description = "ECDSA public key (PEM, P-384) used to verify playback JWTs. Required only when authorized = true; supply the public key, never the private key."
  type        = string
  default     = null

  validation {
    condition     = var.playback_public_key_pem == null || can(regex("BEGIN PUBLIC KEY", var.playback_public_key_pem))
    error_message = "playback_public_key_pem must be a PEM-encoded public key containing a 'BEGIN PUBLIC KEY' header."
  }
}

variable "recording_s3_bucket_name" {
  description = "Name of an existing S3 bucket to archive broadcasts to. When null, recording is disabled and no recording configuration is created."
  type        = string
  default     = null
}

variable "thumbnail_recording_mode" {
  description = "Thumbnail capture mode for the recording configuration. INTERVAL captures on a fixed cadence; DISABLED captures none."
  type        = string
  default     = "INTERVAL"

  validation {
    condition     = contains(["INTERVAL", "DISABLED"], var.thumbnail_recording_mode)
    error_message = "thumbnail_recording_mode must be either INTERVAL or DISABLED."
  }
}

variable "thumbnail_target_interval_seconds" {
  description = "Seconds between captured thumbnails when thumbnail_recording_mode is INTERVAL (1-60)."
  type        = number
  default     = 60

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

variable "tags" {
  description = "Additional tags applied to the channel and its companion resources."
  type        = map(string)
  default     = {}
}

outputs.tf

output "channel_arn" {
  description = "ARN of the IVS channel."
  value       = aws_ivs_channel.this.arn
}

output "channel_id" {
  description = "ID of the IVS channel (same as its ARN)."
  value       = aws_ivs_channel.this.id
}

output "channel_name" {
  description = "Name of the IVS channel."
  value       = aws_ivs_channel.this.name
}

output "ingest_endpoint" {
  description = "RTMPS ingest endpoint to point the broadcaster (OBS, SDK) at."
  value       = aws_ivs_channel.this.ingest_endpoint
}

output "playback_url" {
  description = "HLS playback URL for viewers."
  value       = aws_ivs_channel.this.playback_url
}

output "recording_configuration_arn" {
  description = "ARN of the recording configuration, or null when recording is disabled."
  value       = local.recording_enabled ? aws_ivs_recording_configuration.this[0].arn : null
}

output "playback_key_pair_fingerprint" {
  description = "Fingerprint of the playback key pair (used to identify the kid in signed JWTs), or null when the channel is not authorized."
  value       = try(aws_ivs_playback_key_pair.this[0].fingerprint, null)
}

How to use it

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

  name         = "creator-main-stage"
  type         = "STANDARD"
  latency_mode = "LOW"

  # Archive every broadcast for VOD/replay.
  recording_s3_bucket_name          = aws_s3_bucket.ivs_recordings.id
  thumbnail_recording_mode          = "INTERVAL"
  thumbnail_target_interval_seconds = 30

  # Private channel: only viewers with a signed JWT can play it.
  authorized              = true
  playback_public_key_pem = file("${path.module}/keys/ivs-playback-public.pem")

  tags = {
    Environment = "production"
    Team        = "live-platform"
    Tenant      = "creator-0042"
  }
}

# Downstream: hand the ingest endpoint to broadcasters and the playback URL to
# the web/app front end via SSM Parameter Store.
resource "aws_ssm_parameter" "ingest_endpoint" {
  name  = "/live/creator-0042/ingest-endpoint"
  type  = "SecureString"
  value = module.interactive_video_ivs_creator_main.ingest_endpoint
}

resource "aws_ssm_parameter" "playback_url" {
  name  = "/live/creator-0042/playback-url"
  type  = "String"
  value = module.interactive_video_ivs_creator_main.playback_url
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
}

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

cd live/prod/ivs && 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 string Yes Name of the IVS channel and prefix for companion resources (1-128 chars, [a-zA-Z0-9-_]).
type string "STANDARD" No Channel type: BASIC, STANDARD, ADVANCED_SD, or ADVANCED_HD.
latency_mode string "LOW" No LOW (sub-3s) or NORMAL (larger viewer buffer).
authorized bool false No When true, playback requires a signed JWT (private channel).
insecure_ingest bool false No Allow plain RTMP ingest alongside RTMPS. Keep false to require encryption.
playback_public_key_pem string null No PEM ECDSA (P-384) public key to verify playback JWTs. Needed when authorized = true.
recording_s3_bucket_name string null No Existing S3 bucket to archive broadcasts to. null disables recording.
thumbnail_recording_mode string "INTERVAL" No INTERVAL or DISABLED thumbnail capture for the recording config.
thumbnail_target_interval_seconds number 60 No Seconds between thumbnails when mode is INTERVAL (1-60).
tags map(string) {} No Extra tags merged onto the channel and companion resources.

Outputs

Name Description
channel_arn ARN of the IVS channel.
channel_id ID of the IVS channel (equal to its ARN).
channel_name Name of the IVS channel.
ingest_endpoint RTMPS ingest endpoint for the broadcaster.
playback_url HLS playback URL for viewers.
recording_configuration_arn ARN of the recording configuration, or null when recording is disabled.
playback_key_pair_fingerprint Fingerprint of the playback key pair (JWT kid), or null when not authorized.

Enterprise scenario

A subscription fitness platform runs one private IVS channel per live class. Their booking service calls this module (via a thin Terraform/Terragrunt wrapper) to create a STANDARD, LOW-latency, authorized channel for each scheduled session; the recording configuration archives the full class to a per-tenant S3 prefix so members who missed it can stream the VOD the next day. Because authorized = true and a playback key pair is attached, the front end mints a short-lived JWT only for members with an active subscription — anyone scraping the playback URL gets a 403. The platform team gets consistent recording, tagging, and access control on hundreds of channels without ever touching the console.

Best practices

TerraformAWSInteractive Video (IVS)ModuleIaC
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