Building Local HTTP Servers in Go: From Development to Production Patterns

Building Local HTTP Servers in Go: From Development to Production Patterns

I've been building HTTP servers in Go for years, and I keep coming back to the same question: what's actually the best way to structure these things? You see Mat Ryer's approach (brilliant, but feels complicated), then there's the full framework camp with Gin and Echo, and somewhere in between are people hand-rolling everything with the standard library.

After looking at how HTTP servers are built at Visa and across a few fintech projects, I've settled into patterns that work. Not the most elegant, not the most minimal, but patterns that get you from "works on my machine" to production without wanting to rewrite everything halfway through.

Here's what I've figured out about building Go HTTP servers that actually work well locally and scale to production.

The Framework Question: Gin + Selective Gorilla

Let me start with the big decision: framework or no framework?

I used to hand-roll everything. Standard library only, middleware written from scratch, routing done manually. It felt pure, but honestly, it was a pain. You end up writing the same middleware patterns over and over.

Now I reach for Gin as my default choice for any go http server setup. It's not because it's perfect - it's because it hits that sweet spot between simplicity and having robust middleware systems built in. When I need something Gin doesn't handle well (like more complex routing patterns), I'll pull in Gorilla for those specific bits.

Here's my basic server structure:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
)

type Server struct {
    db     Database
    router *gin.Engine
}

func NewServer(db Database) *Server {
    return &Server{
        db: db,
    }
}

func (s *Server) setupRoutes() {
    s.router = gin.New()
    
    // Basic middleware stack
    s.router.Use(gin.Logger())
    s.router.Use(gin.Recovery())
    
    // Routes
    v1 := s.router.Group("/api/v1")
    {
        v1.GET("/health", s.healthCheck)
        v1.POST("/users", s.createUser)
        v1.GET("/users/:id", s.getUser)
    }
}

func (s *Server) Start(addr string) error {
    s.setupRoutes()
    
    srv := &http.Server{
        Addr:         addr,
        Handler:      s.router,
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
    
    // Start server in a goroutine
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Failed to start server: %v", err)
        }
    }()
    
    // Wait for interrupt signal for graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    // Graceful shutdown
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    return srv.Shutdown(ctx)
}

func main() {
    db, err := NewDatabase(os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    defer db.Close()
    
    server := NewServer(db)
    if err := server.Start(":8080"); err != nil {
        log.Fatal("Server failed:", err)
    }
}

Nothing fancy here - just Gin with some sensible defaults and graceful shutdown baked in from the start.

Setting Up Your Local Go Development Environment

One thing I love about Go is how easy it is to get a proper development environment running. No complex build processes, no mysterious configuration files - just code and run.

For any golang local development, I use a Makefile to spin up a Postgres Docker container with standard everything. This means your local environment mirrors production as closely as possible, without the hassle of installing databases locally.

Here's the Makefile setup I use:

.PHONY: dev-db dev-up dev-down build run

dev-db:
	docker run --name dev-postgres -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=devdb -p 5432:5432 -d postgres:15

dev-up: dev-db
	sleep 2
	go run main.go

dev-down:
	docker stop dev-postgres || true
	docker rm dev-postgres || true

build:
	go build -o bin/server main.go

run: build
	./bin/server

The beauty of this approach is that your golang server starts up in seconds, connects to a real database, and behaves exactly like it will in production. No mocking databases, no weird in-memory setups that break when you try to test complex queries.

Handling HTTP Methods and Request Parsing

This is where Go really shines compared to other languages. The standard HTTP handling is solid, and with Gin, you get clean method routing without much ceremony.

Here's how I typically structure my golang http post handlers and other HTTP methods:

type App struct {
    db     Database
    logger Logger
}

func (app *App) setupRoutes(router *gin.Engine) {
    // Public routes
    public := router.Group("/api/v1")
    {
        public.GET("/health", app.healthCheck)
        public.POST("/register", app.handleRegistration)
        public.POST("/login", app.handleLogin)
    }
    
    // Authenticated routes
    authenticated := router.Group("/api/v1")
    authenticated.Use(app.authMiddleware())
    {
        authenticated.GET("/profile", app.getProfile)
        authenticated.PUT("/profile", app.updateProfile)
        authenticated.POST("/transactions", app.createTransaction)
    }
    
    // Admin routes
    admin := router.Group("/api/v1/admin")
    admin.Use(app.authMiddleware(), app.adminMiddleware())
    {
        admin.GET("/users", app.listUsers)
        admin.DELETE("/users/:id", app.deleteUser)
    }
}

For POST requests, here's my standard pattern for handling both JSON and form data:

type CreateUserRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
    Name     string `json:"name" binding:"required"`
}

