Command Palette

Search for a command to run...

Go HTTP Server

Timeline

Built With

Go

Topics

Introduction

This post documents my learning process regarding building an HTTP server in Go. Since I am still fairly new to Go, this post contains not only concepts about HTTP servers, but also some Go coding styles and practices that I discovered along the way.

Server

Defining a basic server in Go looks like this:

The code is simple enough, feeling very similar to setting up a server with Express.js in JavaScript. However, since I’m new to Go, I wanted to dive into why we write it this way—specifically, that & ampersand.

In the official Go documentation(opens in a new tab), the examples always use the & symbol before http.Server{}. Interestingly, if I remove the ampersand, the compiler doesn't throw an error. This is because Go is smart enough to handle the conversion, but the underlying mechanics are important.

If you hover over server.ListenAndServe(), you’ll see the function signature requires a pointer receiver: func (s *Server) ListenAndServe() error.

We use a pointer here for two main reasons:

  1. Efficiency: A server struct is large. You generally only need one instance, and you don't want to pass around heavy copies of it throughout your program.
  2. Safety: The http.Server struct contains atomic fields (like inShutdown) to prevent race conditions. Go’s sync/atomic types must not be copied after first use. If you copy the server, you risk breaking the synchronization logic that keeps the server's state consistent.

Style Tip: &Server{} vs. new(Server)

If you come from a Java or C# background, your instinct might be to use the new keyword. In Go, you can do that, but you'll rarely see it in production code.

While they are functionally identical, the Go community almost exclusively prefers the composite literal (&T{}) for two reasons:

  1. Consistency: The &T{} syntax allows you to initialize fields immediately. new(T) only allows for zero-initialization, forcing you to use multiple lines to set up your struct.
  2. Visual Clarity: The & symbol is a loud, clear signal that "this is a pointer." In a language where memory address vs. value matters, having that explicit symbol right at the moment of creation is preferred.

The Rule of Thumb: Use &T{} for everything. Use new(T) only if you want to emphasize that you are creating an empty, zero-initialized value.

A Note on Memory: Escape Analysis

Since we are using &http.Server, we are dealing with pointers. In many languages, using a pointer (or the new keyword) explicitly tells the computer to put that data on the Heap.

In Go, the compiler is more intelligent. It uses a process called Escape Analysis. During compilation, Go looks at your code and asks: "Does this variable 'escape' the function it was created in?"

  • The Stack: If the variable is only used inside the function, Go keeps it on the Stack. This is incredibly fast because the memory is reclaimed the moment the function finishes.
  • The Heap: If the variable is returned from the function or shared with other parts of the program (like our server being passed around), it "escapes" to the Heap.

The beauty of Go is that you don't have to manually decide between Stack and Heap like you do in C or C++. You simply write your code naturally. If you use the & operator, the compiler decides the most efficient place to put it.

Why should we care? While we don't "control" it, we should be aware that "Heap = Work." Anything on the heap must eventually be cleaned up by the Garbage Collector (GC). If we put too many things on the heap unnecessarily, our program might slow down while the GC works. By letting Go manage this, we get the safety of a managed language with performance that rivals lower-level languages.

The Dangers of the DefaultServeMux

In Go, a ServeMux is an HTTP request multiplexer. Its job is to match the URL of an incoming request against a list of registered patterns and call the appropriate handler. It also handles path sanitization and pattern precedence.

Go provides a DefaultServeMux out of the box, but you should almost never use it.

The DefaultServeMux is a global variable stored in the net/http package. When you call package-level functions like http.HandleFunc("/", ...), you are modifying this global instance.

The "Malicious Library" Scenario

The danger of a global variable is that anyone can change it. In Go, the init() function runs automatically when a package is imported. If you import a third-party library—even if you don't use any of its functions—that library can secretly register routes to your server.

Imagine a malicious "analytics" package:

If you import this package using the "blank import" (_), the library's init() function runs, and suddenly your server is hosting a route you didn't authorize:

The Solution: Explicit Muxing

To prevent this, you should always create your own local ServeMux. By passing your custom mux into the http.Server struct, you ensure the server only knows about the routes you explicitly defined.

Now, even if a library tries to poison the DefaultServeMux, your server is shielded because it isn't using the global instance.

Serving Static Files Safely

If you are building a web server that needs to serve HTML, CSS, or images, Go provides a built-in http.FileServer. This handler takes an incoming HTTP request and maps it to the file system.

The "Development" Trap

During early development, it’s tempting to serve everything from your root directory:

This works for seeing your index.html, but it creates a massive security flaw. A malicious user could visit localhost:8081/main.go or localhost:8081/go.mod and read your entire backend source code.

