Skip to content
IaC Templates

Live RTMP/SRT ingest and restream

Template · Updated Jul 2026

Live RTMP/SRT ingest and restream

This pattern composes Compute, Network, and Object Storage.

What this template deploys#

  • Private network and router for the ingest instance
  • One ingest VM with cloud-init that installs NGINX-RTMP (default) or SRS
  • 1 floating IP on the ingest port for public RTMP (1935/tcp) and SRT (configurable UDP) pushes
  • Security group rules scoped to encoder_cidr for ingest and admin_cidr for SSH
  • Optional Object Storage bucket for HLS replay when enable_hls_recording is true

Point OBS or a hardware encoder at the rtmp_publish_url output. The instance restreams to each URL in restream_targets.

Prerequisites#

  • OpenTofu or Terraform >= 1.6.0
  • OpenStack application credentials (source openrc.sh)
  • One floating IP available in your project quota
  • EC2-compatible Object Storage credentials when enabling HLS replay. See S3 storage ACL template.

Parameters#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist in your project)required
ingest_servernginx-rtmp or srsnginx-rtmp
ingest_flavorCPU flavor for ingest/restream workc2a.large
encoder_cidrCIDR allowed to push RTMP/SRT0.0.0.0/0
admin_cidrCIDR allowed for SSH0.0.0.0/0
application_nameRTMP application namelive
stream_keyOptional publish key""
restream_targetsDownstream RTMP push URLs[]
enable_hls_recordingUpload HLS segments to Object Storagefalse
hls_bucket_nameReplay bucket (when recording enabled)""
s3_access_key / s3_secret_keyEC2-compat credentials for replay uploadrequired when recording
image_nameBoot image nameUbuntu-24.04
external_networkShared external network for router gateway and floating IPPublicStatic
private_cidrPrivate subnet CIDR for the ingest instance192.168.75.0/24
instance_nameIngest instance display namelive-ingest
srt_portUDP port for SRT ingest when using srs10080
s3_endpointQuake AI S3-compatible endpoint URLhttps://object.us-east-1.rumble.cloud
s3_regionS3 region identifier for the AWS providerus-east-1

Cost and sizing#

Size ingest_flavor for the number of simultaneous restream targets and any CPU transcoding you enable. The template allocates exactly one floating IP. Object Storage charges follow your plan allowance for stored replay segments.

When to use this pattern#

Accept a single RTMP or SRT push from OBS or a hardware encoder and fan out to YouTube, Twitch, or custom RTMP endpoints without a managed live-video SaaS. For batch audio processing, see the audio post-production worker template.

Estimated cost#

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on dedicated vCPU.

Starting template$60.20/mo

Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.

What each resource is for

Ingest

c2a.large · 2 dedicated vCPU, 4 GiB RAM, 0.5 Gbps

$62.00/mo

Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.

Included in baseline

c2a.large

2 dedicated vCPU, 4 GiB RAM, 0.5 Gbps

$62.00

Compute + RAM rate basis

2 vCPU + 4 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.

—

Block storage (40 GiB)

40 GiB at $0.08/GiB/mo

$3.20

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

Included at no charge

These line items are zero on Quake AI. Many other providers meter them separately.

Data transfer (inbound and outbound)

Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.

AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.

Learn more
$0.00

Private networking

Private networks, subnets, Neutron routers, and security groups are included with the plan.

VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.

$0.00

Control-plane API requests

OpenStack API calls for provisioning and management are included.

Some managed services on other clouds meter API calls or charge for premium control-plane features.

$0.00

Dev/test vs production

Start on shared CPU for dev/test, then promote to dedicated for production with a flavor resize. The network, storage, and template stay the same.

Dev/test on shared CPU

Burstable s1a flavors; suited to prototyping and low or bursty load.

$14.70/mo

Production on dedicated CPU

The headline estimate above; predictable steady-load performance.

$60.20/mo

Saves $45.50/mo while you build on shared CPU.

Shared flavors carry less RAM (c2a.large (4 GiB RAM) -> s1a.small (2 GiB RAM)). A resize reboots the instance; data on attached volumes persists. Size the dedicated flavor for the RAM your production workload needs.

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