func (app *App) handleRegistration(c *gin.Context) {
    var req CreateUserRequest
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid request format",
            "details": err.Error(),
        })
        return
    }
    
    // Check if user exists
    if exists, err := app.db.UserExists(req.Email); err != nil {
        app.logger.Error("Failed to check user existence", "error", err)
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Internal server error",
        })
        return
    } else if exists {
        c.JSON(http.StatusConflict, gin.H{
            "error": "User already exists",
        })
        return
    }
    
    // Create user
    user, err := app.db.CreateUser(req.Email, req.Password, req.Name)
    if err != nil {
        app.logger.Error("Failed to create user", "error", err)
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to create user",
        })
        return
    }
    
    c.JSON(http.StatusCreated, gin.H{
        "message": "User created successfully",
        "user_id": user.ID,
    })
}

The key here is proper validation upfront and consistent error responses. Gin's binding system handles most of the heavy lifting for request parsing.

Error Handling the Go Way

This is where the ok golang pattern really matters in HTTP contexts. Go's explicit error handling shines in web servers because you can control exactly how errors surface to your users.

Here's my standard error handling pattern for HTTP servers:

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Details string `json:"details,omitempty"`
}

func (e APIError) Error() string {
    return e.Message
}

// Database layer with proper error handling
func (db *Database) GetUser(id string) (*User, error) {
    user := &User{}
    err := db.conn.QueryRow(
        "SELECT id, name, email FROM users WHERE id = $1", 
        id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    
    if err == sql.ErrNoRows {
        return nil, ErrUserNotFound
    }
    
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    
    return user, nil
}

// HTTP handler with clean error handling
func (app *App) getUser(c *gin.Context) {
    id := c.Param("id")
    
    user, err := app.db.GetUser(id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            c.JSON(http.StatusNotFound, APIError{
                Code:    404,
                Message: "User not found",
            })
            return
        }
        
        app.logger.Error("Failed to get user", "user_id", id, "error", err)
        c.JSON(http.StatusInternalServerError, APIError{
            Code:    500,
            Message: "Internal server error",
        })
        return
    }
    
    c.JSON(http.StatusOK, user)
}

// For operations that might not find results, use explicit checking
func (app *App) searchUsers(c *gin.Context) {
    query := c.Query("q")
    if query == "" {
        c.JSON(http.StatusBadRequest, APIError{
            Code:    400,
            Message: "Search query required",
        })
        return
    }
    
    users, err := app.db.SearchUsers(query)
    if err != nil {
        app.logger.Error("Failed to search users", "query", query, "error", err)
        c.JSON(http.StatusInternalServerError, APIError{
            Code:    500,
            Message: "Search failed",
        })
        return
    }
    
    // Return empty array if no results, not an error
    c.JSON(http.StatusOK, gin.H{
        "users": users,
        "count": len(users),
    })
}

The pattern here is: distinguish between business logic errors (not found) and system errors, always log system errors, and never expose internal error details to the client.

Production-Ready Patterns

Here's where things get interesting. Getting from a basic server to something production-ready involves several patterns I've learned the hard way.

Middleware Stack

My standard middleware stack looks like this:

func (app *App) setupMiddleware(router *gin.Engine) {
    // Request ID for tracing
    router.Use(app.requestIDMiddleware())
    
    // Structured logging
    router.Use(app.loggingMiddleware())
    
    // Rate limiting
    router.Use(app.rateLimitMiddleware())
    
    // Recovery (panic handling)
    router.Use(gin.Recovery())
    
    // CORS if needed
    router.Use(app.corsMiddleware())
}

