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.

References

  1. AWS Elemental MediaConvert User Guide — AWS Documentation
  2. AWS Elemental MediaPackage User Guide — AWS Documentation
  3. Amazon CloudFront Developer Guide — AWS Documentation
  4. Secure Packager and Encoder Key Exchange (SPEKE) — AWS Documentation

Frequently asked

Which AWS services do I need to build a video streaming platform?
The core four are AWS Elemental MediaConvert (file-based transcoding), MediaPackage (HLS/DASH/CMAF packaging plus DRM), CloudFront (CDN delivery), and S3 (storage). Around them you add Step Functions for workflow orchestration, Lambda for triggers and lightweight processing, DynamoDB for asset metadata, SNS/SQS for notifications, and CloudWatch for monitoring. For live streaming you'd use MediaLive, and MediaTailor for server-side ad insertion.
How much does it cost to run a VOD platform on AWS?
For a 2M DAU platform (30 min average daily viewing, 720p primary stream), expect roughly $500K–$1.2M per month. CloudFront CDN bandwidth dominates at $400K–$800K, followed by S3 storage ($50K–$150K for 10 PB) and MediaPackage JIT packaging; transcoding is relatively cheap (~$10.80 per 90-minute movie across 6 tiers on MediaConvert). The biggest cost levers are better codecs, per-title encoding, and multi-CDN bidding.
Why does my CloudFront cache hit rate drop to 0% with signed URLs?
Because the signing query parameters (Signature, Expires, Key-Pair-Id) are being included in the cache key — every user gets a unique URL, so nothing is ever a cache hit. The fix is to configure the CloudFront Cache Policy to use only the path as the cache key and exclude the signing query strings from caching. CloudFront still validates the signature; it just no longer fragments the cache.
Share this article X LinkedIn Hacker News Reddit

Read next

Get new posts by email

One email when a new article goes up. No ads, unsubscribe in one click.

Your address is used for post notifications only.

Discussion

Comments are GitHub Discussions on this repository; sign in with GitHub to post.