7 files. Download the zip or expand to copy any file.Download live-ingest-restream.zip
Show source (7 files)
main.tfHCL
locals {
  restream_targets_lines = length(var.restream_targets) == 0 ? "# no restream targets configured" : join("\n", var.restream_targets)
}

data "openstack_images_image_v2" "os" {
  name        = var.image_name
  most_recent = true
}

data "openstack_networking_network_v2" "external" {
  name = var.external_network
}

resource "aws_s3_bucket" "hls" {
  count  = var.enable_hls_recording ? 1 : 0
  bucket = var.hls_bucket_name
}

resource "aws_s3_bucket_acl" "hls" {
  count  = var.enable_hls_recording ? 1 : 0
  bucket = aws_s3_bucket.hls[0].id
  acl    = "private"
}


resource "openstack_networking_network_v2" "private" {
  name           = "${var.instance_name}-private"
  admin_state_up = true
}

resource "openstack_networking_subnet_v2" "private" {
  name            = "${var.instance_name}-private-sn"
  network_id      = openstack_networking_network_v2.private.id
  cidr            = var.private_cidr
  ip_version      = 4
  enable_dhcp     = true
  dns_nameservers = ["8.8.8.8", "8.8.4.4"]
}

resource "openstack_networking_router_v2" "router" {
  name                = "${var.instance_name}-router"
  external_network_id = data.openstack_networking_network_v2.external.id
  admin_state_up      = true
}

resource "openstack_networking_router_interface_v2" "private" {
  router_id = openstack_networking_router_v2.router.id
  subnet_id = openstack_networking_subnet_v2.private.id
}

resource "openstack_networking_secgroup_v2" "ingest" {
  name        = "${var.instance_name}-sg"
  description = "RTMP/SRT ingest from encoder CIDR; SSH from admin CIDR"
}

resource "openstack_networking_secgroup_rule_v2" "ssh" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_ip_prefix  = var.admin_cidr
  security_group_id = openstack_networking_secgroup_v2.ingest.id
}

resource "openstack_networking_secgroup_rule_v2" "rtmp" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 1935
  port_range_max    = 1935
  remote_ip_prefix  = var.encoder_cidr
  security_group_id = openstack_networking_secgroup_v2.ingest.id
}

resource "openstack_networking_secgroup_rule_v2" "srt" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "udp"
  port_range_min    = var.srt_port
  port_range_max    = var.srt_port
  remote_ip_prefix  = var.encoder_cidr
  security_group_id = openstack_networking_secgroup_v2.ingest.id
}

