Go Chat Websocket

01 — Project Overview

go-chat is a real-time chat application with a terminal-style frontend, WebSocket-based messaging, JWT authentication, and PostgreSQL persistence. At its core, it attempts to answer a practical question: how do you build a production-grade real-time system in Go without reaching for heavy frameworks or managed services?

This project exists as a deliberate exercise in foundational backend engineering. It strips away the complexity of a production chat platform like Slack or Discord to focus on the layers that matter: stateful connections, authenticated APIs, persistent storage, and clean architecture.

The Core Idea

In a traditional REST API, the server waits for the client to ask. In go-chat, the server pushes. When a user sends a message, every other user in the same room sees it instantly — no polling, no refreshing, no latency. This is enabled by WebSockets, but the real engineering is in how those connections are managed, secured, and scaled.

The application also demonstrates full-stack ownership: a Go backend, a vanilla JavaScript frontend, a PostgreSQL schema, Docker containerization, and Makefile-driven workflows. Every layer is intentional and explainable.

Why This Project Exists

Real-time systems are a common interview topic, but building one from scratch teaches you things tutorials skip:

  1. How do you authenticate a WebSocket connection? HTTP headers during the upgrade, or query parameters?
  2. How do you broadcast to 500 clients without blocking the sender? Goroutines, channels, and non-blocking sends.
  3. How do you prevent goroutine leaks when a client disappears? Ping/pong, read deadlines, and deferred cleanup.
  4. How do you structure a Go project so it’s testable without a database? Interfaces, dependency injection, and the repository pattern.

This project exists to explore these questions hands-on.

Tech Stack

Component Technology Why
Language Go 1.24+ Standard library richness, goroutines, compiled performance
Router Chi (v5) Lightweight, context.Context native, clean middleware chain
Database PostgreSQL ACID transactions, JSON support, robust concurrency
Driver lib/pq Mature, battle-tested PostgreSQL driver for Go
WebSockets Gorilla WebSocket De facto standard, excellent examples, production-hardened
Auth JWT (golang-jwt) + bcrypt Stateless tokens, adaptive password hashing
Frontend Vanilla JS + CSS No build step, no framework lock-in, terminal aesthetic
Deployment Docker + Compose Reproducible builds, isolated environment, one-command startup

The deliberate choice to use the standard library for most things — net/http, database/sql, context — is significant. By avoiding ORMs and heavy frameworks, the project maintains full control over connection lifecycles, query execution, and error handling. Chi is the only external HTTP dependency, chosen because it adds routing and middleware without hiding the standard library’s interfaces.

Current Capabilities

As of the latest commit, the system can:

  • Register and authenticate users with bcrypt-hashed passwords and JWT tokens.
  • Create chat rooms, list them, and join/leave with membership tracking.
  • Send and receive real-time messages via WebSockets in joined rooms.
  • Persist all messages to PostgreSQL with room-scoped history queries.
  • Display join/leave notifications to all room members.
  • Serve a terminal-themed single-page application from static files.
  • Run entirely inside Docker with automatic database migrations.

What It Is Not (Yet)

It is important to set expectations. This is not a production chat platform. There is no horizontal scaling (the Hub is a single goroutine in one process), no direct messaging between users, no file uploads, no message editing or deletion, and no rate limiting. The WebSocket architecture details these gaps honestly.

Where to Go Next

  • To understand the high-level design, read 02 — Architecture and Design Patterns.
  • To dive into the concurrency model, read 03 — WebSocket Real-Time Architecture.
  • To understand auth and security, read 04 — Authentication & Security.
  • To see how it all deploys, read 05 — Deployment, Build & Operations.

02 — Architecture and Design Patterns

One of the most valuable aspects of go-chat is its disciplined use of Go idioms and structural patterns. Despite being a focused project, it follows a clean, layered architecture that would not look out of place in a much larger codebase. This note explores the structural decisions that make the system extensible, testable, and easy to reason about.

The Three Layers

The system is divided into three primary layers, each with a single, well-defined responsibility:

  1. HTTP Layer (cmd/api/): Handles routing, middleware, request parsing, and response writing. It knows about HTTP status codes, JSON, and WebSocket upgrades. It knows nothing about SQL.
  2. Store Layer (internal/store/): Handles database access. It knows about SQL queries, transactions, and PostgreSQL-specific types. It knows nothing about HTTP.
  3. Infrastructure Layer (internal/db/, internal/auth/, internal/websocket/): Provides cross-cutting concerns — database connections, password hashing, JWT validation, and real-time message routing.

