The re-platforming decision
The original plan was a single VPS with a process manager — the default for a project this size, and genuinely fine until it isn't. Two things made it the wrong choice here:
- Video transcoding is bursty. Encoding jobs need to scale independently of the API serving students, and a single box forces them to compete.
- It is a white-label product. Multiple organisations run on the same system, so "restart the server" is never a small decision.
I moved it to Kubernetes with ArgoCD GitOps, a self-hosted secret manager and self-hosted S3-compatible object storage. The constraint I set myself was that this must not blow the infrastructure budget I had already put in the architecture document — self-hosting the storage and secret layers is what made that possible.
Deployment architecture
- Manifests split across application, infrastructure and ArgoCD directories, so the delivery layer is versioned separately from what it delivers.
- Dockerfiles per service, with CI and build-deploy workflows building and publishing images.
- Self-hosted object storage for media instead of a managed bucket, which is the single largest cost line in a video product.
- Two security audits run against the deployment before it carried real users.
Local development deliberately uses non-default database ports so the project runs alongside other projects on the same machine without a port collision — a small thing that stops "works on my machine" from becoming a daily tax.
What the platform had to support
The deployment work only makes sense against what runs on it, so briefly: this is a white-label Udemy-style learning platform I built from requirements documents through to production — a business requirements doc, a spec covering 70+ functional requirements across 16 collection schemas, a functional document spanning 19 modules, and a nine-section architecture document with the cost estimate.
- Multi-tenancy with path-based organisation routing, org-scoped registration, per-organisation theming from two brand colours, and generated public landing pages. Tenant lookup is cached in Redis so it isn't a database hit on every request.
- Four-role access control — super admin, organisation admin, instructor, student — with short-lived access tokens, rotating refresh tokens and per-role device limits.
- A video pipeline: chunked upload to object storage, a job queue driving FFmpeg to transcode into HLS at four renditions off the request path, and signed-URL streaming with a moving per-organisation watermark.
- Progress tracking that writes through a Redis cache — heartbeat every ten seconds, batched to the database every sixty — so accurate resume behaviour doesn't create a write amplification problem.
Indexes are state, not code. After a schema change, user creation started failing on stale partial-filter unique indexes left over from the previous shape. Dropping and recreating them fixed it — a reminder that a database carries history your repository doesn't, and that migrations need to account for what already exists rather than what the model says should.
Ongoing
I still review pull requests on this codebase — including catching placeholder marketing copy that had been committed as real schema defaults, and verifying the fix actually landed in the follow-up commit rather than taking the description's word for it.
Outcome
- A reproducible, GitOps-delivered deployment replacing a single-server plan.
- Transcoding that scales independently of the API serving students.
- Self-hosted storage and secrets keeping running cost at the estimated figure.
- Two security audits completed before real users arrived.