# 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.

- Author: zhuermu
- Published: 2026-05-10
- Web version: https://zhuermu.com/en/blog/vod-deep-dive-part-12-building-vod-on-aws/

---
> *This is Part 12 of the [VOD Streaming Deep Dive](/blog/vod-deep-dive-part-1-video-fundamentals/) 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**:

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

```bash
# 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:

```bash
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

```json
{
  "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:

```bash
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:

```json
"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

```bash
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:

```python
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](/blog/vod-deep-dive-part-11-end-to-end-workflow/)).

---

## Infrastructure as Code: Terraform

```hcl
# 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](/blog/vod-deep-dive-part-7-cdn-distribution/) 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

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](/blog/vod-deep-dive-part-11-end-to-end-workflow/)

*This concludes the VOD Streaming Deep Dive series. Start from [Part 1: Video Fundamentals](/blog/vod-deep-dive-part-1-video-fundamentals/) if you haven't already.*

---

## 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.


---

## References

- [AWS Elemental MediaConvert User Guide](https://docs.aws.amazon.com/mediaconvert/latest/ug/what-is.html) — AWS Documentation
- [AWS Elemental MediaPackage User Guide](https://docs.aws.amazon.com/mediapackage/latest/ug/what-is.html) — AWS Documentation
- [Amazon CloudFront Developer Guide](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Introduction.html) — AWS Documentation
- [Secure Packager and Encoder Key Exchange (SPEKE)](https://docs.aws.amazon.com/speke/latest/documentation/what-is-speke.html) — AWS Documentation
