DEV Community

Nicole Akinyi
Nicole Akinyi

Posted on

Building a Server-Rendered Forum in Go: Auth, SQLite, and Docker Without a Framework

Most forum tutorials reach for a framework and a hosted database on day one. Working on a four-person team building a forum from scratch in Go with no framework, SQLite as the only datastore, and server-side rendering instead of a JavaScript frontend, taught me more about what a web framework is actually doing for you than any amount of reading framework documentation ever did. This article walks through the real architectural decisions and problems that come up when you build this stack directly on top of Go's standard library.

Why no framework, and why SQLite

The constraint was deliberate: standard library net/http for routing and handlers, html/template for rendering, and SQLite as the only database, all wrapped in Docker for a reproducible environment across four different development machines. No Gin, no Echo, no ORM.

This sounds like unnecessary hardship until you actually do it, at which point it becomes clear how much conceptual clarity you get in exchange. Every request handler, every SQL query, every session check is something you wrote and can trace, which matters enormously when four people are debugging the same codebase and nobody wants to also be debugging an unfamiliar framework's internals at the same time.

SQLite specifically is a better fit for this kind of project than people expect. It is a single file, it needs no separate server process, it supports concurrent reads without configuration, and it is genuinely fast enough for a forum's read-heavy workload. The one thing you have to be deliberate about is write concurrency, which I will come back to.

Project structure and team ownership

With four people working in one repository, structure is not a nicety, it is what prevents constant merge conflicts. We split ownership along clear boundaries: one person owned the Docker setup and README, others owned authentication, post/thread handling, and the templating layer respectively. That division only works if the package structure actually enforces it.

cmd/
  server/
    main.go
internal/
  auth/
    session.go
    handlers.go
internal/
  forum/
    thread.go
    post.go
    handlers.go
internal/
  db/
    schema.sql
    queries.go
web/
  templates/
  static/
Dockerfile
docker-compose.yml
README.md
Enter fullscreen mode Exit fullscreen mode

Keeping db as its own package with a single queries.go file that every other package imports through, rather than letting auth and forum each open their own connections and write their own SQL, turned out to be the single decision that saved the most integration pain. It meant schema changes had one place to happen, and it meant the SQLite write-concurrency issue described below only needed to be solved once.

Schema-first with plain SQL

No ORM means the schema is the actual source of truth, hand-written and versioned:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT UNIQUE NOT NULL,
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE threads (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL REFERENCES users(id),
    title TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    thread_id INTEGER NOT NULL REFERENCES threads(id),
    user_id INTEGER NOT NULL REFERENCES users(id),
    body TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    expires_at DATETIME NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Queries are written as plain SQL strings paired with typed Go functions, using database/sql with the mattn/go-sqlite3 driver:

func GetThreadWithPosts(db *sql.DB, threadID int64) (*Thread, []Post, error) {
    var t Thread
    err := db.QueryRow(
        `SELECT id, user_id, title, created_at FROM threads WHERE id = ?`,
        threadID,
    ).Scan(&t.ID, &t.UserID, &t.Title, &t.CreatedAt)
    if err != nil {
        return nil, nil, err
    }

    rows, err := db.Query(
        `SELECT id, user_id, body, created_at FROM posts WHERE thread_id = ? ORDER BY created_at ASC`,
        threadID,
    )
    if err != nil {
        return nil, nil, err
    }
    defer rows.Close()

    var posts []Post
    for rows.Next() {
        var p Post
        if err := rows.Scan(&p.ID, &p.UserID, &p.Body, &p.CreatedAt); err != nil {
            return nil, nil, err
        }
        posts = append(posts, p)
    }
    return &t, posts, rows.Err()
}
Enter fullscreen mode Exit fullscreen mode

Writing SQL by hand instead of through an ORM makes N+1 query problems visible immediately, because you feel every extra round trip you write. The thread-and-posts fetch above is intentionally two queries rather than a join, because SQLite handles this pattern well and it keeps the Go-side struct mapping simple, but it is a decision made consciously rather than one an ORM made invisibly on your behalf.

The SQLite write-concurrency problem

SQLite handles concurrent reads without issue, but by default only one write can happen at a time across the entire database file, and a second writer arriving while one is in progress gets a database is locked error rather than waiting. On a forum, where posting is the single most common write action and four people are hammering the same dev database while testing, this surfaces almost immediately.

The fix has two parts. First, enable write-ahead logging, which lets reads proceed concurrently with a single writer instead of blocking on it:

db.Exec("PRAGMA journal_mode=WAL;")
Enter fullscreen mode Exit fullscreen mode

Second, set a busy timeout so that a write which arrives while another write is in progress waits and retries instead of failing immediately:

db.Exec("PRAGMA busy_timeout=5000;")
Enter fullscreen mode Exit fullscreen mode

Both of these are one-line fixes, but only if you know to look for them. Without them, the first real concurrent-write test looks like a serious database bug rather than a two-line configuration gap, and it is exactly the kind of thing that eats an afternoon of debugging effort chasing the wrong cause.

Authentication with plain sessions

No JWT library, no third-party auth service. Sessions are random tokens stored in the sessions table and set as an HTTP-only cookie:

func CreateSession(db *sql.DB, userID int64) (string, error) {
    token := generateSecureToken(32)
    expires := time.Now().Add(24 * time.Hour)
    _, err := db.Exec(
        `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`,
        token, userID, expires,
    )
    return token, err
}

func SetSessionCookie(w http.ResponseWriter, token string) {
    http.SetCookie(w, &http.Cookie{
        Name:     "session",
        Value:    token,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
        Path:     "/",
        Expires:  time.Now().Add(24 * time.Hour),
    })
}
Enter fullscreen mode Exit fullscreen mode

HttpOnly prevents JavaScript from reading the cookie, which matters for a forum where user-submitted content eventually ends up rendered on the page and you want session theft via a missed escaping bug to be as hard as possible even as a fallback layer. SameSiteStrictMode cuts down on cross-site request forgery exposure for a login-gated app like this.

Middleware checks the session on every protected route:

func RequireAuth(db *sql.DB, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        cookie, err := r.Cookie("session")
        if err != nil {
            http.Redirect(w, r, "/login", http.StatusSeeOther)
            return
        }
        var userID int64
        var expiresAt time.Time
        err = db.QueryRow(
            `SELECT user_id, expires_at FROM sessions WHERE id = ?`,
            cookie.Value,
        ).Scan(&userID, &expiresAt)
        if err != nil || time.Now().After(expiresAt) {
            http.Redirect(w, r, "/login", http.StatusSeeOther)
            return
        }
        ctx := context.WithValue(r.Context(), userContextKey, userID)
        next(w, r.WithContext(ctx))
    }
}
Enter fullscreen mode Exit fullscreen mode

