Quick answer
Split it into a control plane and a media plane. Run stateless Next.js/API servers on PostgreSQL replicas, Valkey and a NATS queue. Upload straight to object storage, transcode with FFmpeg CPU/GPU workers into an HLS/DASH ladder, batch view counts, and serve segments from your own cache servers plus a CDN, because egress dominates cost.
- Built with Claude Code in about 34 minutes of agent time: 36 files, 11 pages and 4 API routes, with build and lint passing.
- Viewly reproduces the video platform design with real channel videos, a working watch page, Shorts, comments, search, library and a session-only upload flow.
- Comments use real route handlers with cursor pagination, idempotency keys, rate limits and Server-Sent Events for live updates.
- At 5M MAU the estimated delivery volume is about 4.5 PB a month, so egress, not compute, decides the architecture and the budget.
- Use GPUs (NVENC) for fast first renditions and CPUs (x264/SVT-AV1) for the final ladder. Bits saved in encoding are paid back on every view.
- Count views through a stream, Valkey minute buckets and one batched UPDATE per minute, then reconcile in ClickHouse.
- On Hetzner, unmetered AX servers in Germany/Finland make a cheap EU edge-cache tier. Pair them with a global CDN and run Postgres yourself with Patroni and pgBackRest.
- Notify subscribers with fanout-on-write for normal channels and fanout-on-read for very large ones.
- Built with Claude
- DesignClaude design canvasThe app screens were designed with Claude on a design canvas, one board per product.
- CodingClaude CodeA Claude Code sub-agent wrote Viewly: 36 files, 11 pages, 4 API routes, in about 34 minutes.
- Parallel agentsClaude Code sub-agentsSix clones were built at the same time, one sub-agent per app.
- Testing & screenshotsClaude CodeBuild, lint, a running server and 8 screenshots that Claude reviewed and fixed.
- Scalability & infrastructure analysisClaude Opus 5.5Capacity math, Hetzner server plans, real-time design, security and cost models.
- Source code
- github.com/suhasbhairav/youtube-cloneMIT license
- Run it locally
- git clone https://github.com/suhasbhairav/youtube-clone.git && cd youtube-clone && npm install && npm run dev
- Stack
- Built with Claude CodeDesigned with ClaudeAnalysed with Claude Opus 5.5Next.js 16 (App Router, Cache Components)React 19Tailwind CSS v4next/font (Roboto)Route Handlers (REST + Server-Sent Events)useSyncExternalStore + localStorageWeb Speech APIYouTube IFrame embed (youtube-nocookie.com)
- Palette
- Accent red #FF0000Ink #0F0F0FBackground #FFFFFFSecondary text #606060Chip / surface grey #F2F2F2Divider #E5E5E5Input border #C6C6C6Link blue #065FD4Account purple #7B1FA2
Screenshots of Viewly