The Secure Approach: Isolation and StripPrefix

To secure the server, you must isolate your public assets into a dedicated folder like ./public and use http.StripPrefix. The http.StripPrefix function is necessary because of how the FileServer looks for files.

If a user visits localhost:3000:/app, the FileServer will try to find a file at ./public/app/index.html. Since the /app folder doesn't exist inside /public, it would return a 404. We need to strip the /app/ prefix so the FileServer only sees index.html.

With this setup, the visitor is "jailed" within the ./public folder. They can access yoursite.com/app/, but they can never move "up" the directory tree to access your private source code or environment variables.

Stateful Handlers: Moving Beyond Global Variables

A classic "Hello World" in Go looks like this:

But what if we want to track state—like a counter that increments every time a user visits? Your first instinct might be to use a global variable:

While this works, it’s a maintenance nightmare. As your app grows, you’ll have database connections, platform settings, and multiple counters. Scattering these as globals makes your code hard to test and even harder to read.

The "Shared Brain": The apiConfig Struct

The idiomatic Go solution is to bundle everything related to your app’s state into a single struct. We then turn our handlers into methods of that struct using a "pointer receiver."

By doing this, we achieve Dependency Injection. Any function attached to apiConfig immediately knows how to talk to the database or increment the hit counter without needing global variables.


Middleware: The "Handler Factory"

A standalone handler is great, but what if you want to count hits on every request—including requests for static files? You don’t want to manually add cfg.fileserverHits.Add(1) to every single route. This is where Middleware comes in.

Think of middleware as a factory. It takes an existing handler, wraps it in a "costume," and returns a new handler. The signature looks like this: func(next http.Handler) http.Handler.

Building the Metric Middleware

Here is how we turn our hit counter into a bridge that sits between the server and the final destination:

The "Domino Effect" in Reality

It is important to remember that the Go server itself has no idea what "middleware" is. When we write this line:

mux.Handle("/app/", cfg.middlewareMetricsInc(fsHandler))

The function middlewareMetricsInc runs immediately (at startup), creates an anonymous function that "captures" the fsHandler, and hands that anonymous function to the ServeMux. When a request actually arrives, here is the real sequence:

  1. The Server calls the Product: The server executes the ServeHTTP method of the anonymous function created by our factory.
  2. The Work Happens: Inside that anonymous function, our code increments the counter: cfg.fileserverHits.Add(1).
  3. The Baton Pass: The anonymous function then calls next.ServeHTTP(w, r). Because it "captured" the fsHandler earlier, this call triggers the FileServer's logic.

This is why we call it a chain. Each handler is responsible for calling the next one. If our anonymous function forgot to call next.ServeHTTP, the request would simply stop there—the "domino" effect would be broken, and the FileServer would never run.

Database Infrastructure

Docker

For development, I use Docker to spin up a PostgreSQL instance. While my Go application performs a db.Ping() at startup, I also implemented a Docker-level healthcheck using pg_isready.

This provides a higher level of infrastructure reliability. It prevents the "startup race condition" where the Go app tries to connect before the database is actually ready to accept connections. By adding this check, Docker ensures the database service is "Healthy" before dependent services attempt to communicate with it.

Tooling: Goose and SQLC

In Go, it is standard practice to separate build-time tools from runtime dependencies. To keep my application binaries clean—containing only the standard library database/sql and the necessary driver lib/pq—I use two specific tools:

  1. Goose(opens in a new tab): For version-controlled database migrations.
  2. SQLC(opens in a new tab): To generate type-safe Go code from raw SQL.

This setup prevents "ORM bloat" and gives us the performance of raw SQL with the safety of a compiled language.

Configuration for sqlc lives in sqlc.yaml, telling it where to find my queries and where to output the Go code:

Migrations

We define our schema in sql/schema. Standard convention is to number files sequentially like 001_users.sql so the tool knows the order of execution.

The annotations -- +goose Up and -- +goose Down are directives for the tool.

  • Up: Defines how to apply the change.
  • Down: Defines how to undo the change.

In a professional environment, the "Down" migration is critical for rolling back changes if a deployment fails. When we run goose postgres "{$DB_URL}" up, Goose creates a goose_db_version table to track which migrations have already run, ensuring we don't apply the same change twice.

SQL Queries

With the table created, we can write our queries in sql/queries/users.sql.

Note the RETURNING * clause. In older SQL, an INSERT was "blind"—it didn't return data. You would have to insert the row and then immediately run a SELECT to get the data back (a two-round-trip process).

Modern PostgreSQL allows INSERT, UPDATE, and DELETE statements to behave like SELECTs. By using RETURNING *, the database inserts the row and returns the full object in a single, atomic transaction. This is faster and guarantees that the data we get back is exactly what was stored.

