Go
Updated
11 min read

Building a minimalist web server using the Go standard library + Tailwind CSS

How to build a website with dynamic HTML and a modern UI using Tailwind CSS using only the Go standard library. Embrace the minimalist web server!

Building a minimalist web server using the Go standard library + Tailwind CSS

Dependencies pose a significant maintenance burden on software projects. Every package introduces risks by adding code outside your control, making them a common attack vector. Failing to stay up to date can force stressful upgrades when security patches are released. This is especially challenging in ecosystems like JavaScript, where breaking changes and dependency churn are common.

At Arcjet, we’re on a path to zero dependencies for our developer security SDK. Our goal for the 1.0.0 release (coming soon!) is that when you install Arcjet, you only have to trust us because the only code you bring into your project is our SDK.

I’ve also been thinking about how we can achieve this with our server-side Go code. Our API is built for low-latency high-throughput security decisions, so performance is crucial. While the Go ecosystem experiences less dependency churn than JavaScript, where keeping up to date has become a serious chore, minimizing dependencies remains a goal across our entire codebase.

Luckily, Go has an extensive standard library. Over the holidays I was inspired by several recent blog posts about using the standard library first before reaching for third-party modules. I decided to experiment with building a website using only the Go standard library, plus HTML and Tailwind CSS for styling.

In this post I’ll discuss how I built a minimalist web server using the Go standard library which dynamically generates HTML and CSS using Tailwind CSS. The only external dependency is the Tailwind CLI and optional use of Air for live reload, neither of which are in the production build artifacts.

Dependency churn

How often do you come back to an old project only to find it won’t build because some key dependencies have changed? Or you want to build a new feature only to find out there have been breaking changes to the core dependencies which must be upgraded first? Or a major API you relied on has been deprecated and the migration path is incomplete?

Whether it’s a side project or a major application you work on, I bet every developer has experienced this. It’s frustrating because you then have to spend time on rebuilding, refactoring, and/or migrating to the “new” way of doing things.

Using third party libraries speeds up development because you don’t need to reinvent the wheel. However, they always introduce maintenance overhead and usually come without any guarantee of continued updates or backwards compatibility. Multiply this for dependency you include and you have a real maintenance burden.

Why rely on the Go standard library?

The standard library for whichever programming language you’re using also might not have any such guarantees, but mature languages know that developers rely on them. There is an implied contract that things should rarely break.

But in Go, there is an explicit contract:

In Go 1 and the Future of Go Programs (from 2012) the Go team state

Go 1 defines two things: first, the specification of the language; and second, the specification of a set of core APIs, the "standard packages" of the Go library. The Go 1 release includes their implementation in the form of two compiler suites (gc and gccgo), and the core libraries themselves.

And in 2023 this was followed up by Backward Compatibility, Go 1.21, and Go 2

when should we expect the Go 2 specification that breaks old Go 1 programs?

The answer is never. Go 2, in the sense of breaking with the past and no longer compiling old programs, is never going to happen. Go 2 in the sense of being the major revision of Go 1 we started toward in 2017 has already happened.

There will not be a Go 2 that breaks Go 1 programs. Instead, we are going to double down on compatibility, which is far more valuable than any possible break with the past. In fact, we believe that prioritizing compatibility was the most important design decision we made for Go 1.

By relying solely on the Go standard library, we can effectively guarantee long-term compatibility and minimal breakage.

Does Go have everything we need?

Yes! Go 1.22 introduced some improvements to the built in web server to make defining routes a lot easier, negating many of the ergonomic benefits of frameworks like Gin (and others to choose from).

And if we combine this with the existing support for serving embedded static files, compiling dynamic HTML templates, structured logging, SQL drivers for common databases, runtime traces, and the ability to execute commands before the Go build step, we can easily build a single binary with no external dependencies ready to ship to production.

Of course you can swap things out later - using an ORM like Gorm or exporting telemetry to Otel, for example - but Go has everything we need to get started in the standard library.

Start with the web server

Developers familiar with frameworks like Next.js or Remix are accustomed to automatic static asset management and filesystem-based route definitions. With our minimalist Go server this is more manual, but can be implemented in a way that feels idiomatic with the routing enhancements in Go 1.22.

We’ll follow the commonly used Go Project Layout by defining the routes and server in main.go which will load the route handlers from an internal/handlers package and keep the web content and templates in a web/templates directory.

For a basic website we want a static directory of assets like CSS and images (at web/static), plus a favicon and robots.txt hosted at the root. These are embedded in the Go binary. We also include a simple health check to indicate the server is running when it’s deployed.

The server will be containerized and shipped to a modern hosting platform like Railway, Fly.io, Render, or one of the larger cloud providers. It’s standard to route requests through a proxy or load balancer which can handle SSL, so that’s another dependency avoided.

However, if you wanted to just host this on a single VM then you could use something like Certmagic to automatically generate a Let’s Encrypt certificate for you. It goes against our zero dependency philosophy, but dealing with issuing SSL certificates might not be something you want to write from scratch! This becomes more challenging when you have to sync certificates across multiple servers, which is why it's often delegated to the proxy frontend.

