Why Project-based learning works: How I Built a Live Platform while learning Go.

go dev.to

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials