Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

Building a Go Web App: net/http, Templates, and a Production Shape

Sean

Platform Writer

Sep 22, 2026
8 min read

A Go web app does not need a framework to be production-ready. The standard library’s net/http package, using http.ServeMux for routing since Go 1.22 added method and wildcard patterns, html/template for rendering pages, and http.FileServer for static assets, covers what most applications need. What separates a working local server from a deployable one is a handful of production details: reading PORT from the environment instead of hardcoding it, shutting down gracefully instead of dropping in-flight requests, a health route, and structured logging, none of which need a third-party package either.

Building a Go Web App: net/http, Templates, and a Production Shape

Frameworks like Echo, Gin, and Chi add convenience, mostly around routing and middleware chaining, but the case for needing one specifically has gotten weaker since Go 1.22 let ServeMux match HTTP methods and path parameters directly.

This post builds a small Go web app with net/http alone, then adds the pieces that turn it into something worth deploying: shutdown, health, logging, and a Dockerfile.

Table of contents

net/http is enough

ServeMux since Go 1.22 registers a method and a path pattern together, which used to be the main reason people reached for a router package. The full syntax is documented in the net/http package reference.

mux := http.NewServeMux()
mux.HandleFunc("GET /", homeHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "user %s", id)
}

A wildcard route still needs to be checked against real input, id here is whatever string was in the URL, but the routing itself no longer needs an external package to express method plus path.

A pattern that does not match anything registered falls through to a default 404 handler unless one is set explicitly with mux.HandleFunc for the bare path. Two conflicting patterns registered for the same method and path, a common mistake when a route is copy-pasted and only the handler function renamed, panic at startup with a message naming both patterns rather than silently picking one, which is the safer failure to get.

Templates and static files

html/template renders HTML with automatic contextual escaping, which is the reason to prefer it over text/template for anything producing a web page; it escapes differently inside an attribute, a script tag, and plain text, closing off a class of injection bugs that text/template does not know about.

var tmpl = template.Must(template.ParseFiles("templates/home.html"))

func homeHandler(w http.ResponseWriter, r *http.Request) {
    tmpl.Execute(w, map[string]string{"Title": "Home"})
}

mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

embed.FS is worth using once the binary needs to ship without its templates and static files sitting next to it on disk, embedding both directly into the compiled binary at build time.

template.Must panics immediately if the template file has a syntax error or is missing, which is the right failure mode for a startup-time problem: better to crash on deploy with a clear stack trace pointing at the bad template than to serve a broken page to the first real visitor. ParseGlob is worth using once there is more than one template file, template.Must(template.ParseGlob(“templates/*.html”)), so a new page does not need a code change to be parsed.

The production shape: PORT from the environment

A hardcoded port works on a laptop and breaks on most hosting platforms, which assign a port at runtime and expect the process to read it.

port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}
srv := &http.Server{
    Addr:    ":" + port,
    Handler: mux,
}

Defaulting to 8080 keeps local development working without setting anything, while still respecting whatever the deploy environment assigns.

Reading other configuration the same way keeps the pattern consistent: a DATABASE_URL, a log level, a feature flag, all read once at startup into a small config struct rather than scattered os.Getenv calls through the handler code, which also makes it obvious at a glance what the application actually depends on from its environment. A missing required variable should fail fast rather than fall back to a default that only makes sense locally. A missing DATABASE_URL should stop the process at startup with a clear log line, not surface later as a confusing connection error from deep inside a handler.

Graceful shutdown

Without it, a deploy or a scale-down event kills the process mid-request, cutting off whoever was in the middle of a response. signal.NotifyContext ties the server’s shutdown to the process receiving an interrupt or terminate signal.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

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

<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

Shutdown stops accepting new connections and waits for in-flight ones to finish, up to the timeout, rather than dropping them immediately.

Skip the timeout on Shutdown and a stuck connection, a slow client or a handler waiting on a hung downstream call, blocks the process from exiting at all, which on most hosts means a forced kill once its own grace period runs out anyway. The context.WithTimeout above caps that wait explicitly, so a deploy is bounded by a known number rather than however long the slowest in-flight request happens to take.

A health route and structured logging

A health route gives a host’s own health check something to poll that confirms the process is actually serving requests, not just that it started.

mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
})

log/slog, in the standard library since Go 1.21, writes structured logs as JSON by default, which is what most log aggregation on a hosting platform expects rather than freeform text lines.

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request handled", "method", r.Method, "path", r.URL.Path, "status", status)

A health route that always returns 200 is only checking that the process is listening, not that it can do useful work. A version that also pings the database with a short timeout catches the case where the process is up but its one dependency is not, at the cost of the health check itself becoming another thing that can fail under load if the timeout is set too aggressively.

A Dockerfile

A multi-stage build keeps the shipped image to just the compiled binary, with no Go toolchain or source in the final layer.

FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

CGO_ENABLED=0 produces a fully static binary, which is what lets the final stage be distroless with nothing else installed, no shell, no package manager, a small image and a small attack surface.

go.mod and go.sum are copied and go mod download run before the rest of the source is copied in, specifically so Docker’s layer cache keeps that step cached across builds where only application code changed, not dependencies. Skipping that ordering and copying everything at once means every code change invalidates the dependency download layer too, turning a ten-second rebuild into a much slower one for no reason.

Where the database goes, and why the binary makes this simple

database/sql plus a driver, pgx for Postgres or go-sql-driver/mysql for MySQL, opens a connection pool sized with SetMaxOpenConns to match the host’s resources rather than a driver default meant for a much larger server. The connection string comes from an environment variable, never hardcoded.

The single compiled binary is what makes the rest of this straightforward to deploy: no node_modules, no virtual environment, no interpreter version to match between local and production. Whatever built and ran locally is the same binary that runs on the host, and the Dockerfile above exists mostly to give it a place to sit next to certificates and a working directory.

A pool opened with too high a MaxOpenConns for the host’s plan does not fail at startup. It fails later, under load, when the database itself starts rejecting new connections past its own limit, which shows up as intermittent connection refused errors that are easy to mistake for a database problem rather than a pool sized for the wrong host.

How this fits the rest of the stack

That single binary and a small set of environment variables are most of what a Go web app needs to run in production. RunxBuild builds and runs Go services directly from a repository with a live route, logs, and a managed database beside it, and the RunxBuild hosting calculator totals the service and database cost for the stack above.

Useful related references:

FAQ

Do I need a framework to build a web app in Go?

No, for most applications. Since Go 1.22, the standard library’s ServeMux matches HTTP methods and path patterns directly, which removes the main reason people reached for Echo, Gin, or Chi. A framework earns its place once middleware chaining or a large number of routes make the standard library’s minimal structure genuinely inconvenient, not before.

How do I make a Go web app shut down gracefully?

Use signal.NotifyContext to catch an interrupt or terminate signal, then call the http.Server’s Shutdown method with a timeout once that signal arrives. Shutdown stops accepting new connections and lets in-flight requests finish within the timeout, instead of the process exiting mid-response.

Why should a Go app read PORT from an environment variable?

Most hosting platforms assign a port at runtime and expect the process to bind to it, rather than a fixed port the application chooses itself. Reading the PORT environment variable with a local default, such as 8080, keeps the same binary working unmodified on a laptop and on a host that assigns its own port.

Where should the database connection string live in a Go app?

In an environment variable, read at startup and passed to database/sql’s Open call, never hardcoded or committed to the repository. This also lets the same binary connect to a local database in development and a managed instance in production without a code change, just a different value for the variable.

#go webapp#net/http#golang web development#go dockerfile#graceful shutdown