func (app *App) requestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        requestID := c.GetHeader("X-Request-ID")
        if requestID == "" {
            requestID = generateRequestID()
        }
        c.Set("requestID", requestID)
        c.Header("X-Request-ID", requestID)
        c.Next()
    }
}

func (app *App) rateLimitMiddleware() gin.HandlerFunc {
    limiter := rate.NewLimiter(10, 100) // 10 req/sec, burst of 100
    
    return func(c *gin.Context) {
        if !limiter.Allow() {
            c.JSON(http.StatusTooManyRequests, APIError{
                Code:    429,
                Message: "Too many requests",
            })
            c.Abort()
            return
        }
        c.Next()
    }
}

func (app *App) loggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        raw := c.Request.URL.RawQuery
        
        c.Next()
        
        latency := time.Since(start)
        clientIP := c.ClientIP()
        method := c.Request.Method
        statusCode := c.Writer.Status()
        
        if raw != "" {
            path = path + "?" + raw
        }
        
        app.logger.Info("Request completed",
            "method", method,
            "path", path,
            "status", statusCode,
            "latency", latency,
            "ip", clientIP,
            "request_id", c.GetString("requestID"),
        )
    }
}

Graceful Shutdown with Background Jobs

This is crucial if you're running background jobs or have queues. Here's how I handle graceful shutdown when there are running transactions:

type App struct {
    db       Database
    jobQueue JobQueue
    logger   Logger
    server   *http.Server
}

func (app *App) Start(addr string) error {
    router := gin.New()
    app.setupMiddleware(router)
    app.setupRoutes(router)
    
    app.server = &http.Server{
        Addr:         addr,
        Handler:      router,
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
    
    // Start background job processor
    go app.jobQueue.Start()
    
    // Start server in goroutine
    go func() {
        app.logger.Info("Server starting", "addr", addr)
        if err := app.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            app.logger.Error("Server failed to start", "error", err)
        }
    }()
    
    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    return app.Shutdown()
}

func (app *App) Shutdown() error {
    app.logger.Info("Starting graceful shutdown")
    
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    // Stop accepting new HTTP requests
    if err := app.server.Shutdown(ctx); err != nil {
        app.logger.Error("HTTP server shutdown failed", "error", err)
    }
    
    // Drain job queue with timeout
    queueCtx, queueCancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer queueCancel()
    
    if err := app.jobQueue.Drain(queueCtx); err != nil {
        app.logger.Warn("Job queue drain timeout", "error", err)
        app.jobQueue.Stop() // Force stop
    }
    
    // Close database connections
    if err := app.db.Close(); err != nil {
        app.logger.Error("Database close failed", "error", err)
    }
    
    app.logger.Info("Graceful shutdown complete")
    return nil
}

Context Handling and Timeouts

For any external calls, proper context handling is essential. Here's how I structure this, which builds on patterns I covered in my golang time handling article:

type ExternalAPIClient struct {
    baseURL string
    client  *http.Client
    logger  Logger
}

func NewExternalAPIClient(baseURL string, logger Logger) *ExternalAPIClient {
    return &ExternalAPIClient{
        baseURL: baseURL,
        client: &http.Client{
            Timeout: 30 * time.Second,
        },
        logger: logger,
    }
}

func (e *ExternalAPIClient) FetchUserData(ctx context.Context, userID string) (*UserData, error) {
    url := fmt.Sprintf("%s/users/%s", e.baseURL, userID)
    
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("creating request: %w", err)
    }
    
    resp, err := e.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("making request: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
    }
    
    var userData UserData
    if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil {
        return nil, fmt.Errorf("decoding response: %w", err)
    }
    
    return &userData, nil
}

func (app *App) handleExternalData(c *gin.Context) {
    userID := c.Param("id")
    
    // Use request context for automatic cancellation
    userData, err := app.externalAPI.FetchUserData(c.Request.Context(), userID)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            c.JSON(http.StatusGatewayTimeout, APIError{
                Code:    504,
                Message: "External service timeout",
            })
            return
        }
        
        app.logger.Error("External API call failed", "user_id", userID, "error", err)
        c.JSON(http.StatusBadGateway, APIError{
            Code:    502,
            Message: "External service error",
        })
        return
    }
    
    c.JSON(http.StatusOK, userData)
}

