Building a real-world education platform for Kenyan university students — in a language I'd never touched before.
The Problem That Started Everything
Every Kenyan university student knows the pain. A lecturer recommends a textbook that costs more than a full month's budget. Someone shares useful notes in a WhatsApp group and three weeks later nobody can find them. You open an edtech app and it spins forever because the campus internet can't handle it.
ElimuLocal is my answer — a free, offline-capable platform where university students upload and share notes, past papers, textbooks, and lecture videos with each other. No paywalls. Works on campus WiFi with zero internet. Deployed live at elimulocal.onrender.com.
And I built the entire backend in Go — a language I started learning from scratch on the same day I started the project.
Why Go? Why Learn While Building?
Before ElimuLocal I hadn't written a single line of Go. The decision was deliberate.
Go compiles to a single binary with no runtime. A campus administrator can deploy ElimuLocal by downloading one file and running it — no Node version conflicts, no Python virtual environments, no JVM. Its standard library is powerful enough to build a complete web server without a framework.
But the most important reason was philosophical: learning while building is the only way I know how to learn properly. Reading a tutorial gives you syntax. Solving a real problem gives you understanding. Every Go concept I encountered during this build stuck because I needed it — not because it appeared on a syllabus.
Month 1 — The Foundation (March 2026)
The First Go Program
package main
import ("fmt"; "net/http")
func homeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ElimuLocal is working!")
}
func main() {
http.HandleFunc("/", homeHandler)
http.ListenAndServe(":8080", nil)
}
Six lines. A working HTTP server. I learned package main, handler functions, http.HandleFunc, and http.ListenAndServe — all in one sitting. I ran go mod init elimulocal, pushed to GitHub, and had something running on day one. That momentum matters.
Data Models and HTML Templates
Go's html/template standard library meant zero third-party dependencies for rendering pages. To pass data to a template you define a struct — this is where Go's type system started making sense:
type Resource struct {
ID, Downloads, Upvotes, UserID int
Title, Course, University string
Category, Description string
UploadedBy, UploadedAt string
FileName string
}
I used template composition — {{define "base"}} and {{template "content" .}} — so the navbar and footer were written exactly once. This step taught me slices, maps, range loops, and the strings package, all in the context of building a real university filter.
SQLite and File Uploads
I chose modernc.org/sqlite — a pure-Go driver requiring no C compiler, CGO_ENABLED=0 compatible. Critical for Docker later.
Parameterized queries protected against SQL injection from day one:
_, err := db.Exec(
`INSERT INTO resources (...) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
r.Title, r.Course, r.University, r.Category,
r.Description, r.UploadedBy, r.UploadedAt, r.FileName, r.UserID,
)
File uploads used io.Copy() to stream directly to disk — no loading the whole file into memory. Unique filenames came from time.Now().UnixNano() to prevent collisions.
Offline by Design
ElimuLocal must work with zero internet. That meant self-hosting fonts. I downloaded Sora and Space Mono locally and served them via Go's built-in static file server:
fs := http.FileServer(http.Dir("static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
One line. An entire static file server. No configuration needed.
Month2 - Polishing and improvements.
Dynamic Search and Filtering
Students wanted to filter by category and sort by popularity or helpfulness. This introduced dynamic SQL construction — building queries conditionally based on active filters.
The []interface{} variadic argument list was a real Go moment — I had to understand how Go handles dynamic argument counts to make safe parameterized dynamic queries work.
Upvotes, Flash Messages, and LAN Deployment
The upvote handler taught me a clean HTTP pattern: reading the Referer header to redirect users back to their exact search results page after voting — preserving all their active filters.
Flash messages after uploads used ?success=1 query parameters — pure stateless HTTP, no session needed for simple confirmations.
Then came the first real test: I found the server's local IP, verified firewall settings, and accessed ElimuLocal on a real Android phone over campus WiFi. Seeing a resource uploaded on a laptop appear instantly on a phone across the room was the first time ElimuLocal felt like a real product.
Month 3 — Auth, Video, and Production (May 2026)
User Authentication in auth.go
Authentication was complex enough to earn its own file — and immediately taught me that go run main.go becomes go run . when your project spans multiple files.
Password hashing used bcrypt at cost factor 12:
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
return string(bytes), err
}
Raw passwords never touch the database. Only hashes are stored.
Session management with gorilla/sessions introduced my hardest bug of the project. Without this one line:
func init() {
gob.Register(int(0)) // Critical — without this, sessions silently fail
}
The session would be created on login, but on the next request the user ID would come back as zero. The user appeared logged out immediately. init() is Go's mechanism for package-level setup that runs automatically — and gob.Register tells the encoder what types to expect. Silent failure, hard lesson, one line fix.
The type switch for decoding session values was another Go pattern I learned solving a real problem:
switch v := session.Values["userID"].(type) {
case int: userID = v
case int64: userID = int(v)
case float64: userID = int(v)
default: return User{}, false
}
Video Uploads and the 500 << 20 Idiom
Supporting lecture videos meant raising the upload limit and extending server timeouts:
r.ParseMultipartForm(500 << 20) // 500MB — bit shift idiom for byte sizes
server := &http.Server{
ReadTimeout: 300 * time.Second, // 5 min for slow campus uploads
WriteTimeout: 300 * time.Second,
}
MIME type detection by file extension drove the preview page — the same data model, either a PDF.js viewer or an HTML5 <video> element, decided entirely in Go before the template renders.
Dockerization with Multi-Stage Builds
The Dockerfile uses multi-stage building — the most important Docker pattern for Go.
The builder stage carries the full Go toolchain (300MB+). The production image has only the binary, templates, and static files. Tiny. CGO_ENABLED=0 produces a fully static binary that runs on any Linux container with zero shared library dependencies.
Database Migrations
As the schema evolved I needed reliable, repeatable migrations. migrate.go reads all .sql files from migrations/ in order, checks a schema_migrations tracking table, and runs each unapplied migration inside a transaction:
tx, err := db.Begin()
if _, err = tx.Exec(string(content)); err != nil {
tx.Rollback()
log.Fatalf("migrations: failed to apply %s: %v", name, err)
}
tx.Exec("INSERT INTO schema_migrations (filename) VALUES (?)", name)
tx.Commit()
Three migrations tell the project's story: users table, resources table, and a backfill that linked pre-auth uploads to their authors by matching usernames.
The Infrastructure Stack
Every choice was deliberate and free:
Turso (libsql) — SQLite with cloud persistence. The entire Go database layer works identically against local SQLite and Turso. Only the connection string differs, toggled by environment variables at startup.
Backblaze B2 — S3-compatible storage with no credit card required for the free tier. Switching to any other S3-compatible provider means changing environment variables only. The Go code never changes.
Render — Docker container hosting that builds and deploys on every push. No servers to manage.
Cloudflare — DNS, proxying, and SSL termination. elimulocal.onrender.com gets HTTPS for free.
What Learning While Building Actually Looks Like
This is not the story of someone who learned Go and then built ElimuLocal. It's the story of someone who learned Go by building ElimuLocal.
Every concept arrived when I needed it. Structs the day I needed a data model. Parameterized SQL the day I needed injection protection. gob.Register the day sessions silently broke. Multi-stage Docker the day I needed a small production image.
The failures were real — and they taught me things no tutorial surfaces. The go run . discovery. The gob type registration. The ReadTimeout for video uploads. You find these when a real problem forces you to. Not on a syllabus.
If you're waiting until you feel ready to learn a new language — don't. Start building something real instead. The readiness comes from the building.
What's Live Today
ElimuLocal at elimulocal.onrender.com: browse and search by keyword, university, and category; upload PDFs and lecture videos up to 500MB; preview PDFs in-browser with self-hosted PDF.js; stream videos via HTML5; download for offline reading; upvote and rate resources; full user accounts with edit and delete. Animated GSAP landing page. Docker-deployed. Cloudflare-fronted HTTPS.
Works on campus WiFi with zero internet. Works from anywhere via the cloud.
Built by Ryan Kelly — Zone01 Kisumu, Kenya — 2026
GitHub: github.com/Rkelly-dot/elimulocal · Live: elimulocal.onrender.com
What Comes Next
Month 4 roadmap: campus sync so resources uploaded on LAN are immediately available via cloud; auto-routing that detects whether a student is on campus and picks the fastest server; multi-campus namespace support; and M-Pesa billing integration for institutions that want a premium tier.
The architecture already supports it — shared Turso database, shared B2 storage, Docker everywhere. The foundation is solid.
If you would like to help me better my platform or have feedback, please feel free to reach out and have a look at my github. Otherwise help me grow the platform by sharing learning resources in it and make a difference by helping millions out there.