DEV Community

Cover image for I Rebuilt YouTube on AWS Alone (and Hit Every Wall)
Yuuki Yamashita
Yuuki Yamashita

Posted on

I Rebuilt YouTube on AWS Alone (and Hit Every Wall)

It started as a simple question: how is YouTube actually built? One thing led to another, and a few hours later I had a single-user, self-hosted video platform running in production on AWS — after redesigning the auth layer from scratch mid-build, chasing down an "exec format error," and discovering that avoiding a NAT Gateway didn't actually save me any money. Here's the whole story, including the parts that didn't work the first time.

What YouTube is actually made of

Before writing any code, I wanted to understand what I was copying. Roughly:

  • Upload and transcoding: uploaded video gets converted into 144p through 4K/8K across multiple codecs, processed by a huge fleet of parallel workers
  • CDN: Google Global Cache — dedicated caching nodes placed directly inside ISP networks — plus adaptive bitrate streaming
  • Metadata: Vitess (a sharding layer over MySQL) and Bigtable/Spanner
  • Recommendations: a two-stage candidate generation + ranking ML system
  • Content ID: audio/video fingerprinting to detect copyright infringement
  • Ads: backed by Google Ad Manager

Can AWS alone reproduce it?

Most of the functional skeleton maps cleanly onto managed AWS services: S3 for upload, MediaConvert for transcoding, CloudFront for delivery, DynamoDB for metadata, OpenSearch for search, Personalize for recommendations. That part is genuinely achievable.

A few pieces aren't:

  • Content ID has no AWS-managed equivalent. You'd need a third-party SaaS like Audible Magic or ACRCloud, or roll your own fingerprinting with something like Chromaprint against a reference database you'd also have to build
  • ISP-embedded caching — CloudFront has a global edge network, but nothing at the density of nodes sitting inside individual ISPs
  • Ad auctions at Google Ad Manager's scale aren't something you build yourself; you'd hand this off to an existing ad network

For a personal project, I decided not to build Content ID or ad serving at all. That decision turned out to be tied directly to a legal question I hadn't expected to spend time on.

The legal research I didn't expect to do

Building a video-sharing app in Japan, even a personal one, touches a surprising number of regulations, so I checked before writing any infrastructure code.

First, the Telecommunications Business Act. One-way video distribution generally doesn't require registration, since you're not "mediating someone else's communication." Add a comment section or DMs between users, though, and that changes.