This separation is crucial. You could swap PostgreSQL for MySQL by reimplementing the store interfaces. You could replace Chi with Gin by rewriting the router setup. The core logic remains untouched.

Interface-Driven Design

Go’s implicit interfaces are used heavily to enforce decoupling.

The Storage Interface

Defined in internal/store/storage.go, the Storage struct aggregates all data access contracts:

type Storage struct {
    Users interface {
        Create(context.Context, *User) error
        GetByEmail(context.Context, string) (*User, error)
        GetByID(context.Context, int64) (*User, error)
    }

    Rooms interface {
        Create(context.Context, *Room) error
        GetByID(context.Context, int64) (*Room, error)
        List(context.Context) ([]*Room, error)
        // ...
    }

    Messages interface {
        Create(context.Context, *Message) error
        GetRoomMessages(context.Context, int64, int) ([]*Message, error)
    }

    RoomMembers interface {
        Join(context.Context, int64, int64) error
        Leave(context.Context, int64, int64) error
        IsUserInRoom(context.Context, int64, int64) (bool, error)
    }
}

Each field is an interface, not a concrete type. The application struct holds a Storage, not a PostgresStorage. This means handlers are completely decoupled from PostgreSQL. In tests, you can inject a mock Storage that returns hardcoded data, eliminating the need for a test database.

The Application Struct

type application struct {
    config config
    store  store.Storage
    hub    *websocket.Hub
}

This is dependency injection in its simplest form. The application struct holds everything it needs. Handlers are methods on application, so they access app.store and app.hub directly. There are no package-level variables, no singletons, no hidden globals. This makes the system predictable and trivial to instantiate in tests.

Why Chi (Not Gin or Echo)

Go has many HTTP routers. Chi was chosen deliberately:

  • Standard library compatibility. Chi’s handlers use http.HandlerFunc and http.Handler. No custom context types, no wrapping. If you know net/http, you know Chi.
  • Middleware composability. Chi’s middleware is just func(http.Handler) http.Handler. You can write your own, use Chi’s built-ins, or mix standard library middleware. No vendor lock-in.
  • context.Context native. Chi passes *http.Request through the standard context system. This means timeouts, cancellation, and request-scoped values (like the authenticated user ID) flow naturally through every layer.
  • Lightweight. Chi is a router and middleware chain. It does not come with rendering, validation, or ORM. You add what you need.

Gin and Echo are excellent frameworks, but they hide the standard library behind their own abstractions. For a project whose goal is to demonstrate understanding of Go’s foundational patterns, that opacity is a liability.

The Middleware Chain

The router mounts a deliberate stack of middleware:

r.Use(middleware.RequestID)      // Unique ID per request for tracing
r.Use(middleware.RealIP)         // Extract real client IP behind proxies
r.Use(middleware.Logger)         // Structured request logging
r.Use(middleware.Recoverer)      // Catch panics, return 500, don't crash
r.Use(middleware.Timeout(60 * time.Second)) // Hard request timeout

Then, for protected routes:

r.Group(func(r chi.Router) {
    r.Use(app.AuthMiddleware)    // JWT validation
    // ... protected handlers
})

This is defense in depth. Recoverer prevents a single handler panic from crashing the server. Timeout prevents a slow database query from holding a connection open forever. AuthMiddleware runs after the generic stack, so even unauthenticated requests get logged and traced.

Context Propagation

context.Context is the backbone of the system. It flows from the HTTP request through every layer:

// Middleware adds userID to context
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))

// Handler extracts it
userID, _ := GetUserIDFromContext(r.Context())

// Store uses it for timeouts
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel
err := app.store.Messages.Create(ctx, msg)

This pattern solves three problems at once:

  1. Cancellation. If the client disconnects, r.Context() is cancelled, and the database query aborts instead of running to completion.
  2. Timeouts. Every store operation receives a context with a deadline. Slow queries fail fast.
  3. Request tracing. The RequestID middleware adds a unique ID to the context. If you log at every layer, you can trace a single request end-to-end.

The Options Struct Pattern

Every major component is configured via an options struct:

type config struct {
    addr string
    db   dbConfig
    auth authConfig
}

type dbConfig struct {
    addr         string
    maxOpenConns int
    maxIdleConns int
    maxIdleTime  string
}