resource "openstack_networking_port_v2" "ingest" {
  name               = "${var.instance_name}-port"
  network_id         = openstack_networking_network_v2.private.id
  security_group_ids = [openstack_networking_secgroup_v2.ingest.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.private.id
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_compute_instance_v2" "ingest" {
  name        = var.instance_name
  flavor_name = var.ingest_flavor
  key_pair    = var.key_name

  user_data = templatefile("${path.module}/cloud-init/ingest.yaml", {
    ingest_server          = var.ingest_server
    application_name       = var.application_name
    stream_key             = var.stream_key
    restream_targets_lines = local.restream_targets_lines
    enable_hls_recording   = var.enable_hls_recording ? "true" : "false"
    s3_endpoint            = var.s3_endpoint
    s3_region              = var.s3_region
    s3_access_key          = var.s3_access_key
    s3_secret_key          = var.s3_secret_key
    hls_bucket             = var.enable_hls_recording ? aws_s3_bucket.hls[0].id : ""
    srt_port               = var.srt_port
  })

  block_device {
    uuid                  = data.openstack_images_image_v2.os.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = 40
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.ingest.id
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_networking_floatingip_v2" "ingest" {
  pool = var.external_network
}

resource "openstack_networking_floatingip_associate_v2" "ingest" {
  floating_ip = openstack_networking_floatingip_v2.ingest.address
  port_id     = openstack_networking_port_v2.ingest.id
}
variables.tfHCL
variable "key_name" {
  description = "Existing SSH keypair name in the project for compute instances"
  type        = string
}

variable "ingest_flavor" {
  description = "Compute flavor for the ingest VM (CPU-only; size for concurrent FFmpeg restream jobs)"
  type        = string
  default     = "c2a.large"
}

variable "image_name" {
  description = "Boot image name"
  type        = string
  default     = "Ubuntu-24.04"
}

variable "external_network" {
  description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
  type        = string
  default     = "PublicStatic"
}

variable "private_cidr" {
  description = "Private subnet CIDR for the ingest instance"
  type        = string
  default     = "192.168.75.0/24"
}

variable "instance_name" {
  description = "Ingest instance display name"
  type        = string
  default     = "live-ingest"
}

variable "ingest_server" {
  description = "Ingest software to install: nginx-rtmp or srs"
  type        = string
  default     = "nginx-rtmp"

  validation {
    condition     = contains(["nginx-rtmp", "srs"], var.ingest_server)
    error_message = "ingest_server must be nginx-rtmp or srs."
  }
}

variable "encoder_cidr" {
  description = "Source CIDR allowed to push RTMP (1935/tcp) and SRT (srt_port/udp). Restrict to your encoder or studio egress IP."
  type        = string
  default     = "0.0.0.0/0"
}

variable "admin_cidr" {
  description = "Source CIDR allowed for SSH (22/tcp) administration"
  type        = string
  default     = "0.0.0.0/0"
}

variable "srt_port" {
  description = "UDP port opened for SRT ingest when using srs"
  type        = number
  default     = 10080
}

variable "application_name" {
  description = "RTMP application name encoders push to (for example live)"
  type        = string
  default     = "live"
}

variable "stream_key" {
  description = "RTMP stream key encoders must publish (empty accepts any key on nginx-rtmp)"
  type        = string
  default     = ""
  sensitive   = true
}

variable "restream_targets" {
  description = "Downstream RTMP push URLs (one per line in cloud-init config). Use pass-through copy where possible."
  type        = list(string)
  default     = []
}

variable "enable_hls_recording" {
  description = "When true, record HLS segments locally and upload to Object Storage"
  type        = bool
  default     = false
}

variable "hls_bucket_name" {
  description = "Object Storage bucket for HLS replay segments (required when enable_hls_recording is true)"
  type        = string
  default     = ""
}

variable "s3_access_key" {
  description = "EC2-compatible access key for Object Storage (required when enable_hls_recording is true)"
  type        = string
  default     = ""
  sensitive   = true
}

variable "s3_secret_key" {
  description = "EC2-compatible secret key paired with s3_access_key"
  type        = string
  default     = ""
  sensitive   = true
}

variable "s3_endpoint" {
  description = "Quake AI S3-compatible endpoint URL"
  type        = string
  default     = "https://object.us-east-1.rumble.cloud"
}

variable "s3_region" {
  description = "S3 region identifier passed to the AWS provider"
  type        = string
  default     = "us-east-1"
}
outputs.tfHCL
output "ingest_instance_id" {
  description = "Nova instance ID for the ingest server"
  value       = openstack_compute_instance_v2.ingest.id
}

output "ingest_floating_ip" {
  description = "Public floating IP encoders push RTMP/SRT to"
  value       = openstack_networking_floatingip_v2.ingest.address
}

output "rtmp_publish_url" {
  description = "RTMP URL for OBS or hardware encoders (append stream key if configured)"
  value       = "rtmp://${openstack_networking_floatingip_v2.ingest.address}/${var.application_name}"
}

output "hls_bucket" {
  description = "Object Storage bucket for HLS replay (empty when recording is disabled)"
  value       = var.enable_hls_recording ? aws_s3_bucket.hls[0].id : ""
}

output "floating_ip_count" {
  description = "Floating IPs this template allocates (one public ingest endpoint)"
  value       = 1
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "openstack" {}

provider "aws" {
  region                      = var.s3_region
  access_key                  = var.s3_access_key
  secret_key                  = var.s3_secret_key
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = var.s3_endpoint
  }
}
terraform.tfvars.exampleHCL
# Required
key_name = "YOUR_KEY_NAME"

# ingest_server = "nginx-rtmp"
# ingest_flavor = "c2a.large"
# application_name = "live"
# stream_key = "your-secret-stream-key"
# encoder_cidr = "203.0.113.10/32"
# admin_cidr = "203.0.113.10/32"
# restream_targets = [
#   "rtmp://a.rtmp.youtube.com/live2/YOUR_YOUTUBE_KEY",
#   "rtmp://live.twitch.tv/app/YOUR_TWITCH_KEY",
# ]

# EC2-compatible credentials from: openstack ec2 credentials create
# Required for tofu plan even when HLS recording is disabled (AWS provider init).
# s3_access_key = "YOUR_EC2_ACCESS_KEY"
# s3_secret_key = "YOUR_EC2_SECRET_KEY"

# Optional HLS replay to Object Storage
# enable_hls_recording = true
# hls_bucket_name = "my-live-hls-replay"
cloud-init/ingest.yamlYAML
#cloud-config
package_update: true
package_upgrade: true

packages:
  - ffmpeg

write_files:
  - path: /etc/live-ingest/restream-targets.txt
    permissions: "0644"
    content: |
      ${restream_targets_lines}

  - path: /etc/live-ingest/env
    permissions: "0600"
    content: |
      INGEST_SERVER=${ingest_server}
      APPLICATION_NAME=${application_name}
      STREAM_KEY=${stream_key}
      ENABLE_HLS_RECORDING=${enable_hls_recording}
      S3_ENDPOINT=${s3_endpoint}
      S3_REGION=${s3_region}
      AWS_ACCESS_KEY_ID=${s3_access_key}
      AWS_SECRET_ACCESS_KEY=${s3_secret_key}
      HLS_BUCKET=${hls_bucket}
      SRT_PORT=${srt_port}

runcmd:
  - |
    set -eu
    apt-get install -y python3-pip
    pip3 install --break-system-packages awscli
    . /etc/live-ingest/env
    mkdir -p /var/lib/live-ingest/hls

    if [ "$INGEST_SERVER" = "srs" ]; then
      apt-get install -y docker.io
      systemctl enable --now docker
      docker run -d --name srs --restart unless-stopped \
        -p 1935:1935 -p "$SRT_PORT:$SRT_PORT/udp" \
        -v /var/lib/live-ingest/hls:/usr/local/srs/objs/nginx/html \
        ossrs/srs:5
    else
      apt-get install -y nginx libnginx-mod-rtmp
      PUSH_LINES=""
      while IFS= read -r target; do
        case "$target" in ""|\#*) continue ;; esac
        PUSH_LINES="$${PUSH_LINES}        push $${target};"$'\n'
      done < /etc/live-ingest/restream-targets.txt
      cat > /etc/nginx/nginx.conf <<NGINX
    load_module modules/ngx_rtmp_module.so;
    worker_processes auto;
    events { worker_connections 1024; }
    rtmp {
      server {
        listen 1935;
        chunk_size 4096;
        application $${APPLICATION_NAME} {
          live on;
          record off;
    $${PUSH_LINES}    }
      }
    }
    http {
      server {
        listen 8080;
        location /hls { root /var/lib/live-ingest; types { application/vnd.apple.mpegurl m3u8; } }
      }
    }
    NGINX
      systemctl enable nginx
      systemctl restart nginx
    fi
README.mdMarkdown
# Live RTMP/SRT ingest and restream

CPU ingest VM with one floating IP for encoder pushes, optional downstream RTMP restream targets, and optional HLS segment upload to Object Storage.


**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.

## Prerequisites

- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- One available floating IP quota slot
- EC2-compatible Object Storage credentials when `enable_hls_recording` is true

## Usage

1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`

## Variables

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | Existing SSH keypair name in the project for compute instances |
| `ingest_flavor` | string | no | `c2a.large` | CPU flavor for the ingest VM |
| `ingest_server` | string | no | `nginx-rtmp` | `nginx-rtmp` or `srs` |
| `encoder_cidr` | string | no | `0.0.0.0/0` | CIDR allowed to push RTMP/SRT |
| `admin_cidr` | string | no | `0.0.0.0/0` | CIDR allowed for SSH |
| `restream_targets` | list(string) | no | `[]` | Downstream RTMP push URLs |
| `enable_hls_recording` | bool | no | `false` | Upload HLS segments to Object Storage |
| `hls_bucket_name` | string | when recording | `""` | Replay bucket name |

## Documentation

Full documentation: [Live ingest and restream template](/docs/automation/templates/live-ingest-restream)

Validated variants of this template, each adding one capability the base does not include.

  • Object Storagelive-ingest-restream-archive

    Dedicated Object Storage archive bucket for long-term recordings

    Show source and download
    7 files. Download the zip or copy any file.Download live-ingest-restream-archive.zip
    main.tfHCL
    locals {
      restream_targets_lines = length(var.restream_targets) == 0 ? "# no restream targets configured" : join("\n", var.restream_targets)
    }
    
    data "openstack_images_image_v2" "os" {
      name        = var.image_name
      most_recent = true
    }
    
    data "openstack_networking_network_v2" "external" {
      name = var.external_network
    }
    
    resource "aws_s3_bucket" "hls" {
      count  = var.enable_hls_recording ? 1 : 0
      bucket = var.hls_bucket_name
    }
    
    resource "aws_s3_bucket_acl" "hls" {
      count  = var.enable_hls_recording ? 1 : 0
      bucket = aws_s3_bucket.hls[0].id
      acl    = "private"
    }
    
    resource "aws_s3_bucket" "archive" {
      bucket = var.archive_bucket_name
    }
    
    resource "aws_s3_bucket_acl" "archive" {
      bucket = aws_s3_bucket.archive.id
      acl    = "private"
    }
    
    
    resource "openstack_networking_network_v2" "private" {
      name           = "${var.instance_name}-private"
      admin_state_up = true
    }
    
    resource "openstack_networking_subnet_v2" "private" {
      name            = "${var.instance_name}-private-sn"
      network_id      = openstack_networking_network_v2.private.id
      cidr            = var.private_cidr
      ip_version      = 4
      enable_dhcp     = true
      dns_nameservers = ["8.8.8.8", "8.8.4.4"]
    }
    
    resource "openstack_networking_router_v2" "router" {
      name                = "${var.instance_name}-router"
      external_network_id = data.openstack_networking_network_v2.external.id
      admin_state_up      = true
    }
    
    resource "openstack_networking_router_interface_v2" "private" {
      router_id = openstack_networking_router_v2.router.id
      subnet_id = openstack_networking_subnet_v2.private.id
    }
    
    resource "openstack_networking_secgroup_v2" "ingest" {
      name        = "${var.instance_name}-sg"
      description = "RTMP/SRT ingest from encoder CIDR; SSH from admin CIDR"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "ssh" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 22
      port_range_max    = 22
      remote_ip_prefix  = var.admin_cidr
      security_group_id = openstack_networking_secgroup_v2.ingest.id
    }
    
    resource "openstack_networking_secgroup_rule_v2" "rtmp" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 1935
      port_range_max    = 1935
      remote_ip_prefix  = var.encoder_cidr
      security_group_id = openstack_networking_secgroup_v2.ingest.id
    }
    
    resource "openstack_networking_secgroup_rule_v2" "srt" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "udp"
      port_range_min    = var.srt_port
      port_range_max    = var.srt_port
      remote_ip_prefix  = var.encoder_cidr
      security_group_id = openstack_networking_secgroup_v2.ingest.id
    }
    
    resource "openstack_networking_port_v2" "ingest" {
      name               = "${var.instance_name}-port"
      network_id         = openstack_networking_network_v2.private.id
      security_group_ids = [openstack_networking_secgroup_v2.ingest.id]
    
      fixed_ip {
        subnet_id = openstack_networking_subnet_v2.private.id
      }
    
      depends_on = [openstack_networking_router_interface_v2.private]
    }
    
    resource "openstack_compute_instance_v2" "ingest" {
      name        = var.instance_name
      flavor_name = var.ingest_flavor
      key_pair    = var.key_name
    
      user_data = templatefile("${path.module}/cloud-init/ingest.yaml", {
        ingest_server          = var.ingest_server
        application_name       = var.application_name
        stream_key             = var.stream_key
        restream_targets_lines = local.restream_targets_lines
        enable_hls_recording   = var.enable_hls_recording ? "true" : "false"
        s3_endpoint            = var.s3_endpoint
        s3_region              = var.s3_region
        s3_access_key          = var.s3_access_key
        s3_secret_key          = var.s3_secret_key
        hls_bucket             = var.enable_hls_recording ? aws_s3_bucket.hls[0].id : ""
        archive_bucket         = aws_s3_bucket.archive.id
        srt_port               = var.srt_port
      })
    
      block_device {
        uuid                  = data.openstack_images_image_v2.os.id
        source_type           = "image"
        destination_type      = "volume"
        volume_size           = 40
        boot_index            = 0
        delete_on_termination = true
      }
    
      network {
        port = openstack_networking_port_v2.ingest.id
      }
    
      depends_on = [openstack_networking_router_interface_v2.private]
    }
    
    resource "openstack_networking_floatingip_v2" "ingest" {
      pool = var.external_network
    }
    
    resource "openstack_networking_floatingip_associate_v2" "ingest" {
      floating_ip = openstack_networking_floatingip_v2.ingest.address
      port_id     = openstack_networking_port_v2.ingest.id
    }
    
    variables.tfHCL
    variable "key_name" {
      description = "Existing SSH keypair name in the project for compute instances"
      type        = string
    }
    
    variable "ingest_flavor" {
      description = "Compute flavor for the ingest VM (CPU-only; size for concurrent FFmpeg restream jobs)"
      type        = string
      default     = "c2a.large"
    }
    
    variable "image_name" {
      description = "Boot image name"
      type        = string
      default     = "Ubuntu-24.04"
    }
    
    variable "external_network" {
      description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
      type        = string
      default     = "PublicStatic"
    }
    
    variable "private_cidr" {
      description = "Private subnet CIDR for the ingest instance"
      type        = string
      default     = "192.168.75.0/24"
    }
    
    variable "instance_name" {
      description = "Ingest instance display name"
      type        = string
      default     = "live-ingest"
    }
    
    variable "ingest_server" {
      description = "Ingest software to install: nginx-rtmp or srs"
      type        = string
      default     = "nginx-rtmp"
    
      validation {
        condition     = contains(["nginx-rtmp", "srs"], var.ingest_server)
        error_message = "ingest_server must be nginx-rtmp or srs."
      }
    }
    
    variable "encoder_cidr" {
      description = "Source CIDR allowed to push RTMP (1935/tcp) and SRT (srt_port/udp). Restrict to your encoder or studio egress IP."
      type        = string
      default     = "0.0.0.0/0"
    }
    
    variable "admin_cidr" {
      description = "Source CIDR allowed for SSH (22/tcp) administration"
      type        = string
      default     = "0.0.0.0/0"
    }
    
    variable "srt_port" {
      description = "UDP port opened for SRT ingest when using srs"
      type        = number
      default     = 10080
    }
    
    variable "application_name" {
      description = "RTMP application name encoders push to (for example live)"
      type        = string
      default     = "live"
    }
    
    variable "stream_key" {
      description = "RTMP stream key encoders must publish (empty accepts any key on nginx-rtmp)"
      type        = string
      default     = ""
      sensitive   = true
    }
    
    variable "restream_targets" {
      description = "Downstream RTMP push URLs (one per line in cloud-init config). Use pass-through copy where possible."
      type        = list(string)
      default     = []
    }
    
    variable "enable_hls_recording" {
      description = "When true, record HLS segments locally and upload to Object Storage"
      type        = bool
      default     = false
    }
    
    variable "hls_bucket_name" {
      description = "Object Storage bucket for HLS replay segments (required when enable_hls_recording is true)"
      type        = string
      default     = ""
    }
    
    variable "archive_bucket_name" {
      description = "Object Storage bucket for long-term stream recording archive"
      type        = string
    }
    
    variable "s3_access_key" {
      description = "EC2-compatible access key for Object Storage (required when enable_hls_recording is true)"
      type        = string
      default     = ""
      sensitive   = true
    }
    
    variable "s3_secret_key" {
      description = "EC2-compatible secret key paired with s3_access_key"
      type        = string
      default     = ""
      sensitive   = true
    }
    
    variable "s3_endpoint" {
      description = "Quake AI S3-compatible endpoint URL"
      type        = string
      default     = "https://object.us-east-1.rumble.cloud"
    }
    
    variable "s3_region" {
      description = "S3 region identifier passed to the AWS provider"
      type        = string
      default     = "us-east-1"
    }
    
    outputs.tfHCL
    output "ingest_instance_id" {
      description = "Nova instance ID for the ingest server"
      value       = openstack_compute_instance_v2.ingest.id
    }
    
    output "ingest_floating_ip" {
      description = "Public floating IP encoders push RTMP/SRT to"
      value       = openstack_networking_floatingip_v2.ingest.address
    }
    
    output "rtmp_publish_url" {
      description = "RTMP URL for OBS or hardware encoders (append stream key if configured)"
      value       = "rtmp://${openstack_networking_floatingip_v2.ingest.address}/${var.application_name}"
    }
    
    output "hls_bucket" {
      description = "Object Storage bucket for HLS replay (empty when recording is disabled)"
      value       = var.enable_hls_recording ? aws_s3_bucket.hls[0].id : ""
    }
    
    output "floating_ip_count" {
      description = "Floating IPs this template allocates (one public ingest endpoint)"
      value       = 1
    }
    
    versions.tfHCL
    terraform {
      required_version = ">= 1.6.0"
    
      required_providers {
        openstack = {
          source  = "terraform-provider-openstack/openstack"
          version = "~> 2.0"
        }
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.0"
        }
      }
    }
    
    provider "openstack" {}
    
    provider "aws" {
      region                      = var.s3_region
      access_key                  = var.s3_access_key
      secret_key                  = var.s3_secret_key
      skip_credentials_validation = true
      skip_metadata_api_check     = true
      skip_requesting_account_id  = true
    
      endpoints {
        s3 = var.s3_endpoint
      }
    }
    
    terraform.tfvars.exampleHCL
    # Required
    key_name = "YOUR_KEY_NAME"
    
    # ingest_server = "nginx-rtmp"
    # ingest_flavor = "c2a.large"
    # application_name = "live"
    # stream_key = "your-secret-stream-key"
    # encoder_cidr = "203.0.113.10/32"
    # admin_cidr = "203.0.113.10/32"
    # restream_targets = [
    #   "rtmp://a.rtmp.youtube.com/live2/YOUR_YOUTUBE_KEY",
    #   "rtmp://live.twitch.tv/app/YOUR_TWITCH_KEY",
    # ]
    
    # EC2-compatible credentials from: openstack ec2 credentials create
    # Required for tofu plan even when HLS recording is disabled (AWS provider init).
    # s3_access_key = "YOUR_EC2_ACCESS_KEY"
    # s3_secret_key = "YOUR_EC2_SECRET_KEY"
    
    # Optional HLS replay to Object Storage
    # enable_hls_recording = true
    # hls_bucket_name = "my-live-hls-replay"
    
    cloud-init/ingest.yamlYAML
    #cloud-config
    package_update: true
    package_upgrade: true
    
    packages:
      - ffmpeg
    
    write_files:
      - path: /etc/live-ingest/restream-targets.txt
        permissions: "0644"
        content: |
          ${restream_targets_lines}
    
      - path: /etc/live-ingest/env
        permissions: "0600"
        content: |
          INGEST_SERVER=${ingest_server}
          APPLICATION_NAME=${application_name}
          STREAM_KEY=${stream_key}
          ENABLE_HLS_RECORDING=${enable_hls_recording}
          S3_ENDPOINT=${s3_endpoint}
          S3_REGION=${s3_region}
          AWS_ACCESS_KEY_ID=${s3_access_key}
          AWS_SECRET_ACCESS_KEY=${s3_secret_key}
          HLS_BUCKET=${hls_bucket}
          ARCHIVE_BUCKET=${archive_bucket}
          SRT_PORT=${srt_port}
    
    runcmd:
      - |
        set -eu
        apt-get install -y python3-pip
        pip3 install --break-system-packages awscli
        . /etc/live-ingest/env
        mkdir -p /var/lib/live-ingest/hls
    
        if [ "$INGEST_SERVER" = "srs" ]; then
          apt-get install -y docker.io
          systemctl enable --now docker
          docker run -d --name srs --restart unless-stopped \
            -p 1935:1935 -p "$SRT_PORT:$SRT_PORT/udp" \
            -v /var/lib/live-ingest/hls:/usr/local/srs/objs/nginx/html \
            ossrs/srs:5
        else
          apt-get install -y nginx libnginx-mod-rtmp
          PUSH_LINES=""
          while IFS= read -r target; do
            case "$target" in ""|\#*) continue ;; esac
            PUSH_LINES="$${PUSH_LINES}        push $${target};"$'\n'
          done < /etc/live-ingest/restream-targets.txt
          cat > /etc/nginx/nginx.conf <<NGINX
        load_module modules/ngx_rtmp_module.so;
        worker_processes auto;
        events { worker_connections 1024; }
        rtmp {
          server {
            listen 1935;
            chunk_size 4096;
            application $${APPLICATION_NAME} {
              live on;
              record off;
        $${PUSH_LINES}    }
          }
        }
        http {
          server {
            listen 8080;
            location /hls { root /var/lib/live-ingest; types { application/vnd.apple.mpegurl m3u8; } }
          }
        }
        NGINX
          systemctl enable nginx
          systemctl restart nginx
        fi
    
    README.mdMarkdown
    # Live RTMP/SRT ingest and restream
    
    CPU ingest VM with one floating IP for encoder pushes, optional downstream RTMP restream targets, and optional HLS segment upload to Object Storage.
    
    
    **Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.
    
    ## Prerequisites
    
    - OpenTofu >= 1.6.0 or Terraform >= 1.6.0
    - Quake AI account with OpenStack credentials
    - One available floating IP quota slot
    - EC2-compatible Object Storage credentials when `enable_hls_recording` is true
    
    ## Usage
    
    1. Clone or copy this template directory
    2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
    3. Source your OpenStack credentials: `source openrc.sh`
    4. Initialize: `tofu init`
    5. Preview: `tofu plan`
    6. Apply: `tofu apply`
    
    ## Variables
    
    | Name | Type | Required | Default | Description |
    | --- | --- | --- | --- | --- |
    | `key_name` | string | yes | n/a | Existing SSH keypair name in the project for compute instances |
    | `ingest_flavor` | string | no | `c2a.large` | CPU flavor for the ingest VM |
    | `ingest_server` | string | no | `nginx-rtmp` | `nginx-rtmp` or `srs` |
    | `encoder_cidr` | string | no | `0.0.0.0/0` | CIDR allowed to push RTMP/SRT |
    | `admin_cidr` | string | no | `0.0.0.0/0` | CIDR allowed for SSH |
    | `restream_targets` | list(string) | no | `[]` | Downstream RTMP push URLs |
    | `enable_hls_recording` | bool | no | `false` | Upload HLS segments to Object Storage |
    | `hls_bucket_name` | string | when recording | `""` | Replay bucket name |
    
    ## Documentation
    
    Full documentation: [Live ingest and restream template](/docs/automation/templates/live-ingest-restream)
    
Resources, parameters, and variables
Provisions
Parameterized by
Variables
  • key_namerequired
  • ingest_flavor="c2a.large"
  • image_name="Ubuntu-24.04"
  • external_network="PublicStatic"
  • private_cidr="192.168.75.0/24"
  • instance_name="live-ingest"
  • ingest_server="nginx-rtmp" validation {
  • encoder_cidr="0.0.0.0/0"
  • admin_cidr="0.0.0.0/0"
  • srt_port=10080
  • application_name="live"
  • stream_key=""
  • restream_targets=[]
  • enable_hls_recording=false
  • hls_bucket_name=""
  • s3_access_key=""
  • s3_secret_key=""
  • s3_endpoint="https://object.us-east-1.rumble.cloud"
  • s3_region="us-east-1"

Outputs#

OutputDescription
ingest_floating_ipPublic IP for encoder pushes
rtmp_publish_urlBase RTMP URL (rtmp://FIP/live)
floating_ip_countAlways 1 for this pattern
hls_bucketReplay bucket name when recording is enabled

See also#

Usage Guidelines

The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.

For the full policy, see Usage Guidelines.

Last validated: 07.07.2026

Was this page helpful?