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. 32 broadcasts preserved across 8 channels, 158 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. Over a three-day window it produced the figures below, all of which sit inside the same 30-day deletion window.
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.
Live counters from streamkeep.live. 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 migration path out is described in section 11.
6. Engineering decisions and trade-offs
Most production incidents I have seen come from the deployment, not from the code being deployed. The pipeline is therefore built to fail loudly and early rather than to deploy quickly.
Deployment safety
The pipeline runs only on the default branch, on a self-hosted runner on the production host. A resource group and a file lock together make it impossible for two deployments to overlap, and the job is marked non-interruptible so a newer pipeline cannot terminate a release mid-rollout.
- Backup first — every deployment takes a compressed pg_dump before it touches any running container.
- Validate the backup — the dump is checked for content and the deployment aborts if it comes back empty. An unverified backup is not a backup.
- Store it out of reach — dumps are written outside the CI checkout so the next pipeline’s clean checkout cannot delete them.
- Record the release — the commit and pipeline identity tag the images, and the last successful release is recorded on the host.
Migration safety
Schema migrations are embedded in the API binary, applied in filename order, and tracked in a migrations table so each runs exactly once. Sixteen have been applied so far.
The ordering of the rollout is the important part. The old worker is stopped before the new API starts, so no process is executing against the old schema while a migration is in flight. Only once the API has started and migrated do the worker and frontend come up. This removes the class of race where a worker writes a row shaped for a schema that no longer exists.
Health checks
A release passes three independent gates, in order, and failing any one of them fails the pipeline:
- Container health — the orchestrator waits on each service’s own health check with a bounded timeout before proceeding.
- Internal readiness — the API is probed on its readiness endpoint from inside the network, which confirms it reached a serving state rather than merely starting.
- External verification — the public API health URL and the site itself must both return HTTP 200 over the real network path, through DNS, TLS and the reverse proxy.
Background processing
Archive jobs run in a separate process from the API for two reasons: their duration has no relationship to a request lifecycle, and their failure modes are dominated by an upstream I do not control. Isolating them means a stalled download cannot consume an API worker or affect page latency.
- Durable queue — jobs are held in Redis through Asynq, so a worker restart does not lose queued work.
- State in Postgres — every archive carries a status and an attempt counter in the database, incremented atomically. Redis holds the transient progress snapshot; the durable truth is relational.
- Retry without duplication — the task identifier is scoped by attempt number, so a retry is never mistaken for a duplicate of the original request, and a re-queued job cannot create a second copy of the same object.
- Independent sub-jobs — chat sync, metadata enrichment and storyboard generation are queued separately from the video fetch, each with its own claim guarded by attempt number, so partial failure degrades one asset instead of the whole archive.
- Upstream backoff — chat pagination applies exponential backoff when the platform throttles, rather than discarding pages already fetched.
7. 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.
8. Cost optimisation
The cost model was designed alongside the architecture rather than reviewed after it. The requirement was that the unit a customer buys and the unit I am billed for should be the same unit, so that revenue and cost move together.
Why object storage rather than instance disk
Block storage attached to a VM is provisioned and billed whether or not it is used, and growing it means resizing a host that is not otherwise under pressure. Object storage is billed by what is actually stored, needs no capacity planning, and separates the storage lifecycle from the compute lifecycle: I can rebuild the host without touching a single archived file.
Why a CDN, and why this one
Serving video from the origin makes bandwidth the dominant and least predictable line item, because it scales with popularity rather than with the catalogue. R2 charges no egress, so the marginal cost of an additional viewer is effectively zero and the bill is a function of hours retained.
That is what makes hours-based pricing honest: customers pay for retained video, which is exactly the axis on which my cost grows. Viewership, the axis I cannot predict, does not appear on either side.
Why not scale the compute tier instead
Serving video from compute couples an unbounded, bursty workload to the tier that also handles authentication and job submission. It would require over-provisioning for peak, paying for that headroom continuously, and accepting that a traffic spike degrades the product rather than merely costing more. Moving the volume to managed infrastructure removed the need to buy elasticity at all.
The result is infrastructure that scales without unnecessary cost: one small VM plus storage billed by the gigabyte, with the high-volume path handled by services that need no capacity planning from me.
9. Monitoring and operations
Observability here is sized to the system: enough signal for one operator to detect a failure and identify its cause, without a monitoring stack that would itself need operating. I am describing what is actually in place, and naming what is not.
- Application health — each service exposes a health check that the runtime evaluates continuously and the deployment pipeline gates on. The API separates liveness from readiness so a starting process is not mistaken for a serving one.
- Container supervision — services carry restart policies, so a crashed process is restarted without intervention while the underlying failure remains visible.
- Job-level visibility — every archive job records its status, attempt count and failure reason in PostgreSQL. A broken archive is a row I can query and re-queue, not a log line I have to search for.
- Logs — container logs use size-capped rotation, which bounds disk usage and prevents a chatty failure loop from filling the host.
- Database — Postgres runs its own readiness probe, and every deployment produces a verified dump, which doubles as a recurring integrity check on the data.
- Delivery — CDN-side analytics cover request volume and cache behaviour for the one layer I do not operate myself.
What is deliberately absent: there is no metrics time series, no dashboard and no alerting pipeline on this project. At one host and one operator, health checks plus queryable job state answer the questions I actually ask. That is a considered trade-off rather than an oversight, and it is the first thing I would change if the system grew past a single node.
10. 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.
11. Future improvements
These are ordered by the signal that would trigger them, not by preference. Each has a concrete threshold.
- Horizontal worker scaling — the queue already supports multiple consumers; the change is running more of them once archive latency, not download bandwidth, becomes the bottleneck.
- Orchestrated deployment — moving to Kubernetes once the system needs more than one node, which would also convert the current downtime-on-host-failure into a genuine failover.
- Event-driven processing — replacing the remaining synchronous fan-out with published events, so new consumers can be added without changing the producer.
- Advanced observability — metrics collection, dashboards and alert routing, which becomes necessary the moment there is more than one instance of anything to compare.
- Multi-region storage — replicating objects across regions for durability and for reader latency outside the current audience.
- AI-assisted highlight detection — using chat velocity and viewer deltas already recorded to propose candidate moments, so a creator is offered clips rather than having to find them.
12. 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.