Wiring it up in Main

Finally, we need to initialize the database connection in main.go and inject it into our apiConfig struct.

Important: We must import the postgres driver lib/pq using the blank identifier _. This registers the driver with Go's database/sql package without us needing to call its functions directly.

Helper Functions

Before proceeding to the main function, I needed to create some helper functions to keep the code DRY. Writing the same header setting and error handling logic in every handler is a recipe for messy code.

These two functions are the backbone of my API responses. The respondWithJSON() function ensures we always convert the payload to JSON and set the appropriate Content-Type. The respondWithError() wrapper enforces a standard error structure.

Most frontends expect a specific JSON shape to parse data easily:

Frontend developers usually access properties via data.email. This implies two requirements: we return an Object rather than a primitive, and we use camelCase keys.

To understand why respondWithJSON is written this way, I walked through a few failure scenarios.

The Raw String: A Type Error

Technically, json.Marshal can handle a string. It produces a valid JSON string:

However, this usually breaks the frontend. If the client code expects an object—perhaps trying to access a property like data.result—receiving a primitive string causes a runtime error or undefined behavior. We need to send an object.

Structs without Tags: The Case Mismatch

My next attempt was returning a struct without generic tags:

This returns a valid JSON object, but the keys match the Go struct fields exactly:

In Go, struct fields must be capitalized to be exported and visible to other packages. However, frontends expect lowercase keys. If a JavaScript developer tries user.email, they get undefined. They would have to write user.Email, which violates standard JS conventions.

Lowercase Fields: The "Silent" Failure

I thought I would just make the Go struct fields lowercase to match the JSON requirement.

This results in an empty object {}.

The failure happens because json.Marshal comes from the encoding/json package. Since email and password start with lowercase letters, they are private to my package. The external JSON package cannot "see" them via reflection, so it simply ignores them.

The Solution: Struct Tags

The only way to satisfy both Go's export rules and JSON's naming conventions is using struct tags:

Now, Go sees the exported Email field, but the JSON encoder uses the tag value email for the output.

The "Implicit 200" Trap

There is one more subtle bug to watch out for. If you forget to set the header or status code explicitly, Go makes assumptions.

Go fails safe here, but in a dangerous way. If you write data without setting a status code, Go implicitly assumes 200 OK and tries to guess the content type.

If you are trying to send an error—such as { "error": "Unauthorized" }—but forget the status code, the frontend receives a 200 OK. A client checking response.ok will think the request succeeded, even though the body contains an error message. Explicitly setting w.WriteHeader(code) is mandatory for correct error handling.

Hash Functions

Create a file internal/auth/auth.go and create the hash function :

The first function is to hash the password, the second function is to check if the password is correct. Argon2Id is the latest industry standard, it is even better than Bcrypt.

The User Handler: Parsing and Creating

Now that sqlc has generated our type-safe database functions, we can wire them up in main.go.

Parsing JSON

Since we are building an HTTP server, we deal with streaming data. To read the request body, we use a json.NewDecoderwhich is to attach a JSON processing machine to the request body. Unlike json.Unmarshal (which requires loading the whole JSON into memory first), the decoder processes the stream directly from the request body, which is more efficient for an HTTP server.

We define a struct to match the expected JSON structure. (Note: You can define this struct inside the handler function to keep the global namespace clean).

In the line decode.Decode(&params):

  • Scanning: It reads the JSON key - "email" from the stream.
  • Matching: It looks at the parameter struct, search for the field that has struct tag json:"email"
  • Type Checking : It checks if the value in the JSON matched the type in the parameter struct, check if it is truly a string type
  • Assignment: It reaches into the memory address provided &params, and write the value directly there. Which is why later in the function you can access it through params.email

Context: The "Kill Switch"

When calling our database function, you'll notice we pass r.Context():

Why pass context? Context acts as a kill switch for the request.

Imagine a user sends a complex search query that takes the database 30 seconds to process. If the user gets impatient and closes their browser tab after 2 seconds, the request is cancelled.

  1. The server receives the cancellation signal.
  2. The r.Context() becomes "Done".
  3. The database driver sees this and immediately stops the query, freeing up resources.

Without context, your database would keep crunching numbers for a ghost user.

The Importance of DTOs (Data Transfer Objects)

After creating the user, we need to return the data. It is tempting to simply return the struct generated by sqlc:

However, we should never return this directly. We need to create a custom "Response Struct" (or DTO) for three reasons:

1. Security: The Leaky Abstraction The internal model contains implementation details like HashedPassword. Even if it's hashed, you never want to expose security internals to the frontend.

