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.
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:
| Service | Purpose |
|---|---|
| AWS Elemental MediaConvert | File-based transcoding (offline, VOD) |
| AWS Elemental MediaLive | Real-time encoding (live streaming) |
| AWS Elemental MediaPackage | Stream packaging (HLS/DASH/CMAF) + DRM |
| AWS Elemental MediaTailor | Server-side ad insertion (SSAI) |
Supporting services:
| Service | Purpose |
|---|---|
| Amazon S3 | Object storage |
| Amazon CloudFront | CDN |
| AWS Lambda | Triggers, lightweight processing |
| AWS Step Functions | Workflow orchestration |
| Amazon DynamoDB | Asset metadata store |
| Amazon SNS / SQS | Event notification, async queues |
| Amazon CloudWatch | Monitoring 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:
- Creator uploads source file to S3 Source Bucket
- S3 Event triggers Step Functions via EventBridge
- Lambda probes and validates the file
- Step Functions invokes MediaConvert for transcoding
- Transcoded output written to S3 Destination Bucket
- MediaPackage-VOD packages HLS/DASH + DRM
- CloudFront serves as CDN
- 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 capGopSize: 2 SECONDS— 2-second GOPsSegmentLength: 4— 4-second segmentsFragmentLength: 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:
| Tier | Price (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.
| Category | Monthly 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
| AWS | GCP | Azure |
|---|---|---|
| S3 | GCS | Blob Storage |
| MediaConvert | Transcoder API | Media Services |
| MediaPackage | Media CDN | Media Services DRM |
| CloudFront | Cloud CDN / Media CDN | Azure CDN |
| Step Functions | Workflows | Durable Functions |
| Lambda | Cloud Functions | Azure 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
- AWS core four: MediaConvert + MediaPackage + CloudFront + S3.
- Step Functions for orchestration, Lambda for lightweight processing.
- Use SPEKE to integrate with managed DRM services.
- Production environments should use Terraform for declarative infrastructure.
- CDN bandwidth is the dominant cost — followed by storage, with transcoding being relatively cheap.
- Every major cloud has equivalent services — choose based on your market.
- 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.