This is a clean alternative to long constructor parameter lists. It allows for optional configuration, sensible defaults (via env.GetString and env.GetInt), and forward-compatible APIs. Adding a new config field never changes a function signature.

Testability

These patterns aren’t academic. They solve real problems:

  • You can test handlers without a database. Mock store.Storage and pass it to application.
  • You can test the Hub without HTTP. Create a Hub with a mock store and feed it messages directly through channels.
  • You can test auth without a server. Call auth.GenerateToken and auth.ValidateToken directly.

This is the difference between a demo and a maintainable system.

Interview Hook

Q: “Why interfaces instead of concrete structs?”

A: Interfaces define behavior, not implementation. In Go, they’re implicit — you don’t declare that a type satisfies an interface, you just implement the methods. This means I can write a MockMessageStore for tests that satisfies the Messages interface without importing the real PostgreSQL code. It also means I could swap PostgreSQL for SQLite in tests with a one-line change.

Q: “Why pass context.Context everywhere?”

A: It’s Go’s standard mechanism for cancellation, timeouts, and request-scoped values. If a user closes their browser mid-request, r.Context() is cancelled, and the database query aborts. Without context, a slow query would hold a connection open until it finished, starving the pool. It’s also the only clean way to pass the authenticated user ID from middleware to handlers without global state.

Q: “What would you change if this grew to 50 handlers?”

A: I’d split the application struct into smaller service structs — AuthService, RoomService, MessageService — each holding only the store interfaces they need. Handlers would depend on services, not the full Storage struct. This prevents every handler from having access to every database table, which improves both readability and security.

  • 01 - Project Overview: Goals, capabilities, and tech stack.
  • 03 — WebSocket Real-Time Architecture: How the Hub consumes the Storage interface for message persistence.
  • 04 - Authentication & Security: How AuthMiddleware injects user ID into context.Context.
  • 05 - Deployment, Build & Operations: How the config struct is populated from environment variables.

03 — WebSocket Real-Time Architecture

The WebSocket layer is the beating heart of go-chat. It transforms a simple HTTP API into a living, breathing real-time system where messages flow instantly between connected clients. This is also the most technically interesting part of the project — the part interviewers will ask about when they see “real-time chat” on a resume.

This note explores the Hub pattern, the goroutine model, and the concurrency decisions that make broadcasting to hundreds of clients both fast and safe.

The Problem

HTTP is request-response. A client asks, the server answers, and the connection closes. Chat doesn’t work like that. When User A sends a message, User B needs to receive it without asking for it. The naive approach — long polling — is wasteful: clients hammer the server with “any new messages?” requests, burning CPU and bandwidth.

WebSockets solve this by upgrading an HTTP connection into a persistent, full-duplex TCP socket. Once upgraded, either side can send data at any time. The challenge shifts from “how do we talk” to “how do we manage thousands of simultaneous conversations without leaking memory or blocking goroutines.”

The Hub Pattern

The Hub is a single goroutine that owns all WebSocket state. It is the central coordinator, the traffic cop, the post office. Every client connection flows through it.

type Hub struct {
    rooms      map[int64]map[*Client]bool  // roomID -> set of clients
    broadcast  chan *Message                // inbound messages from clients
    register   chan *Client                 // new connections
    unregister chan *Client                 // disconnections
    store      store.Storage                // persistence layer
}

Why a single goroutine? Because it eliminates the need for mutexes. The Hub owns the rooms map, and nothing else touches it. All access happens through channels, which are Go’s idiomatic way to share memory by communicating. This is the canonical pattern from the Gorilla WebSocket chat example, refined for production.

The Event Loop

func (h *Hub) Run() {
    for {
        select {
        case client := <-h.register:
            h.registerClient(client)
        case client := <-h.unregister:
            h.unregisterClient(client)
        case message := <-h.broadcast:
            h.handleBroadcast(message)
        }
    }
}

This for { select {} } loop is the entire Hub. It waits on three channels:

  1. register: A new client connected. Add them to their room’s client set.
  2. unregister: A client disconnected. Remove them, close their send channel, clean up empty rooms.
  3. broadcast: A message arrived. Persist it, then fan it out to every client in the room.

The select statement guarantees that handling one event never blocks another. A slow client cannot prevent a new client from registering.

The Client: Two Goroutines Per Connection

Every WebSocket connection spawns exactly two goroutines:

type Client struct {
    hub      *Hub
    conn     *websocket.Conn
    send     chan []byte        // buffered outbound messages
    userID   int64
    username string
    roomID   int64
}

