Golang Web Server Tutorial: Build and Configure an HTTP Server in Go

Learn how to build a Go web server using net/http, configure routes, handlers, ports, timeouts, static files, JSON responses, and graceful shutdown.

Published

Updated

Read time 13 min read

Reviewed byDeepak Prasad

Golang Web Server Tutorial: Build and Configure an HTTP Server in Go

This tutorial is for developers who want a working Go HTTP server with clear next steps: handlers, routing with net/http, practical http.Server settings, JSON and static responses, and shutdown without turning into a full deployment guide. If you are comparing stacks, see Go web frameworks after you are comfortable with the standard library.

Go 1.24 on Linux (examples use Go 1.22+ ServeMux patterns).


Quick answer: how do you run a Go web server?

Create a *http.ServeMux, register handlers on it, pass that mux to http.ListenAndServe (or to an http.Server as Handler), and block on the listen call. The mux picks the handler; the handler reads *http.Request and writes to http.ResponseWriter.

Minimal shape (run with go run .—it listens until you stop it):

go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("Hello"))
	})
	log.Fatal(http.ListenAndServe(":8080", mux))
}

The next section spells out vocabulary; after that you get a fuller walkthrough, tests with httptest, and server configuration.


Terms: web server, HTTP server, and net/http

Web server versus HTTP server

People often say “web server” for anything that speaks HTTP—HTML pages, assets, JSON APIs, or internal tools. In Go you usually implement that as an HTTP server: a listener, the net/http stack, and your handlers (functions or types with ServeHTTP) that contain your application logic.

Why net/http is enough for many programs

For JSON APIs, internal tools, and many public sites, the standard library already gives you routing (since Go 1.22, method-aware patterns and path variables), timeouts, TLS, HTTP/2 where applicable, and reverse-proxy-friendly behavior. You can add a framework later; starting with net/http keeps dependencies small and examples portable.


Create a simple Go web server

Project setup

Install Go if needed (getting started with Go), then create a module (custom Go modules covers naming in more depth):

text
mkdir my-webserver
cd my-webserver
go mod init example.com/my-webserver

Add main.go as shown below.

A minimal main.go with ListenAndServe

This is the shape you run locally with go run . (it listens until you stop the process):

go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		_, _ = w.Write([]byte("Hello from Go"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

The pattern GET /{$} matches only GET at the root path (the {$} suffix means “end of path” in Go 1.22+ routing).

Run and probe

With the server running, open http://localhost:8080/ or use curl:

text
curl -i http://127.0.0.1:8080/

You should see HTTP/1.1 200 OK and the plain-text body.

Runnable check with httptest

The same handler logic can be exercised without binding a real port (this version is suitable for the in-page Run control):

go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		_, _ = w.Write([]byte("Hello from Go"))
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Println(resp.Status, string(body))
}
Output

After Run, expect a line starting with 200 and the hello text.


Understanding handlers in Go

Handlers sit at the center of net/http: every request is delivered to some http.Handler, which reads the *http.Request and writes through http.ResponseWriter. Below is the usual syntax for each piece, then one small program that wires them together.

http.ResponseWriter

ResponseWriter sends the response status, headers, and body. Set headers before the first Write or WriteHeader; after the body starts, header changes may be ignored or trigger a panic depending on the concrete type.

Common calls:

go
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusCreated) // optional; omit for 200
fmt.Fprint(w, "body")
// or: _, err := w.Write([]byte("body"))

*http.Request

r carries the method, URL, protocol version, headers, and optional body. Use r.Context() for deadlines and client disconnects when you call other APIs that accept a context.Context.

Fields and accessors you reach for often:

go
r.Method
r.URL.Path
r.URL.RawQuery
r.URL.Query().Get("q")
r.Header.Get("Accept")
r.RemoteAddr
ctx := r.Context()
defer r.Body.Close()
// body, err := io.ReadAll(r.Body)

http.Handler and http.HandlerFunc

An http.Handler is any value with ServeHTTP(http.ResponseWriter, *http.Request). http.HandlerFunc is a function type with the same signature, so plain functions can satisfy http.Handler without a wrapper struct:

go
type Counter struct{ n int }

func (c *Counter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "%d", c.n)
}

var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "ok")
})

http.HandleFunc versus http.Handle

  • http.HandleFunc("/path", fn) registers fn on DefaultServeMux (the global mux used when you pass nil to ListenAndServe).
  • mux.HandleFunc("GET /path", fn) registers on your mux.
  • mux.Handle("GET /path", handler) registers an existing http.Handler (struct, http.FileServer, http.HandlerFunc(...), etc.).
go
http.HandleFunc("/legacy", func(w http.ResponseWriter, r *http.Request) {})

mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServer(http.Dir("static")))
mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) {})

This tutorial keeps a local mux := http.NewServeMux() so routes stay explicit and easy to test.

Example: Handler, HandlerFunc, headers, and query strings

The server uses mux.Handle with a small struct type, mux.HandleFunc for a second route, and a http.HandlerFunc value on a third route. The client issues two requests so you can see status and body output when you click Run:

go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

type greet struct{}

func (greet) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name")
	if name == "" {
		name = "world"
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintf(w, "hello %s (%s)", name, r.Method)
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /greet", greet{})

	mux.HandleFunc("GET /echo", func(w http.ResponseWriter, r *http.Request) {
		msg := r.Header.Get("X-Msg")
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, msg)
	})

	var ping http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}
	mux.Handle("GET /ping", ping)

	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp1, _ := http.Get(ts.URL + "/greet?name=Ada")
	b1, _ := io.ReadAll(resp1.Body)
	_ = resp1.Body.Close()
	fmt.Println("greet:", resp1.Status, string(b1))

	req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/echo", nil)
	req2.Header.Set("X-Msg", "hi")
	resp2, _ := http.DefaultClient.Do(req2)
	b2, _ := io.ReadAll(resp2.Body)
	_ = resp2.Body.Close()
	fmt.Println("echo:", resp2.Status, string(b2))

	resp3, _ := http.Get(ts.URL + "/ping")
	_ = resp3.Body.Close()
	fmt.Println("ping:", resp3.Status)
}
Output

After Run you should see greet: with 200 OK and hello Ada (GET), echo: with 200 OK and hi, and ping: with 204 No Content (no response body).


Configure routes with ServeMux

Why a custom mux helps

A dedicated *http.ServeMux makes tests easy (pass the mux to httptest.NewServer), avoids hidden globals, and scales to multiple Handle registrations without import side effects.

Register multiple routes

go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
	})
	mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "hello")
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	for _, path := range []string{"/api/health", "/hello"} {
		resp, _ := http.Get(ts.URL + path)
		b, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()
		fmt.Println(path, string(b))
	}
}
Output

Each path prints its short body on its own line.

Go 1.22 method-based routes and Request.PathValue

Method-specific patterns look like "GET /users/{id}". Inside the handler, r.PathValue("id") returns the captured segment.

go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "id=%s", r.PathValue("id"))
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp, _ := http.Get(ts.URL + "/users/42")
	b, _ := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	fmt.Print(string(b))
}
Output

After Run, the program prints id=42.


Configure the Go HTTP server

After a handler works, prefer an http.Server so timeouts and shutdown live in one struct.

Address and port

Addr is a host:port string. :8080 listens on all interfaces on port 8080; 127.0.0.1:8080 restricts to loopback for local development.

ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout

  • ReadHeaderTimeout caps how long the server waits for request headers (mitigates slowloris-style header stalls).
  • ReadTimeout bounds reading the entire request (including body) from the connection.
  • WriteTimeout bounds writing the response.
  • IdleTimeout controls keep-alive idle time on the server side.

Tune values to your handlers: long uploads need a higher read budget; streaming responses need careful write limits.

Start the configured server

go
package main

import (
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	log.Fatal(srv.ListenAndServe())
}

This is the production-minded baseline the brief targets: explicit mux, explicit server, and timeouts set on purpose instead of left at zero forever.


Return different response types