Testing Your HTTP Server

Testing Go HTTP servers is straightforward, but there are a few patterns that make it much easier.

For integration testing, I create a test app that uses the same patterns as production:

func newTestApp() *App {
    db := newTestDatabase()
    logger := newTestLogger()
    
    return &App{
        db:     db,
        logger: logger,
    }
}

func TestUserRegistration(t *testing.T) {
    app := newTestApp()
    defer app.db.Close()
    
    gin.SetMode(gin.TestMode)
    router := gin.New()
    app.setupRoutes(router)
    
    tests := []struct {
        name           string
        payload        interface{}
        expectedStatus int
        checkResponse  func(t *testing.T, body string)
    }{
        {
            name: "valid registration",
            payload: CreateUserRequest{
                Email:    "test@example.com",
                Password: "password123",
                Name:     "Test User",
            },
            expectedStatus: http.StatusCreated,
            checkResponse: func(t *testing.T, body string) {
                assert.Contains(t, body, "User created successfully")
            },
        },
        {
            name: "invalid email",
            payload: CreateUserRequest{
                Email:    "invalid-email",
                Password: "password123",
                Name:     "Test User",
            },
            expectedStatus: http.StatusBadRequest,
            checkResponse: func(t *testing.T, body string) {
                assert.Contains(t, body, "Invalid request format")
            },
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            body, _ := json.Marshal(tt.payload)
            req := httptest.NewRequest("POST", "/api/v1/register", bytes.NewReader(body))
            req.Header.Set("Content-Type", "application/json")
            
            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)
            
            assert.Equal(t, tt.expectedStatus, w.Code)
            if tt.checkResponse != nil {
                tt.checkResponse(t, w.Body.String())
            }
        })
    }
}

func TestGetUser(t *testing.T) {
    app := newTestApp()
    defer app.db.Close()
    
    // Create test user
    user, err := app.db.CreateUser("test@example.com", "password", "Test User")
    require.NoError(t, err)
    
    gin.SetMode(gin.TestMode)
    router := gin.New()
    app.setupRoutes(router)
    
    req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/users/%s", user.ID), nil)
    w := httptest.NewRecorder()
    
    router.ServeHTTP(w, req)
    
    assert.Equal(t, http.StatusOK, w.Code)
    
    var response User
    err = json.Unmarshal(w.Body.Bytes(), &response)
    require.NoError(t, err)
    
    assert.Equal(t, user.Email, response.Email)
    assert.Equal(t, user.Name, response.Name)
}

The key is using the same route setup and middleware as your production server, just with a test database and logger.

Connecting the Production Dots

These patterns work well together. The middleware stack provides consistent logging and error handling. The graceful shutdown ensures you don't lose data during deployments. The context handling means timeouts are respected throughout the request chain.

This approach has served me well across different projects, from building payment processing systems to creating production-ready Go packages for LLM integration. The patterns scale from simple APIs to complex financial systems.

If you're dealing with more complex deployment scenarios, you might also find my CI/CD deployment guide useful for getting these servers into production.

The beauty of this approach is that it starts simple but grows with your needs. You can begin with the basic Gin setup and add middleware, testing, and production patterns as your application demands them.

What's Next

Once you have this foundation, you can start thinking about more advanced patterns: distributed tracing, metrics collection, or even building your own development workflow tools to make the development process even smoother.

The goal isn't to have the most elegant code or the most minimal framework usage. It's to have patterns that work reliably from development through production, without requiring rewrites along the way.


Need help with your business?

Enjoyed this post? I help companies navigate AI implementation, fintech architecture, and technical strategy. Whether you're scaling engineering teams or building AI-powered products, I'd love to discuss your challenges.

Learn more about how I can support you.

Get practical insights weekly

Real solutions to real problems. No fluff, just production-ready code and workflows that work.
You've successfully subscribed to Kyle Redelinghuys
Great! Next, complete checkout to get full access to all premium content.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.