readPump: Inbound Messages

readPump runs in its own goroutine and continuously reads from the WebSocket:

func (c *Client) readPump() {
    defer func() {
        c.hub.unregister <- c
        c.conn.Close()
    }()

    c.conn.SetReadLimit(maxMessageSize)
    c.conn.SetReadDeadline(time.Now().Add(pongWait))
    c.conn.SetPongHandler(func(string) error {
        c.conn.SetReadDeadline(time.Now().Add(pongWait))
        return nil
    })

    for {
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, ...) {
                log.Printf("WebSocket error: %v", err)
            }
            break
        }

        msg := &Message{
            RoomID:   c.roomID,
            UserID:   c.userID,
            Username: c.username,
            Content:  string(message),
            Type:     "message",
        }
        c.hub.broadcast <- msg
    }
}

The defer is critical. When readPump exits — whether from an error, a close frame, or a timeout — it sends the client to h.unregister and closes the connection. This guarantees cleanup even if the client vanishes without saying goodbye (power loss, network drop, browser crash).

The ping/pong mechanism detects dead connections. The server sends pings periodically. If the client doesn’t respond with a pong within pongWait (60 seconds), ReadMessage returns a timeout error and readPump exits. Without this, a client that disappears silently would leak a goroutine and a slot in the rooms map indefinitely.

writePump: Outbound Messages

writePump runs in a second goroutine and handles sending messages to the client:

func (c *Client) writePump() {
    ticker := time.NewTicker(pingPeriod)
    defer func() {
        ticker.Stop()
        c.conn.Close()
    }()

    for {
        select {
        case message, ok := <-c.send:
            if !ok {
                c.conn.WriteMessage(websocket.CloseMessage, [])
                return
            }

            w, _ := c.conn.NextWriter(websocket.TextMessage)
            w.Write(message)

            // Batch queued messages into a single frame
            n := len(c.send)
            for i := 0; i < n; i++ {
                w.Write([]byte{'\n'})
                w.Write(<-c.send)
            }
            w.Close()

        case <-ticker.C:
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            c.conn.WriteMessage(websocket.PingMessage, nil)
        }
    }
}

Why two goroutines? Because the WebSocket library is not thread-safe for concurrent reads and writes. By dedicating one goroutine exclusively to reading and one exclusively to writing, we eliminate all races on the connection without a single mutex.

The message batching optimization is subtle but important. If multiple messages arrive while writePump is busy, they queue in c.send. On the next iteration, instead of writing them one at a time, we drain the queue and batch them into a single WebSocket frame separated by newlines. Fewer frames = fewer syscalls = lower latency under load.

Broadcasting: The Fan-Out

When a message hits the broadcast channel, the Hub does two things: persist and propagate.

func (h *Hub) handleBroadcast(message *Message) {
    if message.Type == "message" {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()

        dbMessage := &store.Message{
            RoomID:  message.RoomID,
            UserID:  message.UserID,
            Content: message.Content,
        }
        if err := h.store.Messages.Create(ctx, dbMessage); err != nil {
            log.Printf("Failed to save message: %v", err)
        }
    }

    h.broadcastToRoom(message.RoomID, message)
}

Database persistence happens inline, in the Hub goroutine. This is a deliberate trade-off. The Hub is single-threaded, so a slow database write would block all other events. The 5-second timeout mitigates this — if the database hangs, we log the error and continue broadcasting. In a production system, you’d likely move persistence to a background worker or use an async queue.

The actual fan-out:

func (h *Hub) broadcastToRoom(roomID int64, message *Message) {
    clients := h.rooms[roomID]
    jsonMessage, _ := json.Marshal(message)

    for client := range clients {
        select {
        case client.send <- jsonMessage:
            // delivered to client's writePump
        default:
            // client's buffer is full — likely dead or slow
            close(client.send)
            delete(clients, client)
        }
    }
}

Three critical decisions here:

  1. Marshal once. We call json.Marshal a single time, then send the same []byte to every client. If 500 people are in a room, we serialize once, not 500 times.

  2. Non-blocking send. The select with a default case means we never wait for a slow client. If client.send is full, we close the channel and delete the client. One bad connection cannot stall the entire room.

  3. No mutex on rooms. Because the Hub is a single goroutine, the rooms map is only accessed from one thread. No locks, no contention, no deadlocks.

Join and Leave Notifications

