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
- You are building a live-streaming product — a creator platform, fitness app, auction site, town-hall broadcaster, or gaming feed — and want each channel as code rather than console clicks.
- You need every broadcast archived to S3 automatically for replay (VOD), legal hold, or content moderation, with no per-channel manual setup.
- You sell gated or paid streams and must restrict playback to authenticated viewers using signed JWTs against a playback key pair.
- You provision many channels at scale (one per creator, per event, or per tenant) and want consistent latency mode, recording behaviour, and tagging enforced by the module.
- You want a fast, single-region streaming path without standing up SRS/Wowza media servers, transcoders, or a CDN distribution yourself.
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 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/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
- Require encrypted ingest. Keep
insecure_ingest = falseso streamers must use RTMPS; plain RTMP exposes your stream key in transit. Treat the ingest endpoint and stream key as secrets — store them in SSM SecureString or Secrets Manager, never in the repo. - Match channel
typeto your audience to control cost. IVS bills on ingest + delivered hours and the transcode tier. UseBASICfor passthrough/internal feeds,STANDARDfor adaptive-bitrate consumer streams, and reserveADVANCED_HDfor premium content — don’t default everything to the most expensive tier. - Lock down the recording bucket and lifecycle it. Recordings accumulate fast; apply S3 lifecycle rules to transition older VOD to Glacier/IA and block public access on the bucket. Grant IVS write access via the service-managed permissions rather than a broad bucket policy.
- Gate paid content with a playback key pair. Set
authorized = trueand generate the ECDSA P-384 key pair out-of-band; commit only the public key to Terraform and keep the private signing key in your auth service. Rotate it by issuing a new key pair and retiring the old fingerprint. - Name and tag per tenant/event. A consistent
namescheme (e.g.creator-<id>-stage) plusTenant/Environmenttags makes channels traceable in cost reports and CloudWatch metrics when you are running hundreds of them. - Pin the module and provider. Reference the module by an immutable tag (
?ref=v1.0.0) and keepaws ~> 5.0pinned inversions.tfso a provider bump never silently changes channel or recording behaviour mid-stream.