The only real differences are the Content-Type header, how you build the body (bytes, fmt, html/template, or encoding/json), and when you call WriteHeader (optional for 200; set it before the body when you need another status). User-supplied strings in HTML belong in html/template so they are escaped; JSON fits json.Encoder or json.Marshal. For decoding request JSON, see JSON unmarshaling in Go.

This server exposes four GET routes in one httptest run: plain text, a tiny HTML page, JSON, and a non-200 response with an extra header:

go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

type msg struct {
	Status string `json:"status"`
}

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /plain", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		fmt.Fprint(w, "just text")
	})

	mux.HandleFunc("GET /page", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, "<!doctype html><title>demo</title><p>hello</p>")
	})

	mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_ = json.NewEncoder(w).Encode(msg{Status: "ok"})
	})

	mux.HandleFunc("GET /busy", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Header().Set("Retry-After", "5")
		w.WriteHeader(http.StatusServiceUnavailable)
		fmt.Fprint(w, "try later")
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	for _, path := range []string{"/plain", "/page", "/api/ping", "/busy"} {
		resp, _ := http.Get(ts.URL + path)
		b, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()
		fmt.Println(path, resp.Status, string(b))
	}
}
Output

After Run you should see four lines: plain text for /plain, HTML for /page, JSON for /api/ping (the encoder may add a trailing newline inside the body), and 503 Service Unavailable with try later for /busy (that response also includes a Retry-After header).


Serve static files

Create a directory such as static/ next to main.go, place assets inside, then mount a file server. This example uses StripPrefix so URLs look like /static/...:

go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	fs := http.FileServer(http.Dir("static"))
	mux.Handle("/static/", http.StripPrefix("/static/", fs))

	log.Fatal(http.ListenAndServe(":8080", mux))
}

A request for GET /static/app.css maps to the file static/app.css on disk.


Read request data

Query strings live on r.URL (Query().Get for a single value, or Query()["key"] when a key repeats). HTML forms posted as application/x-www-form-urlencoded use r.ParseForm() (or ParseMultipartForm for uploads), then read r.PostForm / r.Form. JSON APIs read the raw body with json.NewDecoder(r.Body).Decode. For large or untrusted bodies, cap reads with http.MaxBytesReader or io.LimitReader so memory stays bounded. For client-side posting patterns, see POST JSON requests in Go.

One test server and three requests show all three paths:

go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

type createUser struct {
	Name string `json:"name"`
}

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
		name := r.URL.Query().Get("name")
		if name == "" {
			name = "world"
		}
		fmt.Fprintf(w, "query name=%s", name)
	})

	mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
		if err := r.ParseForm(); err != nil {
			http.Error(w, "bad form", http.StatusBadRequest)
			return
		}
		user := r.PostForm.Get("user")
		fmt.Fprintf(w, "form user=%s", user)
	})

	mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
		defer r.Body.Close()
		var u createUser
		if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		fmt.Fprintf(w, "json hello %s", u.Name)
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp1, _ := http.Get(ts.URL + "/hello?name=Ada")
	b1, _ := io.ReadAll(resp1.Body)
	_ = resp1.Body.Close()
	fmt.Println("hello:", string(b1))

	form := strings.NewReader("user=bob&pass=secret")
	resp2, _ := http.Post(ts.URL+"/login", "application/x-www-form-urlencoded", form)
	b2, _ := io.ReadAll(resp2.Body)
	_ = resp2.Body.Close()
	fmt.Println("login:", string(b2))

	resp3, _ := http.Post(ts.URL+"/users", "application/json", strings.NewReader(`{"name":"Cleo"}`))
	b3, _ := io.ReadAll(resp3.Body)
	_ = resp3.Body.Close()
	fmt.Println("users:", string(b3))
}
Output

After Run you should see three lines: hello: query name=Ada, login: form user=bob, and users: json hello Cleo.


Add simple middleware

Middleware is just a function that wraps an http.Handler (or HandlerFunc) and runs code before or after the inner handler.

Logging and panic recovery

go
package main

import (
	"log"
	"net/http"
	"time"
)