When a client registers or unregisters, the Hub synthesizes system messages:

joinMessage := &Message{
    RoomID:   client.roomID,
    UserID:   client.userID,
    Username: client.username,
    Content:  client.username + " joined the room",
    Type:     "join",
}
h.broadcastToRoom(client.roomID, joinMessage)

These have Type: "join" or Type: "leave" instead of Type: "message". The persistence layer skips them — they are ephemeral UI notifications, not chat history. The frontend uses the type field to style them differently (typically gray, italic, centered).

Memory Model and Trade-Offs

Decision Choice Trade-Off
Hub goroutines 1 Simple, no locks. Becomes bottleneck at extreme scale.
Client goroutines 2 per connection Clean separation, no races on websocket.Conn. Higher memory per user.
Send buffer 256 messages Prevents slow clients from blocking. Drops messages if buffer fills.
Broadcast marshaling Once per message Saves CPU. Requires immutable []byte.
Database writes Inline in Hub Simple. Risk: slow DB stalls the Hub. Mitigated by timeout.

At 1,000 concurrent users, this costs roughly ~3,000 goroutines (1 Hub + 2 per client). In Go, goroutines are cheap — a few kilobytes each — so this is well within the capabilities of a single server. The real limit is the OS file descriptor count and network bandwidth, not Go’s scheduler.

What This Is Not (Yet)

This is a single-node design. The Hub lives in one process, and clients must connect to that process. If you want to run multiple server instances behind a load balancer, this architecture breaks: User A connects to Server 1, User B connects to Server 2, and the Hubs cannot see each other.

The standard solution is a message bus like Redis Pub/Sub or NATS. Each server subscribes to a room channel. When Server 1 receives a message, it publishes to Redis, and all servers — including Server 2 — receive it and broadcast to their local clients. The Hub’s fan-out logic stays identical; you just replace the broadcast channel with a bus subscription.

Interview Hook

Q: “How would you scale this to multiple servers?”

A: The single-Hub design is the bottleneck. I’d introduce Redis Pub/Sub as a backplane. Each server instance maintains its own Hub for local clients, but subscribes to Redis channels per room. When a message arrives on a WebSocket, the server publishes it to Redis. All servers receive it and fan it out to their local clients. The Hub’s broadcastToRoom logic doesn’t change — the broadcast channel just gets fed from Redis instead of a local readPump.

Q: “Why not use a mutex instead of a single Hub goroutine?”

A: You could. But channels are Go’s idiomatic concurrency primitive. A single goroutine with select is easier to reason about than a mutex-protected map that gets touched by thousands of goroutines. No risk of forgetting to unlock, no priority inversion, no deadlocks. The trade-off is throughput: one goroutine can only do one thing at a time. For chat, that’s usually fine.

Q: “What happens if the database write in handleBroadcast is slow?”

A: The 5-second context timeout prevents indefinite blocking. If the database is down, we log the error and continue broadcasting. Messages are delivered in real-time even if persistence fails. In a production system, I’d move writes to a background queue or use a write-behind cache to decouple broadcast speed from database latency.

  • 01 - Project Overview: Goals, tech stack, and what the system can do today.
  • 02 - Architecture and Design Patterns: Why interfaces and dependency injection make the Hub testable.
  • 04 - Authentication & Security: How JWT validation works during the WebSocket upgrade handshake.
  • 05 - Deployment, Build & Operations: How the Hub starts alongside the HTTP server in main.go.

04 — Authentication & Security

Authentication is the gatekeeper. Without it, anyone can read any room’s history. Without it, anyone can broadcast messages as anyone else. This note explores how go-chat handles identity, from password hashing to JWT validation to the WebSocket upgrade handshake.

Password Hashing with bcrypt

Passwords are never stored. Only their hashes survive.

func HashPassword(password string) (string, error) {
    hashedBytes, err := bcrypt.GenerateFromPassword(
        []byte(password),
        bcrypt.DefaultCost, // 10 = 2^10 iterations
    )
    return string(hashedBytes), err
}

Why bcrypt

bcrypt is an adaptive hashing function. Its cost parameter controls how many iterations it performs. DefaultCost is 10, which means 1,024 rounds of the Blowfish cipher. This is intentionally slow — a modern CPU can hash a password in ~100ms. An attacker with a GPU farm can try billions of SHA-256 hashes per second, but only thousands of bcrypt hashes. The asymmetry is the defense.

Why Not SHA-256 or MD5