Server-side rendering with html/template, and why escaping matters here specifically

html/template, not text/template, is the entire reason server-rendered user content is safe by default in Go. It automatically contextually escapes output based on where in the HTML it lands, so a post body containing <script> tags gets rendered as inert text rather than executed. This one package-name difference is doing a substantial amount of the security work that a framework would otherwise be doing for you, and it is easy to not appreciate how much until you deliberately try text/template on user content and watch it happily inject raw HTML.

var tmpl = template.Must(template.ParseFiles(
    "web/templates/layout.html",
    "web/templates/thread.html",
))

func ThreadHandler(w http.ResponseWriter, r *http.Request) {
    threadID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
    thread, posts, err := forum.GetThreadWithPosts(db, threadID)
    if err != nil {
        http.Error(w, "thread not found", http.StatusNotFound)
        return
    }
    data := struct {
        Thread *forum.Thread
        Posts  []forum.Post
    }{thread, posts}
    tmpl.ExecuteTemplate(w, "layout.html", data)
}
Enter fullscreen mode Exit fullscreen mode

Dockerizing a CGo-dependent binary

mattn/go-sqlite3 uses CGo, which is the detail that trips people up when they try to build a minimal container the way they would for a pure-Go binary. A standard scratch-based multi-stage build fails because CGo binaries need libc at runtime, which scratch does not provide.

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o forum ./cmd/server

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/forum .
COPY --from=builder /app/web ./web
COPY --from=builder /app/internal/db/schema.sql ./internal/db/schema.sql
VOLUME /app/data
EXPOSE 8080
CMD ["./forum"]
Enter fullscreen mode Exit fullscreen mode

debian:bookworm-slim as the runtime base instead of scratch or alpine is the fix: it has the glibc that the CGo-linked SQLite driver needs, at a much smaller footprint than the full golang build image. The VOLUME directive for the SQLite file matters too, since without it the database resets every time the container is recreated, which is exactly the kind of thing that looks like a data-loss bug during development but is actually just default container filesystem behavior doing what it always does.

What this stack teaches you that a framework hides

Building this without a framework surfaces decisions that are normally made invisibly on your behalf: how sessions are stored and validated, how a database driver's linking model interacts with your container's base image, how a templating engine's escaping behavior is the actual security boundary between user input and rendered HTML. None of these are exotic problems. They are the ordinary, load-bearing decisions that a framework typically makes for you by default, and building without one is less about reinventing wheels and more about finally seeing the wheels that were always there.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

Frameworks don’t just save you from writing code—they also encode years of production lessons.

I enjoyed the article because it explains what Go’s standard library is doing instead of hiding it behind abstractions. That’s valuable.

A few production considerations I’d add:

  • SQLite: WAL + busy_timeout are essential, but I’d also mention connection pool settings (SetMaxOpenConns(1) is often the safest choice for SQLite write-heavy workloads) and regular checkpointing. WAL files can grow unexpectedly if checkpoints are ignored.
  • Authentication: HttpOnly and SameSite are great defaults, but session fixation protection (rotating the session ID after login), secure logout, idle expiration, and periodic cleanup of expired sessions are equally important.
  • CSRF: SameSite=Strict reduces risk, but it shouldn’t be the only protection. Any state-changing POST endpoint should still validate a CSRF token.
  • Password storage: I’d explicitly mention Argon2id or bcrypt with appropriate cost parameters. Authentication examples are often copied directly into production projects.
  • Database access: Centralizing SQL is a good architectural decision, but introducing transactions and a repository/service boundary early makes future migrations (PostgreSQL, MySQL, etc.) much easier.
  • Docker: If sticking with mattn/go-sqlite3, Debian is a reasonable runtime choice because of CGO. Another interesting alternative is using a pure-Go SQLite driver (modernc.org/sqlite) to produce fully static binaries and simplify containerization, depending on performance and compatibility requirements.

Understanding these implementation details is exactly what makes working without a framework educational. Even if you later adopt Gin, Echo, Chi, or another framework, you’ll understand why those abstractions exist instead of simply relying on them.

Nice write-up.