Second, the Act on the Limitation of Liability for Damages of Specified Telecommunications Service Providers (Japan's provider-liability law, recently renamed to something closer to "platform accountability act"). Any platform accepting user-generated content is expected to run a takedown-request contact point; cross a large-user threshold (10M+ monthly users in Japan) and heavier obligations kick in.

Third — and this is the one that actually shaped the design — Article 30 of the Copyright Act, the private-use reproduction exception. Keep something fully private, accessible only to yourself, and it falls under private use. Make it public and it becomes "transmission to the public" (公衆送信), where that exception no longer applies. I also checked whether gating access behind a login would be enough to stay private if I let a few people in. It isn't automatically: under Japanese copyright law, "the public" includes "a specific but numerous group," so the real question isn't whether there's a login screen, it's how many people, and how close a relationship. Family-sized is safe; a wider circle of friends risks crossing into "specific but numerous."

Given all that, I decided the app would support exactly one user — me. No sign-up, no invite flow. That sidesteps the Telecommunications Business Act and the platform-liability law entirely, and keeps everything inside the private-use exception.

The plan

  • Single user only, no sign-up
  • AWS only (I use Vercel for most other projects, but not this one)
  • No Content ID, no ad serving
  • Upload → S3 → MediaConvert (transcode to HLS) → CloudFront
  • Web UI on ECS Fargate + ALB + CloudFront (App Runner was already off the table — AWS stopped accepting new App Runner services)

I wrote the CDK for VPC, S3, DynamoDB, a Lambda to kick off MediaConvert jobs, ECS, CloudFront, and Cognito. So far, so normal.

Cognito's ALB integration needs HTTPS, and I didn't have a domain

My first pass at auth used the ALB's native authenticate-cognito listener action — no app code needed, ALB handles the redirect to Cognito's hosted UI for you. Clean, until deploy:

Resource handler returned message: "Actions of type 'authenticate-cognito' are supported only on HTTPS listeners"
Enter fullscreen mode Exit fullscreen mode

That action only works on HTTPS listeners, which means an ACM certificate, which means a real, DNS-verifiable domain — something this project didn't have. Buying a domain just for this felt like the wrong trade, so I moved authentication into the app itself instead, using Next.js's proxy.ts.

Moving auth to the app ran straight into "no NAT Gateway"

I reconfigured the Cognito App Client as a public client (no secret) and switched to PKCE for the authorization code exchange, so the browser could talk to Cognito directly instead of routing through the ALB's constraints.

Redeployed, logged in, and got a 500 on the callback:

⨯ [TypeError: fetch failed] {

      at ignore-listed frames {
    code: 'ETIMEDOUT',
Enter fullscreen mode Exit fullscreen mode

The ECS task had no path to the internet. To keep costs down I'd built the VPC with interface endpoints instead of a NAT Gateway — but there's no VPC endpoint for Cognito's Hosted UI/OAuth domain (*.auth.<region>.amazoncognito.com). The container simply couldn't reach it.

The fix was to move the token exchange itself into the browser. The only thing that actually needs to happen server-side is JWT verification (fetching the JWKS), which is covered by the cognito-idp VPC endpoint. The browser already has internet access, so it can talk to Cognito's token endpoint directly. That change got login working without ever adding a NAT Gateway.

Forgot to pin the CPU architecture, container wouldn't start

Redeployed again, and this time the ECS task crash-looped indefinitely. CloudWatch Logs had exactly one line to offer:

exec /usr/local/bin/docker-entrypoint.sh: exec format error
Enter fullscreen mode Exit fullscreen mode

Building the Docker image on an Apple Silicon Mac produces an arm64 image. Fargate defaults to x86_64. Nothing about the mismatch surfaces until the container tries to actually execute. Setting runtimePlatform to ARM64 on the FargateTaskDefinition fixed it. I also turned on the ECS deployment circuit breaker at the same time — without it, a failing deployment can take up to three hours to be reported as failed, and I'd already lost about 40 minutes not noticing.

The health check was hitting the login redirect

Next failure: the ALB health check was pointed at /, which — like every other route — goes through the app's auth gate. An unauthenticated health check gets a 302, the ALB reads that as unhealthy, and the deployment fails outright. Added a dedicated /api/health route that skips the auth check, and that was that.

Video played, but the manifest path was broken

Deployment finally succeeded, upload worked, MediaConvert finished the job — and the video was just a black rectangle.

The cause: MediaConvert's completion event returns outputGroupDetails.playlistFilePaths as a full s3://bucket/key URI, not a bucket-relative key. I'd been storing that value directly as manifestKey, so the app's /${manifestKey} template produced a broken /s3://bucket/... path. Since I already control the output prefix at job-creation time, I switched to deriving the key deterministically instead of trusting the event payload. Don't take an AWS event field at face value if you can compute the same thing yourself.

CloudFront's signed cookies ignored my wildcard

To lock down /renditions/* (the actual video files) behind CloudFront's Key Group, I used @aws-sdk/cloudfront-signer's getSignedCookies with a url + dateLessThan — the "canned policy" form — expecting a wildcard path to cover everything under it. It didn't:

{"error":"AccessDenied","message":"Access denied"}
Enter fullscreen mode Exit fullscreen mode

(Along the way I also discovered this AWS account already had a CloudFront Public Key from a different project, and my first debugging attempt had grabbed the wrong Key Pair ID entirely — worth checking aws cloudfront list-public-keys before assuming there's only one.)

The actual fix was switching to an explicit custom policy — passing policy with a JSON statement whose Resource includes the wildcard — rather than the canned url/dateLessThan shortcut. The SDK happily accepts a wildcard in the canned form; CloudFront just doesn't honor it the same way.

Deleting a video brought it back from the dead

With everything working, I added delete. It's supposed to be a simple DynamoDB + S3 cleanup, but it hit two separate bugs.

First, IAM: grantWrite/grantDelete only cover object-level actions (s3:PutObject*, s3:DeleteObject*), not the bucket-level s3:ListBucket that ListObjectsV2 needs during cleanup. Adding grantRead fixed it.

Second, and more interesting: deleting a video that was still processing let the MediaConvert-completion Lambda fire after deletion, calling UpdateItem on a videoId that no longer existed. DynamoDB's UpdateItem creates the item if it's missing — so the "deleted" video would silently reappear, partially populated. Adding ConditionExpression: 'attribute_exists(videoId)' made that update a no-op instead of a resurrection.

The security group I "locked down" wasn't actually locked down

I wanted the ALB reachable only through CloudFront, so I restricted its security group to CloudFront's managed prefix list (pl-58a04531). Deployed, checked the actual rule set, and found this:

{
  "IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": "Allow from anyone on port 80"}],
  "PrefixListIds": [{"PrefixListId": "pl-58a04531"}]
}
Enter fullscreen mode Exit fullscreen mode

Both rules were live at once. The culprit was the ALB listener's open property, which defaults to true and silently adds its own 0.0.0.0/0 ingress rule regardless of what you've configured on the security group yourself. Setting open: false on addListener removed it. This is the kind of gap you only catch by actually reading the deployed state back from the AWS CLI — the CDK code alone looked correct.

Avoiding a NAT Gateway didn't actually save money

Once things were stable, I priced out the fixed monthly cost:

Item Monthly (approx.)
5x VPC interface endpoints ~$50.40
ALB ~$20–23
ECS Fargate (0.25 vCPU / 0.5GB, ARM64) ~$8.90
Secrets Manager $0.40
Total ~$83

I'd built five interface endpoints specifically to avoid a NAT Gateway (roughly $44.60/month in Tokyo, plus data processing). Adding them up, the endpoints cost about the same as the NAT Gateway would have — sometimes more. Consolidating to a single NAT Gateway would save maybe $5–6/month at the cost of a single point of failure, which is a fine trade for a personal, single-user app. I ended up leaving the endpoint-based setup as-is; the savings weren't worth the churn.

What's actually running

  • Login via Cognito with PKCE, one user account, no sign-up flow
  • Upload → S3 → Lambda → MediaConvert → HLS
  • CloudFront with signed cookies gating the video files themselves
  • Delete, a Japanese/English toggle, and a dark, YouTube-ish UI

The code is public on GitHub, including the README section explaining, in plain terms, why multi-user upload was never on the table.

Mobile

The part that actually took the time

None of the individual fixes here were hard once I knew what was wrong. What took the time was reading logs — exec format error, AccessDenied, InvalidKey — each one terse, each one caused by something completely different. Past a certain point, building on managed AWS services stops being about writing code and starts being about getting fast at figuring out why something isn't working.

Top comments (0)