Finally, we also set up structured logging using log/slog and use an environment variable to configure plain text (default, for development) and JSON (for production).

package main

import (
	"embed"
	"fmt"
	"io/fs"
	"log/slog"
	"net/http"
	"os"
	"time"
)

//go:embed web/static/*
var static embed.FS

func init() {
	_, jsonLogger := os.LookupEnv("JSON_LOGGER")
	_, debug := os.LookupEnv("DEBUG")

	var programLevel slog.Level
	if debug {
		programLevel = slog.LevelDebug
	}

	if jsonLogger {
		jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
			Level: programLevel,
		})
		slog.SetDefault(slog.New(jsonHandler))
	} else {
		textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
			Level: programLevel,
		})
		slog.SetDefault(slog.New(textHandler))
	}

	slog.Info("Logger initialized", slog.Bool("debug", debug))
}

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	addr := ":" + port

	mux := http.NewServeMux()

	// Use an embedded filesystem rooted at "web/static"
	fs, err := fs.Sub(static, "web/static")
	if err != nil {
		slog.Error("Failed to create sub filesystem", "error", err)
		return
	}

	// Serve files from the embedded /web/static directory at /static
	fileServer := http.FileServer(http.FS(fs))
	mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))

	mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
		data, err := static.ReadFile("web/static/img/favicon.ico")
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "text/plain")
		w.Write(data)
	})
	mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
		data, err := static.ReadFile("web/static/robots.txt")
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "text/plain")
		w.Write(data)
	})

	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		w.Write([]byte(`OK`))
	})

	server := &http.Server{
		Addr:    fmt.Sprintf(":%s", port),
		Handler: mux,
		// Recommended timeouts from
		// https://blog.cloudflare.com/exposing-go-on-the-internet/
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	slog.Info("Server listening", "addr", addr)

	if err := server.ListenAndServe(); err != nil {
		slog.Error("Server failed to start", "error", err)
	}
}

main.go web server.

Assuming web/static/robots.txt exists then when you go run main.go and curl http://localhost:8080/robots.txt you’ll get the contents of that file served. Same for robots.txt and the health check.

Middleware

Running code on every request is useful for logging, error handling, authentication, etc. Go web frameworks like Gin offer a rich middleware ecosystem, which is an advantage. Figuring out CORS is a pain! However, writing custom middleware for Go net/http is straightforward.

To make this web server more robust, we’ll include a simple panic handler so that if any of the routes panic, we don’t crash the server.

Create a new package in internal/middleware with middleware.go defining the structure:

package middleware

import (
	"net/http"
)

// Middleware is a function that wraps an http.Handler with custom logic.
type Middleware func(http.Handler) http.Handler

// Chain is a helper to build up a pipeline of middlewares, then apply them to a
// final handler.
type Chain struct {
	middlewares []Middleware
}

// Use appends a middleware to the chain.
func (c *Chain) Use(m Middleware) {
	c.middlewares = append(c.middlewares, m)
}

// Then applies the entire chain of middlewares to the final handler in reverse
// order.
func (c *Chain) Then(h http.Handler) http.Handler {
	for i := len(c.middlewares) - 1; i >= 0; i-- {
		h = c.middlewares[i](h)
	}
	return h
}

The middleware definition in internal/middleware/middleware.go

Then internal/middleware/recover.go can be a new HTTP handler:

package middleware

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

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				slog.Error("Recovered from panic", "error", err)
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

The panic recovery middleware in internal/middleware/recover.go


In main.go we can wrap mux with the new middleware and update the http.Server to use the wrapped mux as the Handler

chain := &middleware.Chain{}
chain.Use(middleware.RecoverMiddleware)
wrappedMux := chain.Then(mux)

server := &http.Server{
	Addr:    fmt.Sprintf(":%s", port),
	Handler: wrappedMux,
	// Recommended timeouts from
	// https://blog.cloudflare.com/exposing-go-on-the-internet/
	ReadTimeout:  5 * time.Second,
	WriteTimeout: 10 * time.Second,
	IdleTimeout:  120 * time.Second,
}

Updated main.go with the new middleware.

Generating CSS with Tailwind

Tailwind uses HTML class attributes to automatically generate the CSS needed to create the layout, but that means it needs a build step. It has to parse the HTML then build the CSS, which we want to ship with the Go binary so everything is self contained.

I followed Xe Iaso’s blog How to use Tailwind CSS in your Go programs to get this working. You have to set up a basic npm package in your root so that we can include the Tailwind CLI. The package.json build script line triggers the build where web/static/css/main.css is any custom CSS you want included and web/static/css/styles.css is the output. You can remove the main.css file if you don’t have anything extra to add.

{
  "name": "example.com",
  "version": "1.0.0",
  "scripts": {
    "build": "tailwindcss build -i web/static/css/main.css -o web/static/css/styles.css"
  },
  "dependencies": {
    "tailwindcss": "3.4.17"
  }
}

package.json

The tailwind.config.js file looks like this:

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkmode: "media",
  content: ["./web/templates/*.html"],
  plugins: [],
}

tailwind.config.js

Then in main.go in the root we add a generate command so that we can trigger the npm build script as part of the Go build process.

