VOD Deep Dive Part 12: Building VOD on AWS — Services, Architecture, and Costs

Complete AWS VOD reference: MediaConvert, MediaPackage, CloudFront, S3, Step Functions, SPEKE DRM integration, Terraform IaC, real cost breakdowns, common pitfalls, and a production roadmap.

zhuermu · · 22 min
vodstreamingawsmediaconvertcloudfrontterraform

This is Part 12 of the VOD Streaming Deep Dive series.


AWS Media Services Family

AWS groups its VOD and live-streaming services under the AWS Elemental brand. The core four:

ServicePurpose
AWS Elemental MediaConvertFile-based transcoding (offline, VOD)
AWS Elemental MediaLiveReal-time encoding (live streaming)
AWS Elemental MediaPackageStream packaging (HLS/DASH/CMAF) + DRM
AWS Elemental MediaTailorServer-side ad insertion (SSAI)

Supporting services:

ServicePurpose
Amazon S3Object storage
Amazon CloudFrontCDN
AWS LambdaTriggers, lightweight processing
AWS Step FunctionsWorkflow orchestration
Amazon DynamoDBAsset metadata store
Amazon SNS / SQSEvent notification, async queues
Amazon CloudWatchMonitoring and alerting

Reference Architecture

AWS’s official Video on Demand on AWS solution:

┌──────────────┐                                        ┌──────────────┐
│   Viewer     │                                        │   Creator    │
│              │                                        │  (Uploader)  │
└──────────────┘                                        └──────────────┘
        ▲                                                      │
        │ ⑥ HTTPS                                              │ ① PutObject
        │                                                      ▼
┌──────────────┐                                        ┌──────────────┐
│  CloudFront  │                                        │  S3 Source   │
│    (CDN)     │                                        │  Bucket      │
└──────────────┘                                        └──────────────┘
        ▲                                                      │
        │ ⑤ origin fetch                                       │ ② EventBridge
        │                                                      ▼
┌──────────────┐     ┌──────────────┐                   ┌──────────────┐
│ MediaPackage │◄────│  S3 Dest     │◄──────────────────│    Step      │
│    -VOD      │     │  Bucket      │                   │  Functions   │
│ (packaging)  │     └──────────────┘                   └──────────────┘
└──────────────┘            ▲                                  │
        │                   │                                  ▼
        │ SPEKE             │                           ┌──────────────┐
        ▼                   └───────────────────────────│ MediaConvert │
┌──────────────┐                                        │ (Transcode)  │
│  DRM License │                                        └──────────────┘
│  Server      │                                               │
│  (EZDRM etc) │                                               ▼
└──────────────┘                                        ┌──────────────┐
                                                        │   Lambda     │
                                                        │ (post-proc,  │
                                                        │  notify)     │
                                                        └──────────────┘

Flow:

  1. Creator uploads source file to S3 Source Bucket
  2. S3 Event triggers Step Functions via EventBridge
  3. Lambda probes and validates the file
  4. Step Functions invokes MediaConvert for transcoding
  5. Transcoded output written to S3 Destination Bucket
  6. MediaPackage-VOD packages HLS/DASH + DRM
  7. CloudFront serves as CDN
  8. Lambda sends completion notification

Step-by-Step Walkthrough

Step 1: Set Up S3 Buckets

# Source bucket (raw uploads)
aws s3 mb s3://my-vod-source --region us-east-1

# Destination bucket (transcoded output)
aws s3 mb s3://my-vod-dest --region us-east-1

# Enable EventBridge notifications
aws s3api put-bucket-notification-configuration \
  --bucket my-vod-source \
  --notification-configuration '{
    "EventBridgeConfiguration": {}
  }'

Step 2: Create IAM Role for MediaConvert

MediaConvert needs S3 read/write access:

aws iam create-role --role-name MediaConvertRole \
  --assume-role-policy-document file://trust-policy.json

aws iam attach-role-policy --role-name MediaConvertRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

Step 3: MediaConvert Job Template

{
  "Role": "arn:aws:iam::123456789012:role/MediaConvertRole",
  "Settings": {
    "Inputs": [{
      "FileInput": "s3://my-vod-source/episode01.mov",
      "AudioSelectors": {
        "Audio Selector 1": {"DefaultSelection": "DEFAULT"}
      }
    }],
    "OutputGroups": [{
      "Name": "CMAF",
      "OutputGroupSettings": {
        "Type": "CMAF_GROUP_SETTINGS",
        "CmafGroupSettings": {
          "Destination": "s3://my-vod-dest/ep01/",
          "SegmentLength": 4,
          "FragmentLength": 2,
          "WriteHlsManifest": "ENABLED",
          "WriteDashManifest": "ENABLED"
        }
      },
      "Outputs": [
        {
          "NameModifier": "_1080p",
          "VideoDescription": {
            "Width": 1920, "Height": 1080,
            "CodecSettings": {
              "Codec": "H_264",
              "H264Settings": {
                "RateControlMode": "QVBR",
                "QvbrSettings": {"QvbrQualityLevel": 8},
                "MaxBitrate": 5000000,
                "GopSize": 2, "GopSizeUnits": "SECONDS"
              }
            }
          },
          "AudioDescriptions": [{
            "CodecSettings": {
              "Codec": "AAC",
              "AacSettings": {
                "Bitrate": 128000,
                "SampleRate": 48000,
                "CodingMode": "CODING_MODE_2_0"
              }
            }
          }]
        }
      ]
    }]
  }
}