SHA-256 is fast. That is its purpose. For passwords, fast is dangerous. A leaked database of SHA-256 password hashes can be cracked offline in hours using rainbow tables. bcrypt salts each password with a random 22-character string, so identical passwords produce different hashes, and rainbow tables are useless.

JWT Token Design

JSON Web Tokens are stateless. The server does not store session data. It signs a payload and sends it to the client. The client sends it back with every request. The server validates the signature and trusts the claims.

type Claims struct {
    UserID int64 `json:"user_id"`
    jwt.RegisteredClaims
}

func GenerateToken(userID int64, secret string) (string, error) {
    claims := &Claims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "go-chat",
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

Token Structure

A JWT has three parts, base64-encoded and joined by dots:

  1. Header: {"alg":"HS256","typ":"JWT"}
  2. Payload: {"user_id":42,"exp":...,"iss":"go-chat"}
  3. Signature: HMAC-SHA256(header + "." + payload, secret)

The signature is what makes the token tamper-evident. If a user changes their user_id to 1 (the admin), the signature no longer matches, and validation fails.

Why 24 Hours

Shorter expiration limits the window of damage if a token is stolen. A leaked 24-hour token is usable for a day; a leaked 30-day token is usable for a month. The trade-off is user experience — shorter tokens require more frequent logins. In a production system, you’d pair short-lived access tokens with long-lived refresh tokens.

Why HMAC-SHA256

HMAC (Hash-based Message Authentication Code) is a symmetric algorithm. The same secret signs and verifies. This is simple and fast, but it requires all servers to share the same secret. For distributed systems, asymmetric algorithms (RSA, ECDSA) are preferable because the verifying servers don’t need the private key.

The Auth Middleware

func (app *application) AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            writeError(w, http.StatusUnauthorized, "missing authorization header")
            return
        }

        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" {
            writeError(w, http.StatusUnauthorized, "invalid header format")
            return
        }

        userID, err := auth.ValidateToken(parts[1], app.config.auth.jwtSecret)
        if err != nil {
            writeError(w, http.StatusUnauthorized, "invalid token")
            return
        }

        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Design Decisions

Bearer scheme. The Authorization: Bearer <token> header is the OAuth 2.0 standard. It’s unambiguous and universally supported by HTTP clients.

Context injection. The middleware does not attach the user to a global map. It injects the userID into context.Context, which is request-scoped. When the request ends, the context is garbage-collected. No memory leak, no stale sessions.

Custom context key type. type contextKey string prevents collisions. If two packages both use "userID" as a context key, one overwrites the other. A custom unexported type guarantees uniqueness.

WebSocket Authentication

WebSocket connections begin as HTTP requests. The client sends an HTTP request to /v1/rooms/{roomID}/ws with an Upgrade: websocket header. The server validates the request, upgrades the connection, and the persistent socket is born.

But the WebSocket upgrade request must carry the JWT somehow. The project uses the query parameter approach:

ws://localhost:8080/v1/rooms/5/ws?token=eyJhbGci...

Why Query Parameter

The WebSocket JavaScript API (new WebSocket(url)) does not allow custom headers. You cannot send Authorization: Bearer ... during the upgrade. The alternatives are:

  1. Query parameter: Simple, works everywhere. Risk: token appears in server logs and browser history.
  2. Cookie: Sent automatically by the browser. But cookies require CSRF protection and don’t work well for SPAs with cross-origin setups.
  3. Subprotocol: Overloads the Sec-WebSocket-Protocol header. Non-standard and confusing.

The query parameter is the pragmatic choice for this project. In production, you’d use short-lived tokens specifically for WebSocket connections, or switch to cookies with SameSite and Secure flags.

Room Membership Check

Before upgrading, the handler verifies the user is a member of the room:

inRoom, _ := app.store.RoomMembers.IsUserInRoom(r.Context(), roomID, userID)
if !inRoom {
    http.Error(w, "not a member", http.StatusForbidden)
    return
}

This prevents users from connecting to rooms they haven’t joined. The check happens at the HTTP layer, before the expensive WebSocket upgrade.

SQL Injection Prevention

All database queries use parameterized statements:

query := `INSERT INTO messages (room_id, user_id, content) VALUES ($1, $2, $3)`
_, err := db.ExecContext(ctx, query, msg.RoomID, msg.UserID, msg.Content)

The $1, $2, $3 placeholders are bound by the driver. User input is never concatenated into SQL strings. This is the only acceptable pattern for database access. String concatenation, even with escaping, is a bug waiting to happen.