2. Aggregated Fields Internal database normalization rules don't always match what the UI needs.

  • Database: Stores first_name and last_name columns.
  • API: Returns a computed full_name field.

By using a DTO, we can format the data for the user without cluttering the database with redundant columns.

3. API Stability: Compile vs. Runtime Errors This is the most subtle but powerful reason. Imagine we rename the database column content to message.

  • Without DTO: sqlc updates the JSON tag automatically. The API response changes from {"content": "..."} to {"message": "..."}. The frontend code (which expects content) breaks silently in production—a dangerous Runtime Error.
  • With DTO: You have to manually map dbUser.Message to dtoUser.Content. When you rename the DB column, your mapping code breaks immediately because the field Message doesn't exist on the struct yet. The compiler forces you to fix it—a strict Compile-time Error.

We would always rather fix a bug at compile time than have it crash in production.

Create User

After understanding all these different concept, this handleUser() would be relatively simple to understand.

Authentication with JWTs

Now that we can create users, we need a way to log them in.

In the past, servers used sessions stored in the database to track logged-in users. However, for this project, we are building a stateless API. This means the server will not remember who is logged in between requests. Instead, the client must present a digital "badge" with every request to prove their identity.

This badge is a JSON Web Token or JWT.

We need to update the apiConfig struct by creating a jwtSecret field, so that it can hold the JWTs secret key.

The Auth Deep Dive

Implementing robust authentication involves handling cryptography, expiration times, and refresh tokens. It is a complex topic that deserves its own space to explore properly.

I have written a complete deep dive on the architecture of this system here: Go Authorization and Authentication.

I highly recommend reading that post to understand the security reasoning behind Refresh Tokens and the "Why" of our architecture. For this server build, we will focus strictly on wiring up the handleLogin endpoint using the auth package we designed in that post.

Wiring the Login Handler

The login handler is responsible for three specific tasks:

  1. Verification: Check if the provided password matches the stored hash.
  2. Generation: Create the Access and Refresh tokens.
  3. Response: Return the tokens along with the non-sensitive user profile.

Before writing the Go implementation, we must prepare the database. We need a table to store the long-lived refresh tokens.

We also need to define the queries for sqlc to generate our type-safe Go interface. We need three specific operations: creating a token, retrieving a user by their token, and revoking a token.

After saving these files, run sqlc generate in your terminal. This will update the internal/database package with the new struct definitions and methods.

Now we can implement the handleLogin endpoint.

You will notice a small block of logic handling ExpiresInSeconds. This allows clients to request a very short token duration (capped at 1 hour). This is primarily useful for automated testing, allowing us to verify token expiration without waiting a full hour.

By embedding the User struct inside our response, we automatically flatten the JSON. The client receives a clean object containing the user details alongside the two tokens needed for future requests.

Closing the loop: Authorization vs. Authentication

We have spent a lot of time on Authentication — verifying the user's identity via JWTs. But identity is only half the battle. We also need Authorization — verifying that this user has permission to perform a specific action.

The most common mistake in backend development is assuming that a valid JWT means "Admin Access".

The Ownership Check

We need a resource to manage. While most social media platforms have "posts," in this project we call them "Chirps." We start by defining the schema for this new item.

Just like with users and tokens, we write queries to handle these items so sqlc can generate our type-safe Go functions.

I will not detail every CRUD function here as the patterns for creating and reading are similar to users. However, the deleteChirp endpoint warrants a deep dive because it introduces Authorization.

It is not enough to simply check if the user is logged in. We must ensure they own the specific chirp they are trying to delete.

We rely on the GetBearerToken helper to extract the JWT from the request headers. For a full breakdown of how we parse and validate these tokens, refer to my deep dive on Go Authorization and Authentication.

Notice the distinction between 401 Unauthorized and 403 Forbidden:

  • 401: "I don't know who you are". The token is missing or invalid.
  • 403: "I know who you are, but you can't do this". The token is valid, but the user does not own the resource.

This pattern — Authenticate -> Fetch Resources -> Check Ownership -> Execute formed the backbone of secure RESTful design.

Wiring it All Together

Finally, with all our endpoints created, we must register them to our server mux so the application knows how to route traffic. We map each HTTP verb and path to the corresponding handler function in main().

Conclusion

We started this journey with a simple TCP listener and evolve it into a robust backend server. We tackled database migrations with Postgres, type-safe SQL with sqlc, and a stateless authentication system using JWTs.

While there are many features left to add — webhooks, image processing, or stricter validation — the foundation is solid. The pattern we established here — configuration struct, dependency injection, and DTOs will support the application as it scales from a single main.go file into a complex microservice.