What works in the clone
Collapsible sidebar, mini rail and drawer
The menu button toggles the 240px sidebar and a 72px mini rail on wide screens (persisted in localStorage) and opens a slide-in drawer on tablets and phones and on the watch page. Active state comes from the pathname and the playlist query parameter.
Search with suggestions and voice
The header search suggests matching titles as you type (keyboard navigable) and routes to /results?search_query=. Voice search uses the Web Speech API (SpeechRecognition / webkitSpeechRecognition) with interim transcripts and a clear message when the browser doesn't support it or the microphone is blocked.
Category chips and Shorts shelf
Each of the 22 channel videos gets categories (Podcasts, AI Agents, Automation, Interviews, Enterprise AI, Full episodes, Tutorials) derived from its title, guests and duration. Chips filter the grid, 'New to you' excludes watched videos, and the chip lives in the URL. The 15 Shorts appear in a shelf placed after two full rows using a ResizeObserver column count.
Watch page with real actions
youtube-nocookie embed, Subscribe toggle, like/dislike with counts, Share dialog with copy-to-clipboard for the Viewly and YouTube links, Save dialog (Watch later, playlists, inline playlist creation), expandable description and an Up next list ranked by shared categories with All / Full episodes / Clips filters. Every visit is added to History.
Comments API with live updates
GET /api/comments (top/new sort, cursor pagination) and POST /api/comments (validation, per-IP rate limit with Retry-After, Idempotency-Key replay). /api/comments/stream is a Server-Sent Events endpoint that pushes new comments to every open viewer, with heartbeats and cleanup on disconnect.
Vertical Shorts player
/shorts/[id] is prerendered for all 15 Shorts. Navigate with buttons, arrow keys, the mouse wheel or swipes. Play/pause and mute are sent to the embed via the IFrame API postMessage protocol, and ?panel=comments opens the shared comments panel.
Search API
/api/search tokenises the query and scores titles, categories and descriptions, with optional type filtering. The results page shows skeletons while loading and an empty state with suggestions.
Library persisted in localStorage
Subscriptions, You, History (search, remove, clear, pause), Playlists (create, open, delete), Watch later and Liked videos all read from one versioned localStorage document through a useSyncExternalStore store, so server and client renders match.
Session-only upload flow
Create opens a dialog to pick or drop a video file, edit title, description and visibility, and preview it. Publishing captures a poster frame via canvas, simulates processing with a progress bar, sends an in-app notification and lists the video under Your videos, playable at /watch?v=local-… until reload.
Notifications and account menu
The bell shows uploads from subscribed channels (unsubscribe and they disappear, which mirrors fanout) plus your own upload events, with unread badges and Mark all as read. The account menu links to your channel, history and playlists and can reset all demo data.
Channel page
/channel/suhasbhairav has a banner, avatar, Subscribe and an Open on YouTube link to https://www.youtube.com/@suhasbhairav, plus Videos (Latest/Popular/Oldest), Shorts and About tabs addressable with ?tab=.
Overview: what was built
Viewly is a working clone of a modern video platform, built with Next.js 16 (App Router), React 19 and Tailwind CSS v4. It follows the "Video platform home" design closely: the same 56px header with a hamburger menu, a red play-mark logo, a pill search box with a separate search button and a round voice-search button, a grey Create pill, a bell and a purple account avatar. Below that are a 240px sidebar, a horizontal chip row and a responsive grid of 16:9 cards with a 36px channel avatar, a two-line title, the channel name and a "views • date" line. The palette is the design's: white #FFFFFF, near-black #0F0F0F text, secondary grey #606060, chip grey #F2F2F2, borders #C6C6C6 and #E5E5E5, link blue #065FD4 and accent red #FF0000. Type is Roboto 400/500/700, loaded through next/font/google.
The design's grey "Thumbnail" boxes are replaced with real media. The catalog is a snapshot of Suhas Bhairav's YouTube channel: 22 long-form videos from The Bhairav Show and 15 Shorts, with real thumbnails from i.ytimg.com, real durations, view counts and publish dates. Playback uses the privacy-enhanced youtube-nocookie.com embed, and every player has a "Watch on YouTube" link. The feed only mounts an iframe on the page you are watching. Cards show thumbnails only.
What works
- Home: the menu switches between the full sidebar and a 72px mini rail on wide screens, and opens a drawer on tablets and phones. Search has type-ahead suggestions. Voice search uses the Web Speech API when the browser has it, with a clear fallback message when it doesn't. The notifications dropdown shows unread state, and the account menu works. Category chips (Podcasts, AI Agents, Automation, Interviews, Enterprise AI, Full episodes, Tutorials, Shorts, Recently uploaded, New to you) filter the grid, and the chip is kept in the URL (
/?chip=ai-agents). A Shorts shelf sits after exactly two full rows: aResizeObservermeasures the column count so the shelf never leaves a gap. - Watch page (
/watch?v=ID): player, title, channel row with a Subscribe toggle, like/dislike, Share (copy link), Save (Watch later and playlists, with inline playlist creation), an expandable description, comments with Top/Newest sorting and live updates, and an Up next column with filters. - Shorts (
/shorts/ID): a 9:16 player with up/down buttons, arrow keys, wheel and swipe navigation, play/pause and mute (sent to the embed withpostMessage), and a comments side panel (?panel=comments). - Search (
/results?search_query=): served byGET /api/search, which ranks title, description and category matches, with All/Videos/Shorts filters. - Library: Subscriptions, You, History (search, remove, clear, pause), Playlists (create, open, delete), Watch later and Liked videos. All persisted in
localStorage. - Upload: Create opens a real upload dialog. You pick or drop a video file, set the title, description and visibility, and preview it in a
<video>element. On publish, the app grabs a poster frame with a canvas, shows a simulated processing bar, sends a notification and lists the video under "Your videos", where it plays. - Channel page (
/channel/suhasbhairav): banner, avatar, Subscribe, and Videos (Latest/Popular/Oldest), Shorts and About tabs, with a link out to the real channel.
What is real and what is simulated
| Area | In the clone | In production |
|---|---|---|
| Video playback | YouTube embed (nocookie) of real channel videos | Your own HLS/CMAF ladder from object storage behind a CDN |
| Comments | Real route handlers: validation, cursor pagination, idempotency keys, a per-IP rate limit, Server-Sent Events fanout; stored in memory | PostgreSQL partitioned by video, a WebSocket/SSE gateway with NATS backplane, moderation |
| Search | Real API, token scoring over a static catalog | OpenSearch/Meilisearch with typo tolerance, filters and popularity signals |
| Library state | localStorage via a useSyncExternalStore store | Per-user tables, sync across devices |
| Upload | Object URL in the browser, canvas thumbnail, timer standing in for transcoding; session-only, nothing leaves the browser | Resumable multipart upload, FFmpeg workers, ABR ladder, thumbnails, moderation |
| Like counts | Derived from views plus your own like | Batched counters |
How I built Viewly with Claude: design, code and scaling plan
Viewly was built with Claude at every step. I designed it with Claude on a design canvas in Claude, coded and tested it with Claude Code, and used Claude Opus 5.5 to work out how it scales to millions of users. Here is the actual workflow, including what I decided and what Claude did.
| Stage | Claude tool | What it did |
|---|---|---|
| Design | Claude design canvas | The app screens were designed with Claude on a design canvas, one board per product. |
| Coding | Claude Code | A Claude Code sub-agent wrote Viewly: 36 files, 11 pages, 4 API routes, in about 34 minutes. |
| Parallel agents | Claude Code sub-agents | Six clones were built at the same time, one sub-agent per app. |
| Testing & screenshots | Claude Code | Build, lint, a running server and 8 screenshots that Claude reviewed and fixed. |
| Scalability & infrastructure analysis | Claude Opus 5.5 | Capacity math, Hetzner server plans, real-time design, security and cost models. |
1. Design with Claude
I designed the screens with Claude on a design canvas: six app screens, one board per product, each in the palette of the product it is modelled on. The Viewly design uses an original name and original copy, so the clone borrows the product category and layout conventions, not YouTube's brand.
2. One shared brief, written with Claude Code
Before any app code, Claude Code wrote one brief that all six builds followed. It read the design boards from the Claude canvas, pulled my channel's videos from YouTube, and fetched current prices from Hetzner. The brief covered:
- Stack: the existing
create-next-appscaffold (Next.js 16, React 19, Tailwind CSS v4). Next 16 has breaking changes, so Claude was told to read the docs bundled innode_modules/next/dist/docs/rather than rely on memory. - Fidelity: match the design's layout, hex colours, fonts and spacing; replace grey placeholders with real media; use stroke SVG icons, not emoji.
- "Fully working": every visible control must do something, backed by Next.js route handlers so the UI talks to an API like a real product.
- Media: real videos from @suhasbhairav played through privacy-enhanced embeds.
- Proof: build, run, screenshot every main flow, look at each screenshot, fix what's wrong.
3. Coding with Claude Code sub-agents, in parallel
The main Claude Code session started six sub-agents, one per clone, each owning one folder and one port, so the six apps were built at the same time. Every agent ran on Claude Opus 5.5. The Viewly agent worked for about 34 minutes. In that run Claude:
- Read the design file and the Next.js 16 docs it needed.
- Wrote the app: 36 source files, about 3,865 lines, with 11 page routes and 4 API route handlers (
/api/comments,/api/comments/stream,/api/search,/api/videos). - Ran
npm run buildandnpm run lintuntil both passed cleanly. - Started the production server and drove headless Chrome to capture 8 screenshots. For true phone-width shots it wrote a small Chrome DevTools Protocol script, because Chrome's
--window-sizeflag won't lay out narrower than about 500px. - Opened every screenshot, fixed the bugs it saw, and re-shot.
4. Analysing scalability with Claude
The sections that follow, from the data model to the cost breakdown, were drafted with Claude Opus 5.5. That covers capacity math, the Postgres and caching plan, Hetzner server choices per growth stage, the real-time notification design, security and monthly costs. Each one is marked where it starts. Claude worked from Hetzner's own price pages, fetched that day, rather than from memory, and showed its arithmetic so the estimates can be checked.
5. Review: Claude checking Claude
The main Claude Code session reviewed each agent's output instead of trusting it. It looked at the screenshots and rendered every page. It also caught an inconsistency across the six write-ups: some cost tables listed the price of one server where others listed the row total. All tables were normalised to show both. The agents were explicit about which parts are simulated (in-memory server state, fake payments or uploads where relevant) and which paths were not tested, and this page keeps that explicit.
What this means if you want to do the same
- Spend your time on the brief, not the boilerplate. A precise brief (stack, fidelity bar, definition of "working", proof required) is what made six parallel Claude Code builds come back consistent.
- Make Claude prove it. "Build passes, here are screenshots I looked at" is a much stronger signal than "done".
- Keep the architecture decisions yours. Claude wrote the code and the first draft of the scaling plan; deciding what is real, what is simulated and what goes to production is still the engineer's job.
- Start here: Claude Code overview · Sub-agents · Claude Opus
Page routes in the clone
/ · /channel/[handle] · /feed/history · /feed/playlists · /feed/subscriptions · /feed/you · /playlist · /results · /shorts · /shorts/[id] · /watch
Screens walkthrough
Each screenshot was taken from a production build (next build && next start) of the clone in headless Chrome.
1. Home feed
The home page matches the design: header, 240px sidebar with the eight primary destinations (Home, Shorts, Subscriptions, You, History, Playlists, Watch later, Liked videos), a Subscriptions section, the chip row and a repeat(auto-fill, minmax(300px, 1fr)) grid with 40px row and 16px column gaps. The red badge on the bell counts notifications for uploads newer than the last time you pressed "Mark all as read". Those notifications only appear while you are subscribed, which mirrors how subscriber fanout works on a real platform. Clicking a chip calls router.replace('/?chip=…', { scroll: false }), so a filtered feed can be shared and reloaded.
2. Watch page
The watch page reads v from the query string (useSearchParams inside a Suspense boundary, which Next 16 requires with Cache Components). It mounts one nocookie iframe with autoplay=1&mute=1&playsinline=1&rel=0. The action row is a set of 36px pills: a split like/dislike control, Share, Save and "Watch on YouTube". The Up next list ranks the other videos by how many categories they share with the current one, and the All / Full episodes / Clips chips filter it on the client.
3. Comments with live updates
Comments come from GET /api/comments?videoId=…&sort=top|new&cursor=…. Posting sends POST /api/comments with an Idempotency-Key header, so a double-click or retry cannot create duplicates. The green Live dot means the page holds an EventSource on /api/comments/stream. A comment posted from any other tab or device is pushed over Server-Sent Events and slides in at the top with a light highlight. The like on the video (filled thumb) and your comment likes are saved in localStorage.
4. Shorts player
/shorts/[id] is prerendered for all 15 Shorts with generateStaticParams. The player is a 9:16 box sized to the viewport height. Navigation works with the round chevrons, the arrow keys, the mouse wheel (throttled to one step per 700 ms) and touch swipes, and each step replaces the URL so every Short can be linked. Play/pause and mute talk to the embed through the IFrame Player API's postMessage protocol (enablejsapi=1). The comments panel is the same component as on the watch page, opened with ?panel=comments.
5. Upload dialog
Create opens the upload flow. A file chosen or dropped here becomes an object URL (URL.createObjectURL) and plays in a <video> preview next to the metadata form. On Publish, a hidden video element seeks to about one second in and draws that frame to a canvas to make the thumbnail. The "processing" bar then stands in for the transcoding pipeline described later in this guide. Uploads are deliberately session-only: nothing is sent to a server, and a reload removes them. The UI says this in two places.
6. Search results
Search requests go to /api/search, which splits the query into terms and scores each document: +5 for a title hit, +2 for a category hit, +1 for a description hit and +6 if the full phrase appears in the title. Ties break on views.
7. You (library)
History supports search, per-item removal, clear-all and pause. Playlists can be created from the Save dialog or the Playlists page, and Watch later and Liked videos are virtual playlists.
8. Mobile watch page
Below 640px the search box collapses into an icon that opens a full-width search bar, Create becomes a round plus button, and the sidebar becomes a slide-in drawer.
Frontend architecture
Routes
| Route | Rendering | What it does |
|---|---|---|
/ | Static shell + client feed in Suspense | Chips, grid, Shorts shelf |
/watch?v=ID | Static shell, client reads searchParams | Player, actions, comments, Up next |
/shorts, /shorts/[id] | 15 paths prerendered via generateStaticParams | Vertical player |
/results?search_query= | Client fetch to /api/search | Search |
/feed/subscriptions, /feed/you, /feed/history, /feed/playlists | Static, hydrated from localStorage | Library |
/playlist?list=WL|LL|id | Client | Playlist detail |
/channel/[handle] | suhasbhairav prerendered | Channel with tabs (?tab=) |
/api/* | Route handlers (dynamic) | Videos, search, comments, SSE |
The app has cacheComponents: true and partialPrefetching: true in next.config.mjs. With Cache Components, anything that reads request data (searchParams, dynamic params) has to sit inside a <Suspense> boundary. Each page therefore renders a server shell and wraps the URL-dependent client component in Suspense with a fallback that looks like the final layout. The build output shows most routes as static (○) and the two dynamic segments as partial prerenders (◐), which is what you want: the shell comes from the CDN and only the parts that depend on the URL render on demand.
Components
app/
layout.js Roboto (next/font), metadata, <AppShell>
page.js <Suspense><HomeFeed/></Suspense>
watch/page.js <Suspense><WatchPage/></Suspense>
shorts/[id]/page.js generateStaticParams + <ShortsPlayer id/>
api/comments/route.js, api/comments/stream/route.js, api/search/route.js, api/videos/route.js
components/
AppShell.js Header (SearchBox, VoiceSearch, Notifications, AccountMenu), Drawer, sidebar/mini rail, toasts
Sidebar.js NAV config with route matchers, FullNav, MiniRail
VideoCards.js VideoCard (design grid card), VideoRow, CompactCard, ShortCard, Thumb, Avatar
Comments.js fetch + sort + paginate + post + EventSource
SaveShare.js Share and Save dialogs
UploadModal.js file pick/drop, preview, canvas poster frame, simulated processing
pages/ HomeFeed, WatchPage, ShortsPlayer, SearchResults, Library, ChannelPage
lib/
videos.js catalog + categories + ranking + formatting (isomorphic)
store.js localStorage store (useSyncExternalStore)
session.js in-memory session store (uploads, toasts, events)
comments-db.js in-memory comments, idempotency map, rate limiter, pub/sub
State
There are three kinds of state, and each has its own home:
- URL state: video id, Short id, search query, chip, channel tab, playlist id, comments panel. Anything someone might want to share or reload goes in the URL.
- Durable user state: subscriptions, likes, history, Watch later, playlists, notification read marker, sidebar mode. It lives in
lib/store.js, a 100-line external store.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)returns frozen defaults on the server and during hydration, then switches to the parsedlocalStoragevalue. Writes go through namedactions(toggleLike,addHistory,createPlaylist…), which keeps components dumb and makes the later move to a REST API mechanical: each action becomes afetchwith an optimistic update. - Session state: uploads (object URLs cannot survive a reload), toasts and in-app events. It lives in
lib/session.js, the same pattern without persistence.
There is no global state library. React 19's useSyncExternalStore and a few named actions cover the job without a dependency.
Data fetching
The catalog is a JSON file imported by lib/videos.js. Server components, client components and route handlers all share one module, so the grid can render on the server with zero client fetches. Interactive data (comments, search) goes through route handlers, the same way a real product would call its API. In production the catalog import becomes a 'use cache' function backed by Postgres with cacheLife('minutes') and a tag (cacheTag('video:' + id)) that the upload pipeline invalidates when processing finishes.
Production data model
The clone's catalog is static JSON. A production platform needs a relational core for metadata, users and the social graph, an append-only event store for views and watch time, object storage for media, and a search index. Below is the PostgreSQL 17 schema I would start with. Ids are bigint generated from a Snowflake-style or UUIDv7 scheme so they are time-ordered and shard-friendly. Public ids are 11-character base64url strings, like the clone's video ids.
-- Users and channels (a user can own several channels)
CREATE TABLE users (
id bigint PRIMARY KEY,
email citext UNIQUE NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE channels (
id bigint PRIMARY KEY,
owner_id bigint NOT NULL REFERENCES users(id),
handle citext UNIQUE NOT NULL, -- '@suhasbhairav'
title text NOT NULL,
description text NOT NULL DEFAULT '',
avatar_key text, -- object storage key
banner_key text,
subscriber_count bigint NOT NULL DEFAULT 0, -- maintained by batched counter job
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TYPE video_status AS ENUM ('uploading','processing','ready','failed','blocked');
CREATE TYPE video_visibility AS ENUM ('public','unlisted','private');
CREATE TABLE videos (
id bigint PRIMARY KEY,
public_id char(11) UNIQUE NOT NULL,
channel_id bigint NOT NULL REFERENCES channels(id),
kind text NOT NULL CHECK (kind IN ('video','short','live')),
title text NOT NULL CHECK (length(title) <= 100),
description text NOT NULL DEFAULT '' CHECK (length(description) <= 5000),
status video_status NOT NULL DEFAULT 'uploading',
visibility video_visibility NOT NULL DEFAULT 'private',
duration_ms integer,
width int, height int,
source_key text, -- original upload in object storage
manifest_key text, -- master.m3u8 / manifest.mpd
thumbnail_key text,
view_count bigint NOT NULL DEFAULT 0, -- denormalised, flushed in batches
like_count bigint NOT NULL DEFAULT 0,
comment_count bigint NOT NULL DEFAULT 0,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX videos_channel_published ON videos (channel_id, published_at DESC) WHERE status = 'ready' AND visibility = 'public';
CREATE INDEX videos_ready_recent ON videos (published_at DESC) WHERE status = 'ready' AND visibility = 'public';
-- One row per rendition produced by the transcoder
CREATE TABLE video_renditions (
video_id bigint NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
label text NOT NULL, -- '1080p', '720p', 'audio-128k'
codec text NOT NULL, -- 'avc1.640028', 'av01.0.08M.08', 'mp4a.40.2'
bandwidth integer NOT NULL, -- peak bps for the manifest
width int, height int,
playlist_key text NOT NULL,
bytes bigint NOT NULL,
PRIMARY KEY (video_id, label, codec)
);
CREATE TABLE subscriptions (
user_id bigint NOT NULL REFERENCES users(id),
channel_id bigint NOT NULL REFERENCES channels(id),
notify text NOT NULL DEFAULT 'personalized' CHECK (notify IN ('all','personalized','none')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, channel_id)
);
CREATE INDEX subscriptions_by_channel ON subscriptions (channel_id, user_id); -- fanout scans
CREATE TABLE video_reactions (
user_id bigint NOT NULL,
video_id bigint NOT NULL,
value smallint NOT NULL CHECK (value IN (-1, 1)),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, video_id)
);
-- Comments: hash-partitioned by video so one hot video never bloats a global index
CREATE TABLE comments (
id bigint NOT NULL,
video_id bigint NOT NULL,
parent_id bigint, -- one level of replies
author_id bigint NOT NULL,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 10000),
like_count integer NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'visible', -- 'held', 'removed'
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (video_id, id)
) PARTITION BY HASH (video_id);
CREATE TABLE comments_p0 PARTITION OF comments FOR VALUES WITH (MODULUS 16, REMAINDER 0);
-- ... p1 .. p15
CREATE INDEX comments_newest ON comments (video_id, id DESC) WHERE parent_id IS NULL AND status = 'visible';
CREATE INDEX comments_top ON comments (video_id, like_count DESC, id DESC) WHERE parent_id IS NULL AND status = 'visible';
-- Watch history: range-partitioned by month, dropped after the retention window
CREATE TABLE watch_history (
user_id bigint NOT NULL,
video_id bigint NOT NULL,
watched_at timestamptz NOT NULL,
position_ms integer NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, watched_at, video_id)
) PARTITION BY RANGE (watched_at);
CREATE TABLE playlists (
id bigint PRIMARY KEY,
owner_id bigint NOT NULL REFERENCES users(id),
title text NOT NULL,
visibility video_visibility NOT NULL DEFAULT 'private',
kind text NOT NULL DEFAULT 'user' CHECK (kind IN ('user','watch_later','liked')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE playlist_items (
playlist_id bigint NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
video_id bigint NOT NULL,
position numeric NOT NULL, -- fractional index for cheap reordering
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, video_id)
);
CREATE INDEX playlist_items_order ON playlist_items (playlist_id, position);
-- Transactional outbox: written in the same transaction as the domain change
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
topic text NOT NULL, -- 'video.published', 'comment.created'
key text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;
Why it is shaped this way
- Counters are denormalised and eventually consistent.
view_count,like_countandsubscriber_countare written by background jobs in batches, never withUPDATE … SET n = n + 1per request. A single popular video would otherwise turn one row into a lock hotspot. The source of truth for views is the event stream (ClickHouse). Likes and subscriptions are true rows (video_reactions,subscriptions), so you can always recount. - Comments are hash-partitioned by
video_id. Every comment query is "comments for this video, sorted", so the partition key matches the access path. The composite primary key(video_id, id)lets keyset pagination use the index directly. The partial indexes skip removed and held comments. - Watch history is range-partitioned by month. It is the highest-volume user table. Old partitions are detached and dropped (or moved to cold storage) instead of being
DELETEd, which avoids vacuum storms. Users who pause history simply stop producing rows. - The outbox decouples the database from the message bus. Publishing a video writes
videos.status = 'ready'and anoutboxrow in one transaction. A relay (Debezium or a small poller) pushes it to NATS JetStream or Kafka. Nothing is ever "saved but not announced" or "announced but not saved".
Sharding plan
Up to a few million MAU, a single primary with read replicas is enough for metadata. Video rows are small, and the heavy traffic (segments) never touches Postgres. When writes outgrow one primary, shard by channel_id for videos, renditions and playlists (creator-centric reads), and by video_id for comments and reactions. Citus does this inside Postgres, or you can route in the application with a shard map in Valkey. Subscriptions are sharded by user_id for "my subscriptions" reads, with a secondary copy keyed by channel_id for fanout. Keeping both directions is cheaper than scatter-gather.
Other stores
| Store | Holds | Why |
|---|---|---|
| Object Storage (S3 API) | Originals, HLS/CMAF segments, thumbnails, storyboards, captions | Cheap, durable, CDN origin |
| ClickHouse | View and watch-time events, ad/analytics rollups | Columnar compression on billions of rows a day |
| Valkey | Sessions, rate limits, hot counters, feed caches, presence | Sub-millisecond reads, atomic increments |
| OpenSearch or Meilisearch | Titles, descriptions, captions, channel names | Typo tolerance, facets, BM25 + popularity boosts |
| Vector index (pgvector or Qdrant) | Video and user embeddings | Candidate generation for recommendations |
API design
The clone has four route handlers. A production API keeps the same resource shapes and adds auth, versioning and the full surface below. REST over JSON is the right default. Clients are browsers and mobile apps, responses cache well at the edge, and the few realtime needs go over a separate gateway.
Endpoints
| Method | Path | Purpose | Cache |
|---|---|---|---|
| GET | /v1/feed/home?cursor= | Personalised home candidates | private, 30 s |
| GET | /v1/videos/{publicId} | Video metadata + manifest URL | public, s-maxage=60, stale-while-revalidate |
| GET | /v1/videos/{id}/related | Up next | public, 5 min |
| POST | /v1/uploads | Create upload session, returns multipart presigned URLs | none |
| POST | /v1/uploads/{id}/complete | Finalise multipart, enqueue transcode | none |
| PATCH | /v1/videos/{id} | Title, description, visibility | none |
| GET | /v1/videos/{id}/comments?sort=top|new&cursor= | List comments | public, 15 s |
| POST | /v1/videos/{id}/comments | Create comment (Idempotency-Key required) | none |
| PUT / DELETE | /v1/videos/{id}/reaction | Like / dislike / clear | none |
| PUT / DELETE | /v1/channels/{id}/subscription | Subscribe / unsubscribe | none |
| POST | /v1/views | Batched view and heartbeat events (beacon) | none |
| GET | /v1/search?q=&type=&cursor= | Search | public, 60 s |
| GET/POST/DELETE | /v1/me/history, /v1/me/playlists, /v1/playlists/{id}/items | Library | private |
| GET | /v1/me/notifications?cursor= | Notification inbox | private |
| POST | /v1/push/subscriptions | Register a Web Push endpoint | none |
Cursor pagination
Offset pagination breaks on a fast-moving comment thread (rows shift between pages) and gets slower the deeper you go. Every list uses an opaque cursor that encodes the last row's sort key:
-- "Newest" comments page after cursor (id = 7311229938211)
SELECT id, author_id, body, like_count, created_at
FROM comments
WHERE video_id = $1 AND parent_id IS NULL AND status = 'visible'
AND id < $2
ORDER BY id DESC
LIMIT 21; -- fetch limit+1 to know whether there is a next page
-- "Top" comments: the cursor carries (like_count, id)
SELECT id, author_id, body, like_count, created_at
FROM comments
WHERE video_id = $1 AND parent_id IS NULL AND status = 'visible'
AND (like_count, id) < ($2, $3)
ORDER BY like_count DESC, id DESC
LIMIT 21;
The cursor is base64url JSON ({"l":42,"i":"7311…"}) signed with an HMAC, so clients can't forge arbitrary scans. The clone returns nextCursor in the same position in the response so the front end won't change.
Idempotency
Mobile networks retry. Every mutating POST accepts an Idempotency-Key (a UUIDv4 the client creates per user action). The server stores key → (status, response body) in Valkey for 24 hours with SET key value NX EX 86400. A replay returns the stored response, and a concurrent duplicate sees the in-flight marker and gets 409. The clone does this with an in-memory map. Uploads are idempotent by construction: the upload session id is the key, and S3 multipart part numbers make part retries safe.
Rate limits
| Action | Limit (per user unless noted) | Algorithm |
|---|---|---|
| Anonymous API reads | 600/min per IP | Token bucket at the edge (Cloudflare/Bunny rules) |
| Comments | 10/min, 200/day; stricter for new accounts | Sliding window in Valkey |
| Reactions / subscriptions | 120/min | Token bucket |
| Upload sessions | 20/day (raise with channel verification) | Fixed window |
| Search | 60/min | Token bucket |
Limits return 429 with Retry-After (the clone already does this for comments) and RateLimit-Policy/RateLimit headers.
Versioning and errors
Prefix with /v1 and only make additive changes within a version. Errors use RFC 9457 problem details (type, title, status, detail, plus errors[] for field validation). The front end maps 422 to inline form errors, as the clone's comment box does.
Why not GraphQL?
A watch page needs video + channel + related + first comments page: four requests that can run in parallel from a React Server Component, each with its own cache policy. GraphQL would merge them into one uncacheable POST.
Scaling a video platform to millions of users
Capacity model and architecture drafted with Claude Opus 5.5 inside Claude Code. All figures are estimates with the arithmetic shown.
A video platform is two systems with very different shapes. The control plane (metadata, users, comments, search, recommendations) is a normal web application, read-heavy and latency-sensitive. The media plane (uploads, transcoding, segment delivery) is a bandwidth and CPU problem, and it is where almost all the money goes. Scale them separately.
Back-of-envelope capacity (estimates)
Assumptions. They are estimates; replace them with your own analytics once you have traffic:
- DAU/MAU = 20%. Average watch time is 10 minutes per DAU per day.
- Average delivered bitrate across devices is 2 Mbps, so one minute watched ≈ 2,000,000 / 8 × 60 = 15 MB of egress.
- 0.5% of DAU upload one video a day, average 8 minutes long.
- Stored bytes per uploaded minute: a 4-rung H.264 ladder (1080p 5 Mbps + 720p 3 Mbps + 480p 1.2 Mbps + 360p 0.7 Mbps + 128 kbps audio ≈ 10 Mbps ≈ 75 MB/min) plus the original kept at about 10 Mbps (75 MB/min) = 150 MB per minute.
- Each DAU makes about 80 API reads a day (feed, watch metadata, comments, search) and about 25 writes (views, heartbeats, reactions). Peak is 3× the average.
| Metric | 50K MAU | 500K MAU | 5M MAU | 10M MAU |
|---|---|---|---|---|
| DAU (20%) | 10K | 100K | 1M | 2M |
| Minutes watched/day (×10) | 100K | 1M | 10M | 20M |
| Egress/day (×15 MB) | 1.5 TB | 15 TB | 150 TB | 300 TB |
| Egress/month (×30) | 45 TB | 450 TB | 4.5 PB | 9 PB |
| Uploads/day (0.5% DAU) | 50 | 500 | 5,000 | 10,000 |
| New storage/day (×8 min ×150 MB) | 60 GB | 600 GB | 6 TB | 12 TB |
| New storage/month | 1.8 TB | 18 TB | 180 TB | 360 TB |
| API reads avg / peak rps (×80/86,400, ×3) | 9 / 28 | 93 / 280 | 926 / 2,800 | 1,850 / 5,600 |
| API writes avg / peak rps (×25) | 3 / 9 | 29 / 87 | 290 / 870 | 580 / 1,740 |
| HLS segment requests at the CDN (6 s segments → 100 per DAU/day) | 12 /s | 116 /s | 1,160 /s | 2,300 /s |
What the table tells you:
- Egress dominates everything. At 5M MAU you deliver about 4.5 PB a month. Even at €4–5 per TB on a volume CDN that is about €18K–23K a month, more than all compute combined. Bitrate efficiency (better encoders, per-title ladders, AV1) and where the bytes come from (your own unmetered servers vs a metered CDN) are the biggest levers.
- Storage grows forever. 180 TB of new data a month at 5M MAU is about 2.2 PB after a year. Tiering (deleting originals after a grace period, cold storage for the long tail, re-encoding old videos with better codecs) has to be designed in early.
- The API is small. 2,800 peak read rps is a handful of Node processes behind a cache. Most of those reads (video metadata, channel pages, comments page one) are cacheable for seconds to minutes at the CDN.
Architecture
Read it top to bottom as a request path:
- Clients (web, iOS, Android, TV) →
- CDN / edge (Cloudflare or Bunny for global PoPs, plus your own cache servers in Germany/Finland): HLS/CMAF segments, thumbnails, static JS/CSS, cacheable API GETs; WAF, bot rules and per-IP rate limits →
- Hetzner Load Balancer (TLS termination or TCP passthrough) →
- Stateless app tier: Next.js (SSR/RSC) and the API service, running as containers on k3s. Any node can serve any request. Sessions live in Valkey, never in memory →
- Realtime gateway (Centrifugo) for comments, live chat and notification badges, subscribed to the message bus →
- Data tier:
- PgBouncer (transaction pooling) → PostgreSQL primary + streaming replicas (reads go to replicas unless the user just wrote)
- Valkey: cache, sessions, rate limits, hot counters
- NATS JetStream: durable work queues (transcode, fanout, moderation) and event streams (views, outbox relay)
- ClickHouse: view and watch-time analytics
- OpenSearch or Meilisearch: search
- Media pipeline: upload → Object Storage (originals) → transcode queue → FFmpeg CPU/GPU workers → Object Storage (renditions) → CDN.
- Async workers: notification fanout, Web Push, counter flushers, recommendation feature jobs, moderation scanners.
Stateless app tier
Next.js runs with output: 'standalone' in a container. Nothing that matters lives on the local disk: uploads go straight to object storage through presigned URLs, and the Next.js cache handler is pointed at Valkey so every replica shares rendered data. Horizontal scaling is then just "add replicas". Use a Kubernetes HPA on CPU and p95 latency, and drain connections on SIGTERM so deploys don't drop requests.
Caching strategy
| Layer | What | TTL / invalidation |
|---|---|---|
| CDN | Segments and init files (immutable, content-addressed paths) | 1 year, immutable |
| CDN | Master playlists, thumbnails | 1 hour; purge on re-transcode |
| CDN | Public API GETs (/v1/videos/{id}, channel pages, comments page 1) | s-maxage=15–60, stale-while-revalidate=300 |
| Next.js | 'use cache' data functions with cacheTag('video:'+id) | revalidateTag from the publish worker |
| Valkey | Hot objects (video cards, channel cards), home-feed candidate lists, per-user subscription lists | 1–10 min with jitter; write-through on mutation |
| Process | Tiny LRU for config and category lists | 30 s |
Protect against stampedes. With request coalescing (singleflight) in the API and stale-while-revalidate at the CDN, a viral video's metadata is fetched from Postgres about once a minute, however many people open it.
PostgreSQL: primary, replicas, PgBouncer
- PgBouncer in transaction mode sits in front of every Postgres node. 64 app pods × 20 connections would be 1,280 backends. PgBouncer collapses that to a pool of about 100 real connections.
- Read routing: the API sends reads to replicas, except for a short read-your-writes window. After a user mutates something, a 5-second "sticky primary" flag in their session routes their reads to the primary.
- Replication: one synchronous standby in a different Hetzner datacenter in the same region (for example Falkenstein primary, Nuremberg standby) plus asynchronous read replicas. Patroni with etcd handles failover.
- Partitioning:
commentsis hash-partitioned andwatch_historyrange-partitioned, as shown in the data model.pg_partmanmanages new monthly partitions.
Queues
NATS JetStream is the default here. It is a single small Go binary, gives you work queues with acks and redelivery, key-value buckets, and pub/sub for the realtime backplane, and a 3-node cluster runs on modest servers. Use one stream per concern: TRANSCODE (work queue, explicit ack, max_deliver: 5, dead-letter on failure), FANOUT, VIEWS (limits-based retention, 24 h), OUTBOX. Move the high-volume analytics stream (views, heartbeats) to Kafka or Redpanda once it passes a few hundred thousand events a second or you need long replay windows for ML.
CDN and multi-region
Video is the easiest workload to globalise, because segments are immutable files. The hard part is the control plane.
- Media: segments are cached at the edge for a year. Your own Hetzner AX boxes with unmetered 1 Gbit/s ports in DE/FI act as a regional cache tier (nginx
proxy_cache) in front of Object Storage. A global CDN covers everyone outside Europe and absorbs spikes. Route EU traffic to your boxes and the rest to the CDN with weighted DNS or the CDN's origin-shield settings. - Control plane: start single-region (Germany) with all writes there. Add read-only API replicas plus Postgres async replicas in Ashburn (US) and Singapore as latency demands. Writes (comments, likes) go to the home region and their latency is hidden by optimistic UI. Only consider multi-primary (CockroachDB, Yugabyte, or sharding users by home region) once you have a real regulatory or latency need. It is a large complexity jump.
Deploying on Hetzner
Server plan drafted with Claude from Hetzner's own price pages, fetched by Claude Code on 11 November 2026. Verify prices on hetzner.com before ordering.
Hetzner is a good fit for a video platform because its two cheapest resources, unmetered dedicated bandwidth in Germany and Finland and plenty of CPU cores, are exactly what video needs. Its limits are real, though, and you should plan around them from day one:
- Regions: Falkenstein and Nuremberg (Germany), Helsinki (Finland), Ashburn VA and Hillsboro OR (USA), Singapore. Dedicated AX and GPU servers are in Germany and Finland. US and Singapore are cloud-only, with much smaller included traffic (1–5 TB per server in the US).
- No managed PostgreSQL. You run Postgres yourself (Patroni + pgBackRest, below), or use an external managed provider over a private link.
- Global latency needs a CDN in front. Pair Hetzner with Cloudflare or Bunny for PoPs worldwide.
- Some shared-vCPU cloud plans are periodically marked "currently not available" in some locations. Have a fallback size in your Terraform variables.
All prices below are excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering. Cloud prices are the Nuremberg/Falkenstein (NBG1) column. Helsinki is about 10–15% cheaper for most plans. Dedicated servers carry one-time setup fees (for example €49 for AX42, €129 for AX102) that are not included in monthly totals.
Launch (0–50K MAU): Docker Compose + Ansible on Cloud
- Network: one Hetzner Cloud Network (10.0.0.0/16). Every server gets a private IP, and only the Load Balancer and a bastion have public ingress. Cloud Firewall: 443 on the LB, 22 only from the bastion/VPN (WireGuard or Tailscale).
- App: 2× CPX32 (4 vCPU / 8 GB, €35.99) running Next.js + API in Docker Compose behind an LB11 (from ~€6/month, check console). Deploys are Ansible playbooks doing a rolling
docker compose pull && up -d, one node at a time. - Database: CCX23 (4 dedicated vCPU / 16 GB, €86.49) for PostgreSQL 17 with PgBouncer, plus a CPX32 streaming replica in the other German location. Use dedicated vCPU for the primary, because noisy neighbours hurt p99 latency. Put the data directory on the local NVMe, not a Volume: Volumes are network-attached and slower.
- Cache + queue + realtime: one CPX22 (€19.99) with Valkey, NATS JetStream and Centrifugo. This is a single point of failure for non-critical paths only. If it dies, the app degrades (no live comments, no cache) but keeps serving.
- Transcoding: one CCX33 (8 dedicated vCPU / 32 GB, €138.99) running FFmpeg workers pulling from NATS. At 50 uploads a day (≈400 minutes) it has plenty of headroom (estimate).
- Object Storage: one bucket for originals and one for renditions (€6.49 base incl. 1 TB storage + 1 TB egress, then ~€6.35/TB-month and ~€1/TB egress). Ingress, internal traffic and API calls are free, so the pipeline's reads and writes cost nothing.
- CDN: Bunny or Cloudflare in front of the renditions bucket, with the bucket private and the CDN authenticating with signed origin requests.
- Backups: pgBackRest to a separate Object Storage bucket (in a different location from the primary) with full weekly, differential daily and continuous WAL archiving. Test a restore monthly.
Growth (~500K MAU): k3s, first dedicated servers
- Orchestration: k3s (3 server nodes for an HA control plane on CPX22s, or embedded etcd on the app nodes) with the Hetzner Cloud Controller Manager and CSI driver, so
LoadBalancerservices create Hetzner LBs andPersistentVolumeClaimscreate Volumes automatically. Argo CD for GitOps. - App: 4× CPX42 (8 vCPU / 16 GB, €69.99) as k3s agents. HPA on CPU and request latency.
- Postgres: move to 2× AX102 (16-core Ryzen 9 7950X3D, 128 GB ECC, 2×1.92 TB NVMe datacenter drives in RAID1, from €159). The large L3 cache and NVMe help Postgres a lot. Connect the dedicated servers to the Cloud Network with a vSwitch, so app pods reach Postgres over the private network. Patroni manages failover.
- Valkey: primary + replica on 2× CPX32 with Sentinel. NATS: a 3-node JetStream cluster on CPX22s.
- Transcoding: 2× AX42 (8-core Ryzen 7 PRO, 64 GB, from €79) for the quality CPU ladder, plus 1× GEX45 (RTX PRO 4000 Blackwell SFF 24 GB, €214) for an NVENC fast path, so a 720p rendition is watchable within a minute of upload. KEDA scales worker pods on JetStream consumer lag.
- Edge cache: 3× AX42 in Falkenstein, Nuremberg and Helsinki running nginx as a cache in front of Object Storage. Each has an unmetered 1 Gbit/s port, about 190 TB/month at a 60% average utilisation (1 Gbit/s × 0.6 × 2.6M s ÷ 8 ≈ 195 TB). EU viewers are served from these. The CDN covers the rest of the world and acts as overflow.
Scale (~5M MAU): mostly dedicated
At this point cloud instances cost more than equivalent AX servers for steady load. Keep cloud for bursty and control-plane roles, and move steady workloads to dedicated:
- App/API: 8× AX42 as k3s agents (or vanilla Kubernetes with kubeadm/Talos if you want more control).
- Postgres: 3× AX162 (48-core EPYC 9454P, 256 GB+, from €319): primary, synchronous standby, async replica. Start planning shards (Citus or app-level) for comments and reactions.
- Transcoding: 10× AX102 CPU workers + 4× GEX45 GPU workers. ClickHouse: 3× AX102 (replicated, sharded by video). Edge cache: about 14× AX42 (or fewer servers with the 10 Gbit uplink add-on; price it in the console).
When to move from cloud to dedicated
Move a role to an AX server when (a) its load is steady around the clock (databases, edge caches, transcoding at volume), (b) it needs local NVMe performance, or (c) it moves a lot of bytes: a dedicated box's traffic in DE/FI is unmetered, while cloud servers include a fixed traffic allowance (20 TB in the EU). Keep on cloud anything that scales up and down daily, needs to come up in minutes, or is small.
Hetzner server plan by growth stage
Prices in EUR per month, excl. VAT, as listed on hetzner.com in November 2026. Verify current pricing and availability before ordering.
Launch· 0–50K MAU
≈ €613.98 / monthDocker Compose + Ansible on Hetzner Cloud in Nuremberg/Falkenstein. ~45 TB/month delivered (estimate), all through a CDN. Single transcode box. LB price is 'from ~€6/month, check console'; CDN is a third-party estimate at ~€5/TB. Prices excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (Next.js, PgBouncer) | CPX32 | 4 vCPU / 8 GB / 160 GB NVMe (shared) | 2 | €35.99 | €71.98 |
| PostgreSQL 17 primary | CCX23 | 4 dedicated vCPU / 16 GB / 160 GB NVMe | 1 | €86.49 | €86.49 |
| PostgreSQL streaming replica | CPX32 | 4 vCPU / 8 GB / 160 GB NVMe | 1 | €35.99 | €35.99 |
| Valkey + NATS JetStream + Centrifugo | CPX22 | 2 vCPU / 4 GB / 80 GB NVMe | 1 | €19.99 | €19.99 |
| Transcode workers (FFmpeg, CPU) | CCX33 | 8 dedicated vCPU / 32 GB / 240 GB NVMe | 1 | €138.99 | €138.99 |
| Load Balancer | LB11 | from ~€6/month, check console | 1 | €6.00 | €6.00 |
| Object Storage (originals + renditions) | Object Storage | €6.49 base (1 TB + 1 TB egress) + ~3 TB stored + ~5 TB origin egress | 1 | €29.54 | €29.54 |
| CDN delivery (third-party, estimate) | — (Bunny / Cloudflare) | ~45 TB/month at ~€5/TB (estimate) | 1 | €225.00 | €225.00 |
Growth· ~500K MAU
≈ €2,762.51 / monthk3s with Hetzner CCM/CSI, first dedicated AX servers on a vSwitch. ~450 TB/month delivered (estimate): ~60% from 3 own EU edge caches (unmetered 1 Gbit/s), ~40% via CDN. Setup fees excluded. Prices excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (k3s agents) | CPX42 | 8 vCPU / 16 GB / 320 GB NVMe | 4 | €69.99 | €279.96 |
| Realtime gateway (Centrifugo) | CPX32 | 4 vCPU / 8 GB | 2 | €35.99 | €71.98 |
| PostgreSQL primary + standby (Patroni) | AX102 | Ryzen 9 7950X3D 16c / 128 GB ECC / 2×1.92 TB NVMe DC | 2 | €159.00 | €318.00 |
| Valkey primary + replica | CPX32 | 4 vCPU / 8 GB | 2 | €35.99 | €71.98 |
| NATS JetStream cluster | CPX22 | 2 vCPU / 4 GB | 3 | €19.99 | €59.97 |
| Transcode CPU workers | AX42 | Ryzen 7 PRO 8700GE 8c / 64 GB ECC / 2×512 GB NVMe | 2 | €79.00 | €158.00 |
| Transcode GPU fast path (NVENC) + captions | GEX45 | i5-13500 / 64 GB / RTX PRO 4000 Blackwell SFF 24 GB | 1 | €214.00 | €214.00 |
| Search (Meilisearch / OpenSearch) | CCX23 | 4 dedicated vCPU / 16 GB | 1 | €86.49 | €86.49 |
| Observability (Prometheus, Loki, Tempo, Grafana) | CPX42 | 8 vCPU / 16 GB | 1 | €69.99 | €69.99 |
| EU edge cache (nginx, unmetered 1 Gbit/s) | AX42 | 8c / 64 GB / 2×512 GB NVMe | 3 | €79.00 | €237.00 |
| Load Balancers | LB11 | from ~€6/month each, check console | 2 | €6.00 | €12.00 |
| Object Storage | Object Storage | ~40 TB stored + ~30 TB cache-fill egress (estimate) | 1 | €283.14 | €283.14 |
| CDN delivery outside EU + overflow (estimate) | — (Bunny / Cloudflare) | ~180 TB/month at ~€5/TB (estimate) | 1 | €900.00 | €900.00 |
Scale· ~5M MAU
≈ €18,460.14 / monthMostly dedicated servers in DE/FI, cloud for bursty roles. ~4.5 PB/month delivered (estimate): ~2.7 PB from ~14 own edge caches, ~1.8 PB via CDN at a committed ~€4/TB (estimate). Setup fees and 10 Gbit uplink add-ons excluded. Prices excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (Kubernetes workers) | AX42 | 8c / 64 GB ECC | 8 | €79.00 | €632.00 |
| Realtime gateway (Centrifugo) | AX42 | 8c / 64 GB ECC | 3 | €79.00 | €237.00 |
| PostgreSQL primary + sync standby + async replica | AX162 | EPYC 9454P 48c / 256 GB DDR5 ECC / 2×1.92 TB NVMe DC | 3 | €319.00 | €957.00 |
| Valkey cluster | AX42 | 8c / 64 GB ECC | 3 | €79.00 | €237.00 |
| NATS JetStream / Kafka | AX42 | 8c / 64 GB ECC | 3 | €79.00 | €237.00 |
| ClickHouse (views, watch time) | AX102 | 16c / 128 GB ECC / 2×1.92 TB NVMe DC | 3 | €159.00 | €477.00 |
| Transcode CPU workers | AX102 | 16c / 128 GB ECC | 10 | €159.00 | €1,590.00 |
| Transcode GPU workers (NVENC, captions) | GEX45 | RTX PRO 4000 Blackwell SFF 24 GB | 4 | €214.00 | €856.00 |
| Search cluster | AX102 | 16c / 128 GB ECC | 2 | €159.00 | €318.00 |
| Recommendations (embeddings + ranking) | GEX45 | RTX PRO 4000 Blackwell SFF 24 GB | 2 | €214.00 | €428.00 |
| Observability | AX42 | 8c / 64 GB ECC | 2 | €79.00 | €158.00 |
| EU edge cache (unmetered 1 Gbit/s each) | AX42 | 8c / 64 GB ECC / 2×512 GB NVMe | 14 | €79.00 | €1,106.00 |
| Load Balancers | LB11 | from ~€6/month each, check console | 3 | €6.00 | €18.00 |
| Object Storage | Object Storage | ~600 TB stored + ~200 TB cache-fill egress (estimate) | 1 | €4,009.14 | €4,009.14 |
| CDN delivery outside EU + overflow (estimate) | — (Bunny / Cloudflare, committed) | ~1.8 PB/month at ~€4/TB (estimate) | 1 | €7,200.00 | €7,200.00 |
Hyperscale· 10M+ MAU
≈ €48,464.08 / monthTwo active EU regions (DE primary, FI hot standby + edge), US cloud read nodes, Postgres sharded 4 ways. ~9 PB/month delivered (estimate). At this size compare Object Storage with self-run Ceph/MinIO on storage servers. Prices excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API | AX42 | 8c / 64 GB ECC | 16 | €79.00 | €1,264.00 |
| Realtime gateway | AX42 | 8c / 64 GB ECC | 6 | €79.00 | €474.00 |
| PostgreSQL shards (4 × primary + replica) | AX162 | EPYC 9454P 48c / 256 GB+ ECC | 8 | €319.00 | €2,552.00 |
| Valkey cluster | AX42 | 8c / 64 GB ECC | 6 | €79.00 | €474.00 |
| Kafka / Redpanda event firehose | AX102 | 16c / 128 GB ECC / NVMe DC | 5 | €159.00 | €795.00 |
| ClickHouse cluster | AX102 | 16c / 128 GB ECC | 6 | €159.00 | €954.00 |
| Transcode CPU workers (incl. AV1 re-encodes) | AX102 | 16c / 128 GB ECC | 24 | €159.00 | €3,816.00 |
| Transcode GPU workers | GEX45 | RTX PRO 4000 Blackwell SFF 24 GB | 8 | €214.00 | €1,712.00 |
| Recommendation training + inference | GEX63 | Core Ultra 7 265 / 128 GB / RTX PRO 6000 Blackwell Max-Q 96 GB | 2 | €999.00 | €1,998.00 |
| Search cluster | AX102 | 16c / 128 GB ECC | 4 | €159.00 | €636.00 |
| Observability | AX102 | 16c / 128 GB ECC | 2 | €159.00 | €318.00 |
| EU edge cache (unmetered) | AX42 | 8c / 64 GB ECC | 30 | €79.00 | €2,370.00 |
| US read API + realtime (Ashburn) | CPX31 (US) | 4 vCPU / 8 GB / 160 GB, 1–5 TB traffic | 6 | €62.99 | €377.94 |
| Load Balancers | LB11 | from ~€6/month each, check console | 4 | €6.00 | €24.00 |
| Object Storage | Object Storage | ~2 PB stored + ~500 TB cache-fill egress (estimate) | 1 | €13,199.14 | €13,199.14 |
| CDN delivery outside EU + overflow (estimate) | — (committed CDN contract) | ~5 PB/month at ~€3.5/TB (estimate) | 1 | €17,500.00 | €17,500.00 |
Realtime: live comments, upload notifications and live chat
Real-time design and code drafted with Claude Opus 5.5 in Claude Code.
A video platform needs three realtime features, each with different delivery requirements:
| Feature | Volume | Latency target | Delivery guarantee |
|---|---|---|---|
| Live comment updates on a watch page | Bursty; viral videos get thousands of comments per minute | < 2 s | Best effort (a missed comment shows on refresh) |
| Notifications for new uploads to subscribers | Fanout of 1 upload → up to millions of subscribers | Seconds to a few minutes | At-least-once, durable inbox |
| Live-stream chat | Up to tens of thousands of messages/s in one room | < 500 ms | Best effort with ordering per room |
Transport: WebSockets, SSE or WebTransport?
- Server-Sent Events are enough for one-way pushes (new comments, notification badges, view-count ticks). They run over plain HTTP/2, reconnect automatically, and resume with
Last-Event-ID. The clone uses SSE for live comments (/api/comments/stream), with aretry: 3000hint and a 20-second heartbeat comment so proxies don't time out the stream. - WebSockets are the right choice for live chat, where clients send at a high rate and you want one multiplexed connection per tab for chat, reactions and presence.
Use one gateway that speaks both. Centrifugo is a good default: a Go server with WebSocket, SSE, HTTP-streaming and WebTransport transports, channel-based pub/sub, presence, history with recovery, JWT auth, and a NATS or Redis/Valkey broker so several gateway nodes behave as one. Soketi (Pusher protocol) or a custom uWebSockets.js/Go gateway are alternatives if you have special needs.
Topology
- Centrifugo nodes run behind the Hetzner Load Balancer (TCP or HTTP mode with WebSocket upgrade allowed, health check on
/health). The connection token is a short-lived JWT minted by the API with the user id and allowed channels, so the gateway never calls back into your app for auth. - Backplane: NATS (or Valkey). The API publishes to Centrifugo's HTTP API after committing a comment (or the outbox relay does), and Centrifugo fans out to every node holding subscribers of
comments:video_123. - Channel design:
comments:{videoId}(public, anyone can subscribe),chat:{streamId}(publish allowed for authenticated users, through a proxy that runs moderation),user:{userId}(private, notifications and badge counts; Centrifugo's personal channel feature).
Fanout for new uploads
When a channel publishes, every subscriber should see it in their notification inbox and subscriptions feed, and opted-in users get a push.
- Fanout-on-write for normal channels. The
video.publishedevent goes from the outbox into theFANOUTstream. Workers page throughsubscriptions_by_channel5,000 user ids at a time and write inbox rows (notifications(user_id, id, video_id, created_at), partitioned by user) plus auser:{id}realtime publish for anyone online. For a channel with 20K subscribers that is 4 batches. - Fanout-on-read for large channels. For a channel with 5M subscribers, writing 5M rows per upload is wasteful when most of those users won't open the app that day. Above a threshold (say 100K subscribers), store the upload once in a
channel_uploadslist. Each user's subscriptions feed is assembled at read time by merging the latest uploads of their (bounded) set of subscribed channels, cached in Valkey. Only users who chose "All notifications" for that channel get a per-user push, and that list is much shorter. - Push delivery runs as a separate, rate-limited stage: Web Push (VAPID) for browsers, APNs for iOS and FCM for Android. Collapse keys per channel stop a burst of uploads from becoming a burst of buzzes. Delete push subscriptions that return 404/410.
Delivery guarantees and backpressure
- Comments: best effort over realtime, durable in Postgres. If a client misses messages during a reconnect, Centrifugo's history recovery (a per-channel ring buffer of the last N publications with stream offsets) replays them. If the gap is bigger than the buffer, the client refetches page one.
- Notifications: the inbox row is the source of truth, written at-least-once with an idempotent key
(user_id, video_id). Realtime and push are just hints to fetch it. - Backpressure: per-connection send buffers are capped. A slow client on a hot live chat gets messages dropped and a "you're behind, jump to live" marker instead of growing memory without limit. For very hot rooms, the server batches publications into 100–250 ms frames and samples. Above a few hundred messages per second nobody can read individual messages anyway.
Live-stream chat specifics
Chat messages are published to chat:{streamId} through a moderation proxy: rate limit per user (1 message per second, slow mode configurable), blocked-terms filter, link policy, and a fast spam classifier. Messages also go to a JetStream stream so moderators can delete after the fact (publish a delete event that clients apply) and so the VOD replay can show chat in sync with the video.
Implementation
The clone's SSE endpoint is a minimal version of this. The route handler subscribes a listener to an in-process pub/sub, writes event: comment frames, and cleans up on request.signal abort. Moving to production means replacing the in-process emitter with a Centrifugo publish (see the code snippets) and the EventSource with the centrifuge JS client.
Deep dive: upload, transcoding, ABR delivery, view counting and recommendations
Deep-dive drafted with Claude Opus 5.5 in Claude Code.
This is the core of a video platform: getting a file from a creator's laptop to smooth playback on a phone in another country, cheaply, and then counting and recommending it. The clone simulates this with an object URL and a progress bar. Here is the real pipeline.
1. Upload
- The client calls
POST /v1/uploadswith file size, MIME type and a client checksum. The API creates avideosrow (status = 'uploading'), starts an S3 multipart upload on Object Storage, and returns presigned URLs for each part (8–64 MB parts, depending on file size). - The browser uploads parts in parallel (3–6 at a time) straight to Object Storage. Your servers never proxy the bytes. Ingress to Hetzner Object Storage is free.
- Uploads must be resumable. The client stores the upload id and completed part ETags in IndexedDB. After a crash or network change it asks the API which parts exist (
ListParts) and continues. For mobile apps the tus protocol gives the same behaviour with a standard client library. POST /v1/uploads/{id}/completecallsCompleteMultipartUpload, verifies the size, and writes an outbox event that enqueues aTRANSCODEjob. The creator sees "Processing" immediately, which is the state the clone's progress bar imitates.
2. Probe, scan, transcode
A transcode worker (a container running FFmpeg, pulling from a JetStream work-queue consumer) does:
- Probe with
ffprobe: duration, resolution, frame rate, rotation, HDR metadata, audio layout. Reject corrupt or absurd inputs early (for example over 12 hours or over 8K). - Safety scan: hash the file and a few sampled frames against known-bad hash lists, run a cheap classifier on sampled frames, and check audio fingerprints for copyright if you license that. Flagged uploads go to a human review queue and stay private.
- Fast first rendition: on a GPU worker, NVENC produces a 720p H.264 rendition in a fraction of real time, so the video can be published (or previewed by the creator) within about a minute.
- Full ladder: on CPU workers, libx264 (or SVT-AV1 for popular videos) produces the full ladder with better quality per bit. Long videos are split into GOP-aligned chunks (for example 60-second segments) and encoded in parallel on many workers, then the playlists are stitched together. This turns a 2-hour upload into minutes of wall-clock time.
- Package as fMP4/CMAF segments with both an HLS master playlist (
master.m3u8) and a DASH manifest (manifest.mpd) referencing the same segment files. One copy of the bytes serves Apple devices (HLS) and everything else (either). - Thumbnails and storyboards: pick candidate frames (scene changes, faces, sharpness), generate a sprite sheet of small frames every few seconds for the scrubber preview, and let the creator choose or upload a custom thumbnail.
- Captions: run speech-to-text (Whisper-class models on the GPU box) to produce WebVTT subtitles. These also feed search and recommendations.
- Write
video_renditionsrows, setstatus = 'ready', emitvideo.published(if public) through the outbox, and purge or invalidate caches.
GPU or CPU on Hetzner?
| CPU (AX42 / AX102, libx264/libx265/SVT-AV1) | GPU (GEX45, NVENC) | |
|---|---|---|
| Throughput | A 16-core AX102 encodes a 4-rung 1080p ladder at roughly 3–4× real time with x264 -preset veryfast (estimate, benchmark your content) | NVENC encodes many 1080p streams at once, far faster than real time per stream; one GEX45 roughly matches several AX102s for H.264 throughput (estimate) |
| Quality per bit | Best, especially x264 slow and SVT-AV1; directly lowers egress | Good and improving, but typically needs more bitrate for the same visual quality |
| Cost | €79–159/month per box, unmetered traffic | €214/month (GEX45), plus setup |
| Best for | The final ladder, popular videos, AV1 re-encodes | Fast first rendition, live streams, thumbnails/captions/ML |
The rule that falls out of the cost table is simple: egress costs more than encoding. A 20% bitrate saving at the same quality on a video watched 100,000 times saves far more delivery cost than the extra CPU minutes spent encoding it. So use GPUs for speed (time-to-publish, live), CPUs for efficiency, and re-encode the top few percent of videos by watch time with AV1 a few days after upload, once you know they are popular.
3. ABR ladders: HLS and DASH
Adaptive bitrate streaming splits each rendition into short segments (4–6 s for VOD, 1–2 s parts for low-latency live) and lets the player switch rungs between segments based on measured throughput and buffer. A sensible starting ladder for 16:9 H.264 content:
| Rung | Resolution | Video bitrate | Notes |
|---|---|---|---|
| 1080p | 1920×1080 | 4.5–5 Mbps | Only if the source is ≥1080p |
| 720p | 1280×720 | 2.5–3 Mbps | The workhorse rung |
| 480p | 854×480 | 1–1.2 Mbps | Mobile on weak networks |
| 360p | 640×360 | 0.6–0.7 Mbps | Startup and fallback |
| 240p | 426×240 | 0.3 Mbps | Very poor networks |
| Audio | AAC-LC stereo | 128 kbps | Separate audio rendition (CMAF) |
Improvements once the basics work:
- Per-title encoding: run a fast complexity analysis (or a few trial encodes measured with VMAF) and adjust the ladder per video. A talking-head podcast like The Bhairav Show looks perfect at 1080p and 2.5 Mbps, while a fast game capture needs 6+. This alone often cuts egress 20–40% for talk content.
- Keyframe alignment: force a closed GOP every 2 s (
-g 48 -keyint_min 48 -sc_threshold 0at 24 fps) on every rung, so segments line up and players can switch cleanly. - Codecs: H.264 for universal compatibility, AV1 for capable devices (most recent Android, Chrome, Smart TVs, newer Apple hardware), listed in the same master playlist with
CODECSattributes so each player picks what it supports.
4. Storage on Object Storage
- Layout:
originals/{videoId}/source.mp4(private bucket) andrenditions/{videoId}/{codec}/{rung}/seg_00042.m4splus playlists (renditions bucket, CDN-only access). Content-address segment paths, or include an encode version, so they can be cached for a year withimmutable. - Lifecycle: keep originals for 30–90 days (re-transcodes, appeals), then either delete them or move them to cold storage for creators who pay for archive. Delete unused rungs on videos with no views in a year, and regenerate them on demand if someone shows up.
- Cost check (estimate): at 5M MAU you add about 180 TB a month. At ~€6.35/TB-month, every month of retained uploads adds about €1,140 to the monthly bill, forever. Lifecycle rules are a cost feature, not housekeeping.
5. CDN delivery and egress costs
At 4.5 PB/month (5M MAU estimate) delivery is the largest cost line. Premium CDNs charge per GB and region, and volume CDNs are cheaper (read the terms: some "unlimited" plans exclude video bytes). The cheapest option is your own cache tier on unmetered Hetzner AX servers in DE/FI. The marginal cost of a TB is close to zero up to port capacity: a €79 AX42 with a 1 Gbit/s port delivers about 190 TB/month at 60% utilisation, under €0.50/TB.
The pragmatic design is a hybrid: serve European viewers from your own nginx caches (the origin shield is Object Storage, whose extra egress costs ~€1/TB), and send the rest of the world to a CDN. Use the CDN as overflow for EU spikes too. Monitor cache hit ratio: segment hit ratios of 95%+ are normal because popularity follows a power law. Signed URLs or signed cookies (with expiry and a path prefix) stop hotlinking without breaking caching.
6. View counting at scale
Views look simple and are one of the hardest parts to get right. They must be cheap to record, hard to fake, and eventually exact.
- Client: the player sends a beacon (
navigator.sendBeacon) when playback actually starts and heartbeats every 30 seconds with position and rendition. A "view" counts only after a minimum watched time (for example 30 s, or the whole thing for Shorts). - Ingest:
POST /v1/viewsaccepts batches, validates a signed playback token (issued with the manifest, bound to the video and session), and appends to theVIEWSstream. It never touches Postgres. - Hot counters: a consumer increments Valkey hashes per minute bucket (
HINCRBY views:202611111205 videoId 1) so the watch page can show an approximately live count. - Flush: every minute a flusher atomically renames the bucket, reads it and applies one batched
UPDATE videos SET view_count = view_count + d.n FROM unnest($1, $2). That is one statement for thousands of videos instead of one locked row per view. - Exact and fraud-checked: all raw events also land in ClickHouse. A periodic job dedupes (same session and video within a window), removes bot traffic (datacenter IPs, impossible watch patterns, headless fingerprints), and reconciles the public count. This is why big platforms sometimes "freeze" counts for a while on new videos.
7. Recommendations
The home feed and Up next drive most watch time on a mature platform. Build it in stages:
- Day one (what the clone does): rule-based. Recent uploads from subscriptions, then popular videos in categories you have watched, deduplicated against history. The clone's Up next ranks by shared categories, and "New to you" filters out watched items.
- Candidate generation: several cheap sources, each returning a few hundred ids. Subscriptions, co-watch ("people who watched X then watched Y", computed nightly from ClickHouse sessions), embedding similarity (video embeddings from title, description, captions and thumbnail via a text/image encoder, stored in pgvector or Qdrant, queried with the user's recent-watch embedding), trending in your region and language, and fresh uploads for exploration.
- Ranking: a gradient-boosted model (LightGBM) or a small two-tower/DLRM-style network scores about 500 candidates on predicted watch time and satisfaction (likes, "not interested", survey signals). Features come from a feature store: user history aggregates, video age, CTR, average view duration, creator affinity, device, time of day.
- Serving: candidates and features cached in Valkey, ranking in a small Python or ONNX service on CPU (GPU only for embedding generation and training). Target p95 under 100 ms for the whole feed. Measure everything with online A/B tests on watch time per DAU and 7-day retention, not CTR alone. Optimising CTR gives you clickbait.
Security, abuse prevention and compliance
Threat model and mitigations drafted with Claude in Claude Code.
Authentication and sessions
- OIDC sign-in through a provider you control (Keycloak, Zitadel or Ory self-hosted, or a SaaS IdP), with passkeys (WebAuthn) as the primary factor and email magic links as a fallback. Passkeys remove password reuse and phishing, which are the main ways creator accounts are hijacked.
- Sessions, not long-lived JWTs, in the browser: an opaque session id in an
HttpOnly; Secure; SameSite=Laxcookie, with session data in Valkey. Logout and "sign out of all devices" then work instantly. Use short-lived JWTs (5–15 min) only for service-to-service calls and for the realtime gateway connection token. - Step-up auth for sensitive actions: changing channel email, deleting a channel, adding a payout account or creating an API key needs a fresh passkey assertion.
Authorization
Authorization is resource-based: a user owns channels, and channels own videos and playlists. Every mutation checks ownership in the data layer (WHERE channel_id IN (SELECT id FROM channels WHERE owner_id = $user)), not just in the UI. Channel managers and editors are rows in channel_members(role). Private and unlisted videos are enforced at three layers: API (403), manifest signing (no token, no manifest), and CDN (signed URLs).
OWASP Top 10, applied to a video app
| Risk | Where it bites a video platform | Mitigation |
|---|---|---|
| Broken access control | Editing someone else's video by changing an id; fetching private manifests | Ownership checks in queries; signed, expiring playback URLs; non-guessable public ids |
| Injection | Search, comment filters, sort parameters | Parameterised SQL only; allow-list for sort/filter fields; search engine query builders |
| XSS | Comments, titles, descriptions, channel About pages | React escapes by default; render descriptions with an allow-listed linkifier (no raw HTML); strict CSP |
| SSRF | "Import from URL" uploads, thumbnail fetchers, link previews | Fetch through an egress proxy that blocks private IP ranges and metadata endpoints |
| Insecure design | Unlimited uploads, unbounded transcodes | Quotas, max duration and resolution, per-account transcode budgets |
| Vulnerable components | FFmpeg and image libraries parsing hostile files | Patch fast; run FFmpeg in a sandbox (no network, seccomp, read-only rootfs, CPU/memory/time limits) |
| Auth failures | Credential stuffing on creator accounts | Passkeys, rate limits, breached-password checks for fallback passwords, new-device alerts |
| Logging failures | Not seeing account takeovers or mass-report abuse | Audit log for security events, alerting on anomalies |
Headers and CSP
Set these in middleware or at the edge: Content-Security-Policy with nonces for scripts (Next.js supports per-request nonces), frame-src limited to your own player origin (and youtube-nocookie.com if you embed like the clone), img-src for your thumbnail CDN, media-src for segment hosts (plus blob: for MSE players), connect-src for API and realtime gateway origins. Add Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and Permissions-Policy granting microphone only to your origin (voice search) and denying the rest.
Abuse, spam and moderation
- Rate limits at the edge (per IP) and in Valkey (per user), as in the API section. New accounts get stricter limits and their links are held for review.
- Comment spam: a lightweight classifier (logistic regression on character n-grams, URL reputation and account age) runs inline. Uncertain comments are "held for review" and only visible to the author, not deleted. Creators get blocked-word lists and can ban users from their channel.
- Upload scanning: hash matching against known illegal-content lists, a frame classifier for nudity and violence, copyright fingerprinting if you have licences, and a human review queue with SLAs.
Secrets, encryption and network
- Secrets in a manager (Vault or OpenBao, or SOPS-encrypted files with age keys in GitOps), injected at runtime, never baked into images. Rotate Object Storage keys and database passwords on a schedule.
- In transit: TLS 1.3 at the edge, TLS or WireGuard between servers (Hetzner private networks are isolated but not encrypted, so encrypt anyway),
sslmode=verify-fullfor Postgres. - At rest: LUKS full-disk encryption on dedicated servers holding user data, encrypted backups (pgBackRest
repo1-cipher-type=aes-256-cbc), and server-side encryption on Object Storage buckets with private ACLs. - DDoS: Hetzner includes automatic DDoS protection on all products, which absorbs volumetric network attacks. Layer-7 floods (search spam, login floods) need the CDN/WAF in front plus application rate limits. Hide origin IPs behind the CDN and firewall them to accept only CDN and LB ranges.
Privacy and compliance (GDPR, India's DPDP Act)
- Data minimisation: don't store raw IPs longer than needed for abuse handling. Hash or truncate them in analytics.
- Lawful basis and consent for personalised recommendations and analytics cookies, with a working "pause history", like the clone's History page, and "delete my data".
- Data export (Article 20) and erasure (Article 17) as automated jobs that cover Postgres, ClickHouse, search indexes, object storage and backups (through retention windows).
- Hosting in Germany/Finland helps EU data-residency requirements. Document sub-processors (CDN, email, push providers) and sign DPAs.
- Under India's DPDP Act: notice and consent in plain language, a grievance officer contact, children's data protections (verifiable parental consent, no behavioural ads to minors), and breach notification.
Audit logging
Write an append-only audit log of security-relevant events: sign-ins, passkey changes, role changes, video visibility changes, moderator actions, data exports, admin access. Ship it to separate storage that application credentials can't modify (Object Storage with object lock), and keep it for the period your legal team sets.
Observability and operations
Signals
- Metrics: Prometheus (or VictoriaMetrics once cardinality grows) scraping node exporters, Postgres (
postgres_exporter), PgBouncer, Valkey, NATS, Centrifugo, nginx edge caches and the app's own OpenTelemetry metrics. Grafana dashboards per service, plus a single "platform health" board. - Logs: structured JSON logs shipped by Grafana Alloy (or Vector) to Loki, labelled by service, pod and trace id. No PII in logs, or at least no raw tokens and emails.
- Traces: OpenTelemetry SDK in Next.js (
instrumentation.tswith@vercel/otelor the Node SDK) and in workers, exported to Tempo. Trace a watch-page request through API → Valkey → Postgres, and an upload through API → queue → transcode → publish. - Real user monitoring for video: this is the signal that matters most and that generic APM misses. The player reports startup time (time to first frame), rebuffering ratio, average bitrate, rendition switches and playback errors per CDN, region, device and ISP. Store them in ClickHouse and chart them in Grafana.
SLOs
| SLO | Target | Measured by |
|---|---|---|
| Watch page API availability | 99.9% monthly | Successful responses / total at the LB |
| Video start time | p75 < 1.5 s, p95 < 3 s | Player RUM |
| Rebuffering ratio | < 0.5% of watch time | Player RUM |
| Upload to playable (720p) | p95 < 3 min for videos under 20 min | Pipeline timestamps |
| Comment post latency | p95 < 300 ms | API traces |
| Notification fanout | 99% delivered to inboxes within 5 min | Fanout job metrics |
Alert on error-budget burn rate (fast burn: 2% of the monthly budget in 1 hour, page; slow burn: 10% in 3 days, ticket), not on raw CPU thresholds. Page only for user-visible symptoms. Everything else becomes a ticket.
On-call
The runbooks to write first are: Postgres failover (Patroni switchover and verification), "transcode backlog growing" (scale workers, check for a poison file), "CDN hit ratio dropped" (purge storm or cache-key change), "comment spam wave" (tighten limits, enable hold-for-review), and a restore from backup.
Load testing
Use k6 with realistic scenarios rather than one endpoint hammered in a loop: browsing users (home → watch → comments page), a viral-video spike (50% of traffic on one id, which tests caching and counter batching), and a comment storm on one video (tests partitioning and the realtime fanout). Test segment delivery separately against the edge cache with real segment sizes. The k6 script in the code snippets shows the shape. Run it in CI against staging before each release, with thresholds that fail the build.
Chaos and restore drills
- Monthly: restore last night's pgBackRest backup to a scratch server, run integrity checks, and time it. Restore time is your real RTO, whatever the plan says.
- Quarterly: kill a Postgres primary in staging during load, kill a Valkey node, partition a NATS node, and take a transcode worker offline mid-job (the job must be redelivered and finish once).
CI/CD
GitHub Actions or Forgejo Actions (self-hosted runners on a CPX42 are cheap): lint, type-check, unit tests, next build, container build with SBOM and vulnerability scan (Trivy), push to a registry, then Argo CD syncs to staging automatically and to production after approval. Use canary deploys (10% → 50% → 100%) with automatic rollback when error-rate SLOs burn. Database migrations are expand/contract: add columns and backfill first, switch reads, remove old columns in a later release. A migration never ships in the same deploy that depends on it.
Cost breakdown
Cost model calculated by Claude Code from the Hetzner line items above, excl. VAT.
All Hetzner prices are excl. VAT, as listed on hetzner.com in Nov 2026 — verify before ordering. Cloud servers use NBG1 prices. Dedicated servers exclude one-time setup fees. CDN lines are estimates for a volume CDN at about €4–5/TB. Check your provider's current video-delivery pricing, because it varies a lot. Server counts come from the capacity table in "Scaling to millions" and are estimates to adjust after load tests.
| Tier | MAU | Monthly total (est.) | Hetzner share | CDN share | Cost per 1K MAU |
|---|---|---|---|---|---|
| Launch | 0–50K | €614 | €389 | €225 | €12.28 at 50K |
| Growth | ~500K | €2,763 | €1,863 | €900 | €5.53 |
| Scale | ~5M | €18,460 | €11,260 | €7,200 | €3.69 |
| Hyperscale | 10M+ | €48,464 | €30,964 | €17,500 | €4.85 at 10M |
The detailed server lists are in the infrastructure tables on this page.
Main cost drivers
- Egress (delivery): 45 TB/month at Launch grows to about 9 PB at 10M MAU. Even with your own edge caches serving most EU traffic, the non-EU CDN bill is the largest single line at every tier above Launch.
- Storage: grows by 1.8 TB to 360 TB every month (estimate), and it compounds because old videos stay. By Hyperscale, Object Storage is the second-largest line.
- Transcoding compute: scales with upload minutes. Noticeable but bounded, and easy to make cheaper (spot-style batch scheduling, better presets).
- Everything else (app servers, databases, search, observability) stays below 15% of the bill at Scale. Over-optimising it is a common mistake.
How to cut the bill
- Encode for egress: per-title ladders on talk content (often 20–40% fewer bits), AV1 for popular videos on capable devices (often 30%+ fewer bits than H.264 at similar quality), and capping the default rung on mobile networks. A 25% bitrate reduction at Scale saves over 1 PB of delivery a month.
- Own your EU egress: each additional AX42 edge cache (€79/month) can replace about 190 TB/month of CDN traffic. That pays back as soon as it offloads a couple of dozen TB at typical CDN rates.
- Lifecycle storage: delete originals after 30–90 days unless a creator pays for archive, drop high rungs on cold videos, and regenerate on demand.
- Shorts are cheaper: vertical 1080×1920 at modest bitrates, short duration and very high cache hit ratios. Their per-minute delivery cost is similar, but the long tail is much smaller.
- Right-size after measuring: start with the Launch tier, load-test, and only then buy dedicated servers. Hetzner's setup fees and monthly terms are friendly to this.
- Commit with the CDN: once traffic is predictable, negotiate committed-volume pricing. The difference between list and committed pricing at petabyte scale is large.
Roadmap from clone to production
A concrete path from this clone to a production launch:
Phase 1: Foundations (weeks 1–4)
- Add authentication: OIDC with passkeys, server sessions in Valkey, and account and channel creation flows.
- Replace
localStorageactions with API calls (/v1/me/*) using optimistic updates. Keep theuseSyncExternalStorestore as the client cache. - Create the PostgreSQL schema (above) with migrations (for example
node-pg-migrateor Drizzle), PgBouncer, and pgBackRest to Object Storage with a tested restore. - Move the catalog from JSON to Postgres behind
'use cache'functions with tag-based invalidation. - Move comments to Postgres with keyset pagination, keeping the existing API contract the UI already uses.
Phase 2: Media pipeline (weeks 3–8)
- Multipart, resumable uploads straight to Object Storage with presigned URLs, and an upload-session API.
- Transcode workers: FFmpeg in a sandboxed container pulling from NATS JetStream, 4-rung H.264 CMAF ladder, HLS + DASH manifests, thumbnails, storyboards.
- GPU fast path for a quick 720p rendition. Auto captions with a speech-to-text model.
- Self-hosted player (hls.js or Shaka) replacing the YouTube embed, with RUM beacons for startup, rebuffering and bitrate.
- CDN in front of the renditions bucket, with signed URLs for private and unlisted videos.
Phase 3: Engagement systems (weeks 6–12)
- View and watch-time pipeline: beacons → JetStream → Valkey minute counters → batched Postgres flush, plus raw events into ClickHouse with dedupe and bot filtering.
- Realtime: Centrifugo with NATS broker for live comments and notification badges, replacing the in-process SSE endpoint.
- Notifications: fanout-on-write with a large-channel fallback to fanout-on-read, Web Push (VAPID), and FCM/APNs for the apps.
- Search on Meilisearch or OpenSearch over titles, descriptions and captions.
- Recommendations v1: co-watch and embedding candidates with a LightGBM ranker. A/B test against the rule-based feed.
Phase 4: Trust, safety and operations (parallel)
- Moderation: upload hash matching and classifiers, comment spam scoring with hold-for-review, report queues and moderator tooling.
- Security headers and nonce-based CSP, edge WAF and rate limits, secrets manager, audit log.
- Observability stack (Prometheus, Loki, Tempo, Grafana) with SLOs and burn-rate alerts. Runbooks for the top five incidents.
- k6 load tests in CI. Monthly restore drill. Quarterly chaos drill.
- GDPR/DPDP: consent, export, erasure jobs, DPAs with sub-processors, privacy policy and terms, DMCA/notice-and-takedown process.
Launch checklist
- Load-tested to 3× expected peak, with the viral-video scenario passing.
- Backups restored successfully in the last 30 days.
- Error budgets, on-call rotation and status page live.
- Cost dashboards per tier line (egress, storage, compute), with alerts on anomalies.
Implementation snippets
FFmpeg: 4-rung CMAF ladder with HLS output
One FFmpeg pass decodes the source once, scales to four rungs with keyframes aligned every 2 seconds, and writes fMP4/CMAF segments plus an HLS master playlist. The same segments can be referenced by a DASH manifest. Tune presets and bitrates per title.
#!/usr/bin/env bash
set -euo pipefail
IN="$1" # e.g. /work/source.mp4 (downloaded from Object Storage)
OUT="$2" # e.g. /work/out
mkdir -p "$OUT"
ffmpeg -hide_banner -y -i "$IN" \
-filter_complex "[0:v]split=4[v1][v2][v3][v4]; \
[v1]scale=w=1920:h=1080:force_original_aspect_ratio=decrease:force_divisible_by=2[v1o]; \
[v2]scale=w=1280:h=720:force_original_aspect_ratio=decrease:force_divisible_by=2[v2o]; \
[v3]scale=w=854:h=480:force_original_aspect_ratio=decrease:force_divisible_by=2[v3o]; \
[v4]scale=w=640:h=360:force_original_aspect_ratio=decrease:force_divisible_by=2[v4o]" \
-map "[v1o]" -map "[v2o]" -map "[v3o]" -map "[v4o]" -map 0:a:0 \
-c:v libx264 -preset veryfast -profile:v high -pix_fmt yuv420p \
-g 48 -keyint_min 48 -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*2)" \
-b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k \
-b:v:1 2800k -maxrate:v:1 3000k -bufsize:v:1 4200k \
-b:v:2 1200k -maxrate:v:2 1300k -bufsize:v:2 1800k \
-b:v:3 700k -maxrate:v:3 750k -bufsize:v:3 1050k \
-c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_flags independent_segments \
-hls_segment_filename "$OUT/%v/seg_%05d.m4s" \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,agroup:aud v:1,agroup:aud v:2,agroup:aud v:3,agroup:aud a:0,agroup:aud,name:audio" \
"$OUT/%v/index.m3u8"
# Upload with long-lived cache headers (segments are immutable)
aws s3 cp "$OUT" "s3://viewly-renditions/$VIDEO_ID/h264/" --recursive \
--endpoint-url https://fsn1.your-objectstorage.com \
--cache-control 'public, max-age=31536000, immutable'Transcode worker pulling jobs from NATS JetStream
A worker consumes the TRANSCODE work-queue stream with explicit acks. It extends the ack deadline while FFmpeg runs, retries failed jobs up to max_deliver times and records the result through the outbox. KEDA scales these pods on consumer lag.
import { connect } from "nats";
import { spawn } from "node:child_process";
import { downloadSource, uploadRenditions, markReady, markFailed } from "./media.js";
const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const consumer = await js.consumers.get("TRANSCODE", "workers"); // durable, AckExplicit, max_deliver 5
function run(cmd, args) {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit"] });
p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(cmd + " exited " + code))));
});
}
const messages = await consumer.consume({ max_messages: 1 }); // one job per worker at a time
for await (const m of messages) {
const job = m.json(); // { videoId, sourceKey }
const keepAlive = setInterval(() => m.working(), 20_000); // extend ack wait while encoding
try {
const dir = "/work/" + job.videoId;
await downloadSource(job.sourceKey, dir + "/source");
await run("/app/ladder.sh", [dir + "/source", dir + "/out"]);
const renditions = await uploadRenditions(job.videoId, dir + "/out");
await markReady(job.videoId, renditions); // UPDATE videos + INSERT outbox in one transaction
m.ack();
} catch (err) {
console.error({ videoId: job.videoId, attempt: m.info.redeliveryCount, err: String(err) });
if (m.info.redeliveryCount >= 4) {
await markFailed(job.videoId, String(err));
m.term(); // stop redelivery; alert via dead-letter advisory
} else {
m.nak(30_000 * (m.info.redeliveryCount + 1)); // back off before retry
}
} finally {
clearInterval(keepAlive);
}
}Batched view counters: Valkey minute buckets flushed to Postgres
View events increment a per-minute hash in Valkey. Once a minute a flusher renames the closed bucket (atomic hand-off), reads it and applies a single batched UPDATE, so a viral video never becomes a row-lock hotspot.
import Redis from "ioredis";
import pg from "pg";
const valkey = new Redis(process.env.VALKEY_URL);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); // via PgBouncer
const bucketKey = (d = new Date()) => "views:" + d.toISOString().slice(0, 16); // views:2026-11-11T12:05
// Called by the VIEWS stream consumer for each validated view event
export async function recordView(videoId) {
const key = bucketKey();
await valkey.multi().hincrby(key, videoId, 1).expire(key, 3600).exec();
}
// Runs every minute (one leader via a Valkey lock)
export async function flushClosedBuckets() {
const prev = bucketKey(new Date(Date.now() - 60_000));
const claimed = prev + ":flushing";
const ok = await valkey.renamenx(prev, claimed).catch(() => 0); // no key => nothing to do
if (!ok) return;
const counts = await valkey.hgetall(claimed);
const ids = Object.keys(counts);
if (ids.length) {
await pool.query(
'UPDATE videos v SET view_count = v.view_count + d.n ' +
'FROM unnest($1::text[], $2::bigint[]) AS d(public_id, n) ' +
'WHERE v.public_id = d.public_id',
[ids, ids.map((id) => Number(counts[id]))]
);
}
await valkey.del(claimed);
}Publish a new comment to Centrifugo after commit
The API inserts the comment, then publishes it to the comments:{videoId} channel through Centrifugo's HTTP API. Centrifugo fans it out to every connected viewer across gateway nodes via its NATS broker. In the clone, the same role is played by the in-process SSE endpoint.
import { sql } from "./db";
type NewComment = { videoId: string; authorId: string; body: string };
export async function createComment(input: NewComment, idempotencyKey: string) {
const [comment] = await sql`
INSERT INTO comments (id, video_id, author_id, body)
VALUES (next_id(), ${input.videoId}, ${input.authorId}, ${input.body})
RETURNING id, video_id, author_id, body, like_count, created_at`;
// Fire-and-forget realtime hint; the row in Postgres is the source of truth.
fetch(process.env.CENTRIFUGO_URL + "/api/publish", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CENTRIFUGO_API_KEY!,
},
body: JSON.stringify({
channel: "comments:" + input.videoId,
data: { type: "comment.created", comment },
idempotency_key: idempotencyKey,
}),
}).catch((err) => console.warn("centrifugo publish failed", err));
return comment;
}Web Push fanout for new uploads (VAPID)
A fanout worker sends Web Push notifications to subscribers who opted in to 'All notifications'. It uses a topic so multiple uploads collapse into one notification, and deletes subscriptions the push service reports as gone (404/410).
import webpush from "web-push";
import { db } from "./db.js";
webpush.setVapidDetails(
"mailto:ops@viewly.example",
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
export async function pushNewUpload(video, channel, subscriberIds) {
const subs = await db.query(
'SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ANY($1)',
[subscriberIds]
);
const payload = JSON.stringify({
title: channel.title + ' uploaded a video',
body: video.title,
url: '/watch?v=' + video.publicId,
icon: channel.avatarUrl,
});
const results = await Promise.allSettled(
subs.rows.map((s) =>
webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
payload,
{ TTL: 6 * 3600, urgency: 'normal', topic: 'ch-' + channel.id }
)
)
);
const gone = subs.rows.filter((s, i) => {
const r = results[i];
return r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode);
});
if (gone.length) await db.query('DELETE FROM push_subscriptions WHERE id = ANY($1)', [gone.map((s) => s.id)]);
}nginx edge cache for HLS on an AX42
An EU edge cache on an unmetered dedicated server. Segments are cached for a long time and playlists briefly, following the Cache-Control metadata set at upload time. Concurrent misses for the same segment are collapsed into one origin fetch, and signed URLs are checked with secure_link before anything is served.
proxy_cache_path /var/cache/hls levels=1:2 keys_zone=hls:512m max_size=1600g
inactive=30d use_temp_path=off;
upstream origin {
server viewly-renditions.fsn1.your-objectstorage.com:443;
keepalive 64;
}
server {
listen 443 ssl reuseport;
http2 on;
server_name v1.viewly.example;
ssl_certificate /etc/ssl/viewly/fullchain.pem;
ssl_certificate_key /etc/ssl/viewly/privkey.pem;
set $secure_link_secret "load-from-include-file"; # same secret the API signs with
# /s/<expires>/<md5>/<videoId>/... signed by the API when it returns the manifest URL
# TTLs come from the objects' Cache-Control metadata (segments: 1 year immutable,
# playlists: 60 s); proxy_cache_valid is only the fallback.
location ~ ^/s/(?<expires>\d+)/(?<sig>[\w-]+)/(?<path>.+)$ {
secure_link $sig,$expires;
secure_link_md5 "$expires/$path $secure_link_secret";
if ($secure_link = "") { return 403; }
if ($secure_link = "0") { return 410; }
proxy_pass https://origin/$path;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host viewly-renditions.fsn1.your-objectstorage.com;
proxy_ssl_server_name on;
proxy_ssl_name viewly-renditions.fsn1.your-objectstorage.com;
proxy_cache hls;
proxy_cache_key $path; # signature not part of the key
proxy_cache_lock on; # collapse concurrent misses
proxy_cache_lock_timeout 5s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_valid 200 206 30d;
add_header X-Cache $upstream_cache_status always;
}
}KEDA autoscaling of transcode workers on JetStream lag
Scale the transcode Deployment from 1 to 40 pods based on the number of pending messages on the TRANSCODE consumer. Pods are pinned to the CPU worker nodes, and a long cooldown avoids killing workers in the middle of an encode.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: transcode-workers
namespace: media
spec:
scaleTargetRef:
name: transcode-worker
minReplicaCount: 1
maxReplicaCount: 40
pollingInterval: 15
cooldownPeriod: 900
triggers:
- type: nats-jetstream
metadata:
natsServerMonitoringEndpoint: "nats.messaging.svc.cluster.local:8222"
account: "$G"
stream: "TRANSCODE"
consumer: "workers"
lagThreshold: "2"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: transcode-worker
namespace: media
spec:
template:
spec:
nodeSelector:
viewly.io/pool: transcode-cpu
terminationGracePeriodSeconds: 3600 # let an in-flight encode finish
containers:
- name: worker
image: registry.viewly.example/transcode-worker:2026.11.1
resources:
requests: { cpu: "7", memory: "8Gi" }
limits: { cpu: "8", memory: "12Gi" }
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: work, mountPath: /work }
volumes:
- name: work
emptyDir: { sizeLimit: 200Gi }Keyset-paginated comments query and index
The production version of the clone's GET /api/comments. The composite index matches the sort exactly, so each page is an index range scan no matter how deep the user scrolls.
CREATE INDEX CONCURRENTLY IF NOT EXISTS comments_top
ON comments (video_id, like_count DESC, id DESC)
WHERE parent_id IS NULL AND status = 'visible';
-- $1 video_id, $2/$3 cursor (like_count, id) from the previous page, $4 page size + 1
SELECT c.id, c.body, c.like_count, c.created_at,
u.display_name, u.id AS author_id
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.video_id = $1
AND c.parent_id IS NULL
AND c.status = 'visible'
AND (c.like_count, c.id) < ($2, $3)
ORDER BY c.like_count DESC, c.id DESC
LIMIT $4;k6 load test: browse, watch and comment
A k6 scenario that mixes normal browsing with a viral-video hotspot and comment posts, with thresholds that fail CI if p95 latency or error rate regress.
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
scenarios: {
browse: { executor: "ramping-vus", stages: [{ duration: "2m", target: 300 }, { duration: "5m", target: 300 }, { duration: "1m", target: 0 }] },
},
thresholds: {
http_req_failed: ["rate<0.01"],
"http_req_duration{name:watch}": ["p(95)<300"],
"http_req_duration{name:comment}": ["p(95)<400"],
},
};
const BASE = __ENV.BASE_URL || "https://staging.viewly.example";
const VIRAL = "Rc2GAl1xxQ0";
const IDS = ["TfA5T3vPgfs", "91xb9_BaBIc", "LRkMpeJkHM0", "Ufglo4zqJ00", "pDFmumkX5k4"];
export default function () {
http.get(BASE + "/api/videos", { tags: { name: "home" } });
const id = Math.random() < 0.5 ? VIRAL : IDS[Math.floor(Math.random() * IDS.length)];
const watch = http.get(BASE + "/api/videos?id=" + id, { tags: { name: "watch" } });
check(watch, { "watch 200": (r) => r.status === 200 });
http.get(BASE + "/api/comments?videoId=" + id + "&sort=top", { tags: { name: "comments" } });
if (Math.random() < 0.05) {
const res = http.post(
BASE + "/api/comments",
JSON.stringify({ videoId: id, text: "load test " + __VU + "-" + __ITER }),
{ headers: { "Content-Type": "application/json", "Idempotency-Key": __VU + "-" + __ITER }, tags: { name: "comment" } }
);
check(res, { "comment created": (r) => r.status === 201 || r.status === 429 });
}
sleep(1 + Math.random() * 3);
}Related videos from The Bhairav Show
Frequently asked questions
How did you build the YouTube clone with Claude Code?
I designed Viewly with Claude on a design canvas, then gave Claude Code the design and a written brief covering the stack, the fidelity bar, what "fully working" means and the proof required. A Claude Code sub-agent running Claude Opus 5.5 built the app in about 34 minutes: it wrote the Next.js app and API routes, got build and lint passing, ran the app, took and reviewed screenshots, and drafted the scaling plan.
Can Claude Code build a production-ready YouTube clone?
Claude Code can build a complete, working frontend with API routes quickly, as Viewly shows. Production readiness also needs the backend described in this guide: a real database, authentication, a real-time gateway, media or payment pipelines, security hardening and operations. Claude Code helps write that too, but the architecture and risk decisions stay with the engineer.
Which Claude tools were used to build this clone?
Claude's design canvas for the screens, claude Code and its sub-agents for coding, testing and screenshots, and Claude Opus 5.5 for the scalability, Hetzner infrastructure, real-time and security analysis.
How do you build a YouTube clone that scales to millions of users?
Split the platform into a control plane (metadata, users, comments, search, recommendations) and a media plane (uploads, transcoding, delivery). Run a stateless Next.js/API tier on PostgreSQL with replicas, Valkey and a queue, transcode uploads with FFmpeg into an HLS/DASH ladder on object storage, and deliver segments through a CDN plus your own cache servers. Egress is the dominant cost, so encode efficiently and own as much delivery as you can.
What tech stack is the Viewly YouTube clone built with?
Next.js 16 with the App Router and Cache Components, React 19, Tailwind CSS v4 and plain JavaScript. Route handlers under app/api provide videos, search, comments and a Server-Sent Events stream. There are no extra runtime dependencies beyond Next and React.
Does the clone actually upload and transcode videos?
No. The upload dialog is fully interactive, but the file stays in your browser as an object URL and disappears on reload, and the processing bar is simulated. The deep-dive section of this guide describes the real pipeline: multipart upload to object storage, FFmpeg CPU/GPU workers, a CMAF ladder and CDN delivery.
How much does it cost to host a video streaming platform on Hetzner?
Using the estimates in this guide, about €600/month at launch (up to 50K MAU), a few thousand euros at 500K MAU, and tens of thousands at 5M MAU, most of it CDN egress and object storage. Hetzner prices are excl. VAT as listed in Nov 2026 and should be verified before ordering.
Should I transcode videos on GPU or CPU?
Use both. GPU NVENC (for example a GEX45) gives very fast time-to-first-playable and suits live streams, while CPU encoders like x264 and SVT-AV1 give better quality per bit. Because delivery costs more than encoding, use CPUs for the final ladder and re-encode popular videos with AV1.
What is an ABR ladder and which renditions do I need?
An adaptive bitrate ladder is a set of renditions of the same video at different resolutions and bitrates, split into short segments so the player can switch as bandwidth changes. A sensible H.264 start is 1080p at about 5 Mbps, 720p at about 3 Mbps, 480p at about 1.2 Mbps, 360p at about 0.7 Mbps and 128 kbps AAC audio, with keyframes aligned every 2 seconds.
HLS or DASH: which should a video platform use?
Both, from the same files. Package segments as fMP4/CMAF and write an HLS master playlist for Apple devices and a DASH manifest for other players. Both manifests point to the same segments, so storage and CDN cache are not duplicated.
How does YouTube-style view counting work at scale?
Players send beacons and heartbeats to an ingest endpoint that appends to a stream instead of updating the database. Workers increment per-minute counters in Valkey, a flusher applies one batched UPDATE per minute, and raw events land in ClickHouse, where deduplication and bot filtering reconcile the public count.
How do you notify millions of subscribers when a channel uploads?
Use fanout-on-write for normal channels: workers page through the channel's subscribers and write inbox rows plus realtime and push hints. For very large channels, switch to fanout-on-read: store the upload once and merge it into each user's feed when they open it, and push only to users who asked for all notifications.
WebSockets or Server-Sent Events for live comments?
Server-Sent Events are enough for one-way updates like new comments and badge counts: they work over HTTP/2, reconnect automatically and resume with Last-Event-ID. Use WebSockets for live-stream chat, where clients send at high rates. A gateway like Centrifugo supports both, with NATS or Valkey as the backplane.
Can I run PostgreSQL on Hetzner without a managed database service?
Yes. Hetzner has no managed Postgres, so run it on dedicated servers (for example AX102 or AX162) with Patroni for failover, PgBouncer for pooling and pgBackRest backing up to Object Storage in another location. Test restores monthly, or use an external managed provider over a private connection.
How do I reduce video delivery (egress) costs?
Lower the bits and own the pipe. Per-title encoding and AV1 reduce bitrate for the same quality, and lifecycle rules trim stored renditions. Serving European viewers from your own nginx caches on unmetered Hetzner AX servers can cost well under €1/TB, compared with a metered CDN, with the CDN covering other regions and spikes.
Is this clone affiliated with YouTube?
No. Viewly is an original demo brand built to show how a video platform works. It embeds public videos from Suhas Bhairav's channel using the privacy-enhanced youtube-nocookie.com player and links every video back to its original YouTube page.
More SaaS clones
- ChatGPT cloneHow I Built a ChatGPT Clone Using Claude Code: Assist
- Facebook cloneHow I Built a Facebook Clone Using Claude Code: Kinfolk
- Airbnb cloneHow I Built an Airbnb Clone Using Claude Code: nestly
- TikTok cloneHow I Built a TikTok Clone Using Claude Code: Loop
- Snapchat cloneHow I Built a Snapchat Clone Using Claude Code: Glint