What This Is Not (Yet)

  • No refresh tokens. Tokens expire in 24 hours and require re-login. Refresh tokens would allow silent renewal.
  • No rate limiting. An attacker can attempt unlimited logins. bcrypt slows brute-force, but account lockout or CAPTCHA would help.
  • No HTTPS enforcement. The Docker setup serves HTTP. In production, TLS termination is mandatory.
  • No audit logging. Failed logins, token validation errors, and suspicious room joins are logged to stdout but not persisted for forensics.

Interview Hook

Q: “Why JWT instead of session cookies?”

A: JWTs are stateless. The server doesn’t store session data, which means any server instance can validate a token without a shared session store. This scales horizontally — Server 1 issues a token, Server 2 validates it, no Redis required. The trade-off is revocation: you cannot invalidate a JWT before it expires without a blacklist. For chat, where sessions are short and re-login is acceptable, stateless is the right call.

Q: “How would you handle token theft?”

A: Short expiration is the first line of defense — a stolen token is only usable for 24 hours. Second, I’d add refresh tokens with rotation: each refresh issues a new access token and a new refresh token, invalidating the old one. If an attacker steals a refresh token and the legitimate user refreshes first, the attacker’s token becomes invalid. Finally, binding tokens to IP or device fingerprint would detect anomalies.

Q: “Why is bcrypt cost 10? Would you increase it?”

A: 10 is the Go default and a reasonable balance — ~100ms per hash on modern hardware. I’d monitor login latency and increase it to 12 or 14 as hardware improves. The OWASP recommendation is to tune cost so hashing takes at least 250ms. The key is that it’s adaptive: existing hashes don’t break when you increase the cost, because the cost is stored as part of the hash string.

  • 01 - Project Overview: What the system does and why it exists.
  • 02 - Architecture and Design Patterns: How AuthMiddleware fits into the middleware chain and context propagation.
  • 03 — WebSocket Real-Time Architecture: How JWTs are passed during the WebSocket upgrade handshake.
  • 05 - Deployment, Build & Operations: How JWT_SECRET is injected via environment variables.

05 — Deployment, Build & Operations

A backend system that only runs on one developer’s laptop is a prototype, not a project. go-chat is designed to be deployed by anyone with one command. This note explores the Docker setup, the multi-stage build, the migration strategy, and the Makefile that ties it all together.

Docker Multi-Stage Build

The Dockerfile uses two stages:

# Stage 1: Builder
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o bin/chat cmd/api/*.go
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o bin/migrate cmd/migrate/main.go

# Stage 2: Runtime
FROM alpine:latest
RUN apk --no-cache add ca-certificates netcat-openbsd
WORKDIR /app
COPY --from=builder /app/bin/chat /app/bin/migrate /app/
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
RUN adduser -D -u 1000 appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]

Why Multi-Stage

The builder image contains the full Go toolchain, compiler, and source code — hundreds of megabytes. The runtime image contains only the compiled binaries, CA certificates, and netcat — under 20MB. This reduces attack surface, speeds up deployments, and eliminates unnecessary tooling in production.

Why CGO_ENABLED=0

This produces a fully static binary with no dynamic linking to C libraries. The binary can run on any Linux distribution, including scratch or distroless images. Without this, the binary links to glibc and fails on Alpine’s musl.

Why -ldflags="-w -s"

  • -w disables DWARF debugging info.
  • -s strips the symbol table.

Together they reduce binary size by ~30%. In a container, you don’t need debug symbols. If you need to debug, you build a separate image without these flags.

Why Non-Root User

The final image runs as appuser (UID 1000), not root. If the application is compromised, the attacker gains the privileges of appuser — which is none. They cannot install packages, modify system files, or escape the container easily. This is a standard security baseline.

Docker Compose Orchestration

version: '3.8'

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: gochat
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d gochat"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DB_ADDR: postgres://user:password@db:5432/gochat?sslmode=disable
      JWT_SECRET: ${JWT_SECRET}
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

Why PostgreSQL Health Checks

The app service uses depends_on with condition: service_healthy. This means the Go application does not start until PostgreSQL is actually ready to accept connections — not just until the container is running. Without this, the app would crash on startup because the database wasn’t initialized yet.

Why Named Volumes

postgres_data is a named Docker volume. It persists the database across container restarts and rebuilds. If you run docker-compose down -v, the volume is deleted and you get a fresh database. This is the standard development workflow.

The Entrypoint Script

#!/bin/sh
set -e

echo "Waiting for PostgreSQL..."
while ! nc -z db 5432; do
  sleep 0.1
done
echo "PostgreSQL is up"

echo "Running migrations..."
./migrate up

echo "Starting application..."
exec ./chat

Why nc (netcat)

The app container uses netcat-openbsd to poll the database port. Go’s PostgreSQL driver does not retry connections aggressively. Waiting in the entrypoint script ensures migrations run against a live database.

Why exec for the Final Command

exec ./chat replaces the shell process with the Go binary. This means the Go process becomes PID 1, which receives Unix signals correctly. Without exec, signals go to the shell, which might not forward them, breaking graceful shutdown.

Database Migrations

Migrations live in db/migrations/ as numbered .up.sql and .down.sql files:

db/migrations/
  001_users.up.sql
  001_users.down.sql
  002_rooms.up.sql
  002_rooms.down.sql
  ...

The migration runner in cmd/migrate/main.go executes them in order. The entrypoint.sh runs migrate up before starting the app. This means a fresh deployment automatically creates all tables.

Why Custom Migration Tool

The project uses a custom cmd/migrate binary instead of a third-party tool like golang-migrate. This keeps the dependency surface minimal and demonstrates understanding of how schema versioning works. In production, golang-migrate or Atlas would be preferable for features like transactional migrations and drift detection.

The Makefile

.PHONY: build run migrate-up migrate-down deps setup

build:
	go build -o bin/chat cmd/api/*.go

run:
	go run cmd/api/*.go

migrate-up:
	go run cmd/migrate/main.go up

migrate-down:
	go run cmd/migrate/main.go down

deps:
	go mod tidy

setup: deps migrate-up

Why Make

Make is universal. Every developer knows make build. It requires no Node.js, no task runners, no YAML parsers. The targets are self-documenting. For a Go project, Make is often sufficient — go build, go test, and go run are fast and deterministic.

Local Development Workflow

cp .env.example .env
make setup    # install deps, run migrations
make run      # start server on :8080

In Docker:

docker-compose up        # build and start everything
docker-compose logs -f   # tail logs
docker-compose down -v   # destroy everything (fresh start)

What This Is Not (Yet)

  • No CI/CD pipeline. There is no GitHub Actions or GitLab CI. Builds are manual.
  • No health checks on the app. The Go server has a /v1/health endpoint, but Docker Compose does not use it for orchestration.
  • No log aggregation. Logs go to stdout. In production, you’d ship them to Loki, Datadog, or CloudWatch.
  • No secrets management. JWT_SECRET is injected via environment variable. In production, use Vault, AWS Secrets Manager, or Kubernetes secrets.
  • No zero-downtime deployment. docker-compose up stops the old container before starting the new one. For production, you need rolling updates, blue-green deployment, or Kubernetes.

Interview Hook

Q: “How would you run database migrations in Kubernetes?”

A: I’d use an init container. The main app container doesn’t start until the init container finishes. The init container runs the same migrate up binary, then exits. This guarantees the schema is correct before the app accepts traffic. For rollback safety, migrations must be backward-compatible: additive changes only (new columns, new tables). Destructive changes (dropping columns) happen in a separate deployment after the app no longer references them.

Q: “Why Alpine for the runtime image?”

A: Alpine is ~5MB. A full Debian or Ubuntu image is ~100MB. Smaller images mean faster pulls, faster deploys, and less attack surface. The trade-off is that Alpine uses musl instead of glibc, which can cause issues with C dependencies. Since go-chat is a pure Go binary with CGO_ENABLED=0, this is not a concern.

Q: “How would you handle secrets in production?”

A: Never commit secrets to Git. In Docker Compose, use .env files that are .gitignored. In Kubernetes, use sealed secrets or external secrets operators. In cloud environments, use the provider’s secret manager (AWS Secrets Manager, GCP Secret Manager) and mount them as environment variables or volumes. The application should fail fast on startup if a required secret is missing — no defaults, no fallbacks.

  • 01 - Project Overview: What the system does and its current capabilities.
  • 02 - Architecture and Design Patterns: How the config struct maps environment variables to the application.
  • 03 — WebSocket Real-Time Architecture: Why the Hub runs in a goroutine alongside the HTTP server.
  • 04 - Authentication & Security: How JWT_SECRET is consumed by the auth layer.