Key parameters:

  • QvbrQualityLevel: 8 — QVBR target quality (1–10, higher = better)
  • MaxBitrate: 5000000 — Peak bitrate cap
  • GopSize: 2 SECONDS — 2-second GOPs
  • SegmentLength: 4 — 4-second segments
  • FragmentLength: 2 — 2-second fragments (sub-segments for CMAF)

Submit the job:

aws mediaconvert create-job --cli-input-json file://job.json \
  --endpoint-url https://abc.mediaconvert.us-east-1.amazonaws.com

Step 4: Add DRM via SPEKE

Add to the CMAF OutputGroup:

"Encryption": {
  "EncryptionMethod": "SAMPLE_AES",
  "SpekeKeyProvider": {
    "ResourceId": "episode01",
    "SystemIds": [
      "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
      "94ce86fb-07ff-4f43-adb8-93d2fa968ca2",
      "9a04f079-9840-4286-ab92-e65be0885f95"
    ],
    "Url": "https://speke.ezdrm.com/v2",
    "CertificateArn": "arn:aws:acm:..."
  }
}

The three SystemIds correspond to Widevine, FairPlay, and PlayReady respectively. The Url points to the DRM provider’s SPEKE endpoint.

Step 5: CloudFront Distribution

aws cloudfront create-distribution \
  --distribution-config file://cf-config.json

Key configuration:

  • Origin: S3 Destination Bucket
  • Viewer Protocol Policy: Redirect HTTP to HTTPS
  • Allowed Methods: GET, HEAD
  • Cache Policy: Optimized for uncompressed objects
  • Origin Access Control: Enabled (prevent direct S3 access)
  • Signed URLs: Enable Trusted Key Groups when needed

Step 6: Generate Signed URLs

Backend generates short-lived signed URLs at playback time:

from datetime import datetime, timedelta
from botocore.signers import CloudFrontSigner
import rsa

def generate_signed_url(video_path, key_id, private_key, expires_in=3600):
    url = f"https://d1234.cloudfront.net/{video_path}"
    expires = datetime.utcnow() + timedelta(seconds=expires_in)

    def rsa_signer(message):
        return rsa.sign(message, private_key, 'SHA-1')

    signer = CloudFrontSigner(key_id, rsa_signer)
    return signer.generate_presigned_url(url, date_less_than=expires)

Step 7: Step Functions Orchestration

Wire the full pipeline with a state machine (see Part 11).


Infrastructure as Code: Terraform

# Source bucket
resource "aws_s3_bucket" "source" {
  bucket = "my-vod-source"
}

# Destination bucket
resource "aws_s3_bucket" "dest" {
  bucket = "my-vod-dest"
}

# EventBridge rule: trigger Step Functions on S3 upload
resource "aws_cloudwatch_event_rule" "upload" {
  name = "vod-upload-trigger"
  event_pattern = jsonencode({
    source      = ["aws.s3"]
    detail-type = ["Object Created"]
    detail = {
      bucket = { name = [aws_s3_bucket.source.bucket] }
    }
  })
}

# Step Functions state machine
resource "aws_sfn_state_machine" "vod_pipeline" {
  name       = "vod-pipeline"
  role_arn   = aws_iam_role.sfn_role.arn
  definition = file("${path.module}/vod_pipeline.asl.json")
}

resource "aws_cloudwatch_event_target" "sfn" {
  rule     = aws_cloudwatch_event_rule.upload.name
  arn      = aws_sfn_state_machine.vod_pipeline.arn
  role_arn = aws_iam_role.event_bridge_role.arn
}

# CloudFront distribution
resource "aws_cloudfront_distribution" "vod" {
  origin {
    domain_name = aws_s3_bucket.dest.bucket_regional_domain_name
    origin_id   = "s3-vod-dest"
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.vod.cloudfront_access_identity_path
    }
  }

  enabled             = true
  default_root_object = ""

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "s3-vod-dest"
    viewer_protocol_policy = "redirect-to-https"
    cache_policy_id        = data.aws_cloudfront_cache_policy.caching_optimized.id
    trusted_key_groups     = [aws_cloudfront_key_group.vod.id]
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

Cost Breakdown

MediaConvert Transcoding

Billed by output minutes x encoding complexity:

TierPrice (us-east-1)
Basic SD (< 30fps, < 720p, AVC)$0.0075 / output min
Basic HD (< 30fps, 720p–1080p, AVC)$0.015 / output min
Pro HD (60fps or HEVC or HDR)$0.030 / output min
Pro UHD (4K+)$0.060 / output min
AV1~2–3x premium

Example — 90-minute movie, 6 output tiers (4 H.264 + 2 H.265):

90 min x 4 tiers x $0.015 = $5.40 (H.264)
90 min x 2 tiers x $0.030 = $5.40 (H.265)
Total transcode cost ≈ $10.80 per movie

S3 Storage

  • Standard: $0.023/GB/month (us-east-1)
  • One movie, all versions: ~3 GB → $0.07/month
  • Large library (100K hours): ~1 PB → $23,000/month
  • Cold content on S3 Glacier saves ~80%

CloudFront Bandwidth

$0.005–$0.08/GB by region, with volume discount tiers. See Part 7 for the full cost model.

MediaPackage JIT Packaging

~$0.065/GB of source traffic. Cost-effective for frequently accessed popular content.


Estimated Monthly Bill (2M DAU Platform)

Assumptions: 2M DAU, 30 min average daily viewing, 720p primary stream.

CategoryMonthly cost range
S3 Storage (10 PB)$50K–$150K
MediaConvert (100 new episodes/day)$1K–$5K
CloudFront (24 PB/month)$400K–$800K
MediaPackage JIT$50K–$150K
Lambda + Step Functions$5K–$20K
DynamoDB + RDS$10K–$30K
DRM SaaS$20K–$80K
Total$500K–$1.2M

CDN bandwidth dominates costs. The three biggest cost-reduction levers: better codecs, per-title encoding, and multi-CDN bidding.


Cross-Cloud Equivalents

AWSGCPAzure
S3GCSBlob Storage
MediaConvertTranscoder APIMedia Services
MediaPackageMedia CDNMedia Services DRM
CloudFrontCloud CDN / Media CDNAzure CDN
Step FunctionsWorkflowsDurable Functions
LambdaCloud FunctionsAzure Functions

Cloud selection guidance:

  • Global audience, primarily overseas: AWS (broadest coverage)
  • China mainland: Alibaba Cloud / Tencent Cloud
  • Multi-region: Multi-cloud distribution

Common Pitfalls

CloudFront Signed URL Cache Hit Rate Drops to 0%

Cause: Signing parameters (Signature, Expires, Key-Pair-Id) included in the cache key.

Fix: Configure the Cache Policy to use only the path as the cache key. Signing query strings should be excluded from caching.

MediaConvert Output Files Are Abnormally Large

Cause: No QVBR or MaxBitrate set. CRF too low, causing peak bitrate explosion.

Fix: Set QvbrQualityLevel: 7-8 + MaxBitrate constraint.

HLS Stuck Loading on iOS

Cause: Master Playlist missing the CODECS attribute.

Fix: Ensure every EXT-X-STREAM-INF includes CODECS="avc1.xxxxxx,mp4a.40.2".

High Cross-Border Distribution Latency

Cause: CloudFront origin-fetching from us-east-1 S3 — high latency for Asian users.

Fix: Enable CloudFront Origin Shield near the origin, or use S3 Cross-Region Replication so CloudFront fetches from the nearest region.


Production Roadmap: From Zero to Live

Week 1: Minimum Viable Demo

  • Set up AWS account, enable S3, MediaConvert, CloudFront
  • Manually run a MediaConvert job, produce HLS output
  • Open the m3u8 in Safari — it plays

Weeks 2–4: Automated Pipeline

  • Step Functions: S3 Upload → MediaConvert → S3 → CloudFront
  • Backend generates signed URLs
  • iOS/Android app integrates a player (AVPlayer / ExoPlayer)

Months 2–3: Production Features

  • Multi-tier bitrate ladder + per-title encoding
  • DRM integration (EZDRM / PallyCon)
  • QoE data via Mux or self-built pipeline
  • Multi-CDN routing

Months 4–6: Scale

  • H.265 / AV1 tiers
  • LL-HLS (if live streaming is added)
  • Multi-region deployment + cross-region replication
  • Cost optimization (Spot instances, Glacier cold archival)

Key Takeaways

  1. AWS core four: MediaConvert + MediaPackage + CloudFront + S3.
  2. Step Functions for orchestration, Lambda for lightweight processing.
  3. Use SPEKE to integrate with managed DRM services.
  4. Production environments should use Terraform for declarative infrastructure.
  5. CDN bandwidth is the dominant cost — followed by storage, with transcoding being relatively cheap.
  6. Every major cloud has equivalent services — choose based on your market.
  7. From MVP to production: 2–6 months is typical.

Previous: Part 11: End-to-End Workflow

This concludes the VOD Streaming Deep Dive series. Start from Part 1: Video Fundamentals if you haven’t already.