package main

import (
	"embed"
	"fmt"
	"io/fs"
	"log/slog"
	"net/http"
	"os"
	"time"
)

//go:generate npm run build

//go:embed web/static/*
var static embed.FS

func init() {
	// ...

Update top of the main.go file

Running go generate && go run main.go triggers Tailwind to generate CSS in the web/static directory, which Go then embeds into the build.

Web templates

The final thing to do is set up a simple index page as our first route. Create an HTML file at web/templates/index.html with anything you like. Then at web/templates.go set up this file:

package web

import (
	"embed"
)

//go:embed templates
var Templates embed.FS

web/templates.go

This allows us to use the templates as a Go module so they can be imported elsewhere in our code.

In a new package at internal/handlers/root.go we can define a root handler:

package handlers

import (
	"net/http"

	"html/template"
	"log/slog"

	"github.com/davidmytton/example/web"
)

type PageData struct {
	Title string
}

func RootHandler() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		file, err := web.Templates.ReadFile("templates/index.html")
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			slog.Error("Error reading template", "error", err)
			return
		}

		tmpl := template.Must(template.New("index.html").Parse(string(file)))

		data := PageData{
			Title: "Home",
		}
		if err := tmpl.Execute(w, data); err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			slog.Error("Error executing template", "error", err)
		}
	}
}

internal/handlers/root.go handler

This reads the package template and then parses the data. Our panic recovery middleware will ensure that any template compilation errors are handled gracefully. The template can include variables and other structures, such as the title we pass in.

By using Go's html/template we get injection-safety - the templates themselves are assumed to be safe, but the data injected is not, so Go handles it appropriately.

<!DOCTYPE html>
<html lang="en" class="h-screen">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>{{.Title}}</title>
    <!-- ... -->

web/templates/index.html template file

Finally, in main.go we can set up the handler:

mux.HandleFunc("GET /", handlers.RootHandler())

Add to the main.go route definitions

Live reload with Air

One nice feature of web frameworks like Next.js is the instant reload whenever you make code changes. To implement live reload for our Go web server we can launch it with Air.

After you have installed Air and generated the default config with air init then you can adjust the build configuration with the following:

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go generate && go build -o ./tmp/main ."
  delay = 1000
  exclude_dir = ["node_modules", "assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html", "css"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  post_cmd = []
  pre_cmd = []
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

.air.toml

Launching the dev environment

How many times have you come back to a side project only to forget how to actually run it? Make tends to be installed by default on most systems so if we always write a make dev command then we don’t need to remember anything!

Following the example of go-blueprint, I also set up a Makefile to easily start the web server. Here’s the contents:

.PHONY: dev build

dev:
	@if command -v $(HOME)/go/bin/air > /dev/null; then \
		AIR_CMD="$(HOME)/go/bin/air"; \
	elif command -v air > /dev/null; then \
		AIR_CMD="air"; \
	else \
		read -p "air is not installed. Install it? [Y/n] " choice; \
		if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
			echo "Installing..."; \
			go install github.com/air-verse/air@latest; \
			AIR_CMD="$(HOME)/go/bin/air"; \
		else \
			echo "Exiting..."; \
			exit 1; \
		fi; \
	fi; \
	echo "Starting Air..."; \
	$$AIR_CMD

build:
	@echo "Installing Tailwind..."
	npm ci
	@echo "Generate Tailwind CSS..."
	go generate
	@echo "Building Go server..."
	go build -o tmp/server main.go
	@echo "Build complete."

The project Makefile

Running make dev will launch air watching files for any changes. If you edit a template or any of the Go files, the server will relaunch.

Deployment

We can extend our minimalist philosophy to the production builds as well. I like the Distroless project from Google which provides bare-minimum container images without any operating system and without running as root. Wolfi is an alternative if you need more choice over what's installed, but still want to go with a minimalist approach.

The Dockerfile looks like this:

FROM golang:1.23 AS builder

WORKDIR /app
COPY go.mod ./

ENV CGO_ENABLED=0
RUN go mod download

COPY . .
RUN go build -o server main.go

# Copy the server binary into a distroless container
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /

CMD ["/server"]

USER nonroot

This assumes the CSS has already been generated with go generate after which you can build the container with docker build -t website --load . and run it with docker run -t website

Conclusion

We’ve now built a self-contained website using Tailwind CSS and dynamic HTML templates, ready to deploy as a tiny ~10 MB Docker image. Contrast this to a typical Node.js container, which could easily exceed hundreds of MB. Smaller image sizes isn’t a particularly important goal by itself, but it’s indicative of all the extra bloat you’re shipping.

Unfortunately, we still have to rely on the Tailwind CSS CLI. To illustrate how crazy things have become even with just that single external dependency, take a look inside the node_modules directory and see how many packages it requires! Thankfully, the Tailwind CLI is available as a standalone binary so you could switch to that - it's not as easy to install with npm though.

However, the server itself has zero external dependencies and since the default go.mod contains the toolchain version, even if we come back to this in 10 years it should still build and run without any changes!

Related articles

Subscribe by email

Get the full posts by email every week.