StreamKeep is a livestream archive for the Mongolian community on Kick. Kick removes a broadcast 30 days after it airs, and the streamer who made it has no way to recover the file. StreamKeep lets a creator sign in with their own Kick account, select the broadcasts worth preserving, and keep the original video at source quality together with a synchronised chat replay. Playback is public and requires no account.
It is a personal project, designed and built end to end, and running in production under my own operation. The engineering goals were reliability, predictable cost, and an operational surface small enough for one engineer to own. What follows is the architecture, the decisions behind it, and the trade-offs I accepted.
1. Project overview
Livestreaming platforms treat past broadcasts as cache, not as archive. Kick applies a fixed 30-day retention window: after it closes the video object is deleted and cannot be restored on request. For a creator this means the work disappears on a schedule they do not control, and any moment they did not clip at the time is unrecoverable.
StreamKeep addresses this narrowly and deliberately. It does not mirror an entire platform. It gives a creator a way to mark specific broadcasts for preservation before the retention window closes, stores the original rendition rather than a re-encode, and preserves the chat transcript alongside the video so the recording keeps its context.
- Scope — a personal project, built end to end: product, backend, infrastructure, deployment and on-call.
- Current state — open beta. 77 broadcasts preserved across 68 channels, 537 hours of video retained.
- Design priorities — reliability first, then cost predictability, then capacity. Capacity last, because the workload does not yet justify paying for elasticity.
2. Problem and motivation
Before building anything I measured the scale of the problem. A recorder samples Kick’s Mongolian live directory once a minute and records viewership, airtime and chat volume per channel. It still runs: the figures below are its totals since tracking began in September 2026, and every broadcast behind them is deleted 30 days after it airs.
Storage efficiency
Broadcasts are multi-hour source-quality video. Retaining them on compute-attached disk would mean growing the instance for storage rather than for load, and paying for provisioned capacity whether or not it is used. Storage had to be billed by consumption and decoupled from the application host.
Delivery must not touch the application
If video is streamed through the API, every concurrent viewer consumes an application connection and host bandwidth. A single popular archive would then degrade sign-in, browsing and job submission for everyone. Read traffic had to be served by infrastructure that scales independently of the API.
Long-running work cannot run in a request
Fetching and storing a multi-hour broadcast takes far longer than any acceptable HTTP timeout, and it is subject to upstream failures outside my control. This work had to be asynchronous, durable across restarts, and safely retryable.
Cost has to stay predictable
The project is funded by its users on hours-based plans. A cost model that grows with viewership rather than with stored hours would break that relationship, so the unit customers pay for and the unit I am billed for had to match.
That measurement framed four engineering constraints, and those constraints drove every decision that follows.
Counters read from streamkeep.live on 26 October 2026. The recorder samples Kick’s Mongolian directory every minute; hours watched and viewer peaks count only sampled time.
3. High-level architecture
The system separates three paths that have very different characteristics: a synchronous request path, an asynchronous archive path, and a delivery path that bypasses the application entirely. The diagram below traces all three and can be panned and stepped through.
Component responsibilities
- Next.js frontend — the three public surfaces: stream library, live multiview and channel statistics. Bilingual, with the language cookie read server-side so the first paint already matches the reader.
- Go API — authentication through Kick OAuth 2.0 with PKCE, authorisation, archive submission, and the live recorder that polls the public directory every 60 seconds. It serves metadata only; no video passes through it.
- PostgreSQL — the system of record: archives and their state, chat transcripts, channel statistics, and the migration ledger. Job status and failure reasons live here, which makes a broken archive a row I can query rather than a log line I have to find.
- Redis — the job broker and coordination layer, running Asynq. It holds queued and in-flight tasks, live per-archive progress snapshots, and rate-limiting counters.
- Archive worker — a separate process that consumes the queue and performs the long-running work: fetching the source rendition with yt-dlp and ffmpeg, writing it to object storage, and capturing the chat transcript.
- Cloudflare R2 — object storage for the original video files. Chosen for consumption-based pricing and, critically, zero egress charges.
- Cloudflare CDN — delivery on a dedicated domain, in front of R2. Viewers stream HLS from here; the API is never in the path of a byte of video.
Interactive — pan, trace a path, switch theme, or open the guided views
Open full size4. Technology stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js, TypeScript | Server rendering for first-paint correctness on a bilingual UI |
| Backend | Go | One static binary per service; low memory footprint on a small VM |
| Database | PostgreSQL 16 | Transactional job state, relational statistics, versioned migrations |
| Queue & cache | Redis 7, Asynq | Durable background jobs, retry semantics, live progress snapshots |
| Object storage | Cloudflare R2 | Consumption-based pricing with no egress fees |
| Delivery | Cloudflare CDN, HLS, hls.js | Video served at the edge, independent of the application |
| Media pipeline | yt-dlp, ffmpeg | Source-quality fetch with no transcode step |
| Runtime | Docker Compose, Linux, Oracle Cloud | One host, one declarative file, no control plane to operate |
| CI/CD | GitLab CI, self-hosted runner | Build, migrate and health-gate a release from a single pipeline |
Every choice here optimises for a single operator. Go and Compose keep the number of moving parts low; R2 and the CDN move the expensive, high-volume work onto managed infrastructure that needs no attention from me.
5. Production deployment
Production is five containers on one Linux VM, declared in a single Compose file and rolled out by the pipeline. Each service has a health check and a restart policy; container logs are capped so a runaway process cannot fill the disk.
- Frontend — the Next.js application.
- Go API — REST surface plus the live directory recorder.
- Archive worker — the Asynq consumer that performs archive jobs.
- PostgreSQL — persistent state, on a named volume.
- Redis — the job broker and progress cache.
Why this shape
An orchestrated cluster was the obvious alternative and I decided against it. At this workload it would add a control plane to patch, upgrade and debug, and it would buy elasticity the traffic does not need. The relevant question was not which architecture is most scalable, but which one a single engineer can operate correctly at 3am.
- Operational simplicity — one host to patch, one file to read, no scheduler to reason about during an incident.
- Cost — a small VM plus consumption-priced storage, with no per-cluster or per-node overhead.
- Maintainability — the whole runtime is described in one Compose file that is versioned with the application.
- Sufficient capacity — the API is metadata-only and video is served by the CDN, so the host is not on the critical path for the traffic that actually scales.
The trade-off I accepted
One host and one database mean a host failure is downtime, not a failover, and a schema migration briefly pauses the worker. Both are acceptable at this stage and both are stated plainly rather than engineered around prematurely. The way out, once the workload needs it, is a second node under an orchestrator, which turns a host failure into a failover.
6. Video storage and delivery
This is the decision with the largest effect on both reliability and cost, and it is worth stating explicitly because it is easy to get wrong.
The pattern being avoided
The naive design routes playback through the application: viewer to API server to storage. It is simple to implement and it fails badly. Every concurrent viewer holds an application connection for the duration of a multi-hour video, host bandwidth becomes the ceiling on audience size, and one popular archive degrades sign-in and job submission for every other user. The blast radius of a traffic spike is the entire product.
The pattern used
Playback goes viewer to CDN to object storage, on a dedicated domain. The API issues metadata and never touches a byte of video. HLS segments are requested directly from the edge by the player.
- Application load is bounded — API concurrency tracks metadata requests, not viewer-hours.
- Bandwidth cost is bounded — R2 charges no egress, so a broadcast watched a thousand times costs the same to serve as one watched once.
- Delivery scales independently — audience growth is absorbed by the CDN, with no change to the host.
- Failure is isolated — a delivery problem does not take down archiving, and an application deploy does not interrupt playback.
One necessary exception
For live multiview, the master playlist is proxied, because that single file is the one whose CORS policy is restricted to the platform’s own origins. Variant playlists and video segments still stream directly from the upstream CDN. The proxy is scoped to the smallest object that requires it rather than to the whole stream.
7. Challenges and lessons learned
Large media is a storage problem, not an application problem
The instinct is to treat video as data the application owns. Treating it as an object the application only references, and keeping it entirely off the request path, removed the majority of the scaling and cost questions before they became problems.
Reliable workers need durable state, not just a queue
A queue alone does not survive contact with retries. Keeping the authoritative status and attempt count in the database, scoping task identity by attempt, and guarding each sub-job with its own claim is what makes a re-queued job safe instead of merely possible.
Most incidents come from the deploy
Adding a verified backup, a strict rollout order and three independent health gates cost an afternoon and removed the failure mode I was most likely to cause myself. The gates that matter are the ones that check the real network path, not just the process.
Simplicity is a capacity decision, not a shortcut
Choosing Compose over an orchestrator was a judgement about what one engineer can operate, not an admission of missing skill. The corresponding obligation is to know exactly which signal would invalidate that choice, and to have the migration path ready before it does.
Design the cost model with the architecture
Selecting storage with no egress charge was an architectural decision as much as a financial one. It is what allows viewing to be free and unauthenticated without the economics inverting as the audience grows.
8. Final reflection
This project represents my engineering approach: build simple systems, automate reliability, optimise cost, and design infrastructure that can evolve with future scale.
StreamKeep is deliberately not the most sophisticated architecture I could have built. It is the one I could build correctly, deploy safely, operate alone, and explain honestly, including its limits. Every decision above has a stated reason and a stated cost, and each is reversible along a path I have already thought through.