func withLogging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

func withRecover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Println("panic:", err)
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           withRecover(withLogging(mux)),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

Attach the chain to http.Server.Handler (as above) or wrap a single sub-handler—either way the outer wrapper runs first on the way in and last on the way out.


Graceful shutdown

Listen in a goroutine, wait for SIGINT or SIGTERM, then call Shutdown with a deadline so active requests finish:

go
package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("shutdown: %v", err)
	}
}

Shutdown stops accepting new connections and waits for handlers tied to r.Context() to unwind; choose the timeout to match your longest legitimate request.


HTTPS and TLS

For local experiments, point ListenAndServeTLS (or srv.ListenAndServeTLS) at a certificate and key file:

go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("hello tls"))
	})

	log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", mux))
}

In production, many teams terminate TLS at a load balancer or reverse proxy, or use managed certificates, instead of binding raw cert files in the app. Keeping that as an infrastructure choice avoids duplicating long proxy tutorials here.


Best practices for Go web servers

Use a custom ServeMux (or another router you can inject) instead of scattering http.Handle calls on the default mux. Set timeouts on http.Server, keep handlers small (parse, validate, delegate), avoid unsynchronized shared maps for per-request data, and carry cancellation with r.Context(). Log ListenAndServe and Shutdown errors distinctly so operators can tell a bind failure from a clean shutdown.


Mistakes to avoid

Registering everything on DefaultServeMux via nil makes tests and reuse harder. Skipping server timeouts invites hung connections. Ignoring errors from ListenAndServe hides bind failures. Writing the body before WriteHeader can lock the status to 200 unexpectedly. Reading unlimited bodies can exhaust memory—wrap with http.MaxBytesReader. Shipping debug routes on public listeners is a common leak; gate them behind build tags or separate binaries.


Go web server cheat sheet

Task Go API
Start a simple server http.ListenAndServe(addr, handler)
Create a router http.NewServeMux()
Register a function mux.HandleFunc(pattern, fn)
Configure timeouts and addr http.Server{...}
Serve files http.FileServer(http.Dir("static"))
Emit JSON json.NewEncoder(w).Encode(v)
Set a header w.Header().Set(name, value)
Set status w.WriteHeader(code)
Graceful stop srv.Shutdown(ctx)
TLS listen srv.ListenAndServeTLS(certFile, keyFile)

Summary

You can build a capable Go HTTP server with net/http: define handlers, register patterns on a ServeMux (including Go 1.22 method routes and PathValue), wrap the mux in an http.Server with read, write, header, and idle timeouts, return JSON or static files, and stop cleanly with Shutdown. That combination hits beginner examples and intermediate “how do I configure this?” intent without leaving the standard library.


References


Frequently Asked Questions

1. What package do you use for a web server in Go?

The standard library net/http package is enough for many services: it gives you HTTP servers, routing via ServeMux, client calls, and helpers for headers, cookies, and TLS.

2. What is the difference between http.HandleFunc and mux.HandleFunc?

http.HandleFunc registers on DefaultServeMux, the implicit router used when you pass nil to ListenAndServe. mux.HandleFunc registers on your own *http.ServeMux so routes stay explicit and testable.

3. When should you use http.Server instead of http.ListenAndServe?

Prefer an http.Server value when you want timeouts, a custom Addr, TLS, or graceful shutdown. ListenAndServe is a thin wrapper around a default Server with limited knobs.

4. What Go version added method and path patterns on ServeMux?

Go 1.22 extended http.ServeMux so patterns can include an HTTP method prefix and {name} path segments, with Request.PathValue reading those names.

5. How do you shut down a Go HTTP server gracefully?

Run the server in a goroutine, listen for SIGINT or SIGTERM, then call server.Shutdown with a context deadline so in-flight requests finish and new ones are rejected.

6. How do you return JSON from a Go HTTP handler?

Set Content-Type to application/json, pick an HTTP status if it is not 200, then use json.NewEncoder(w).Encode on a Go value; check the encoder error and log if Encode fails after headers may have been sent.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …