Serving Static Files

Serve a directory of CSS, images and fonts with http.FileServer and StripPrefix, without exposing files you did not mean to.

Syntaxhttp.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))

A browser asks for more than HTML. It asks for stylesheets, images, fonts and JavaScript. Those are ordinary files sitting in a directory. Writing a handler for each one would be tedious, so net/http ships one handler that serves a whole directory.

http.FileServer takes a file system and returns a handler. http.Dir("public") is the simplest file system: a directory name, used as the root. A request for /style.css is answered with the bytes of public/style.css. The handler sets the Content-Type header from the file extension, and it answers Range requests, which is how a browser asks for only part of a large file.

Mounting under a prefix

Assets usually live under a URL prefix such as /static/. The file server knows nothing about that prefix, so on its own it would look for public/static/style.css. http.StripPrefix fixes that. It returns a handler that cuts the prefix off the request path, then calls the handler you gave it. If the path does not start with the prefix, it replies 404.

Two spellings of the same thing

http.FileServerFS(fsys) is exactly http.FileServer(http.FS(fsys)). The short form arrived in Go 1.22. http.FS converts an fs.FS, the standard read-only file system interface, into the http.FileSystem value the older function wants.

It serves everything in there

http.Dir will serve a file whose name starts with a dot, so a public/.env is downloadable. Keep assets and nothing else in that directory, or wrap the file system and refuse the names you do not want. From Go 1.24, os.OpenRoot("public") with root.FS() also stops a symlink inside the directory from reaching files outside it.

Example

Example · go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing/fstest"
)

func main() {
	// A stand-in for a real directory, so this program needs no files.
	// In a server this would be http.Dir("public") or os.DirFS("public").
	assets := fstest.MapFS{"style.css": {Data: []byte("body{margin:0}")}}

	mux := http.NewServeMux()
	// StripPrefix removes "/static/" so the file server looks up "style.css".
	// http.FileServerFS(assets) is the same as http.FileServer(http.FS(assets)).
	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(assets)))

	for _, target := range []string{"/static/style.css", "/static/missing.css"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
		fmt.Printf("%s -> %d %q\n", target, rec.Code, rec.Body.String())
	}
}

// Output:
// /static/style.css -> 200 "body{margin:0}"
// /static/missing.css -> 404 "404 page not found\n"

When to use it

  • Serve the compiled CSS and JavaScript for a server-rendered site under /static/.
  • Let users download files the program wrote to a directory, such as generated invoices.
  • Answer /robots.txt and /favicon.ico without writing a handler for each file.
  • Serve a built frontend's assets folder from the same binary that serves the API.

More examples

A real directory on a running server

Run this in a directory that contains public/app.css and public/robots.txt, then check it with curl -i localhost:8080/static/app.css and curl -i localhost:8080/robots.txt. Two things to know about ServeFile: it refuses any request whose path contains "..", and it redirects a request ending in /index.html to the same path without that last part. Patterns that start with a method, such as "GET /robots.txt", need Go 1.22 or later.

Example · go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	// public/app.css is served as http://localhost:8080/static/app.css
	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))

	// One file, one URL. ServeFile names the file, so no prefix work is needed.
	mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "public/robots.txt")
	})

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

Hiding dotfiles with a wrapper

The file server turns any error wrapping fs.ErrNotExist into a plain 404, so a hidden file looks exactly like a missing one and leaks nothing. Reach for this when the directory you serve is also a working directory, such as a repository checkout or a folder users upload into.

Example · go
package main

import (
	"io/fs"
	"net/http"
	"os"
	"strings"
)

// hiddenFS wraps a file system and refuses any path that has an element
// starting with a dot, so .env and .git/config are never served.
type hiddenFS struct{ inner fs.FS }

func (h hiddenFS) Open(name string) (fs.File, error) {
	for _, part := range strings.Split(name, "/") {
		// "." on its own is the root of the file system, which is fine.
		if part != "." && strings.HasPrefix(part, ".") {
			return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
		}
	}
	return h.inner.Open(name)
}

// The wrapper satisfies fs.FS, so the file server accepts it unchanged.
var assetHandler = http.FileServerFS(hiddenFS{os.DirFS("public")})

Confining the file server with os.OpenRoot

Use this when the served directory holds content you did not create: user uploads, an extracted archive, a mounted volume. Root.FS() returns an fs.FS whose lookups cannot escape the directory through a symlink, which is the one hole os.DirFS leaves open.

Example · go
package main

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

func main() {
	// Go 1.24 and later. Every lookup stays inside ./public, even if
	// something in there is a symlink pointing at /etc or at the home
	// directory. os.DirFS alone does not promise that.
	root, err := os.OpenRoot("public")
	if err != nil {
		log.Fatal(err)
	}
	defer root.Close()

	// http.Handle is shorthand for http.DefaultServeMux.Handle, and the nil
	// handler below means "use that same default mux".
	http.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(root.FS())))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Discussion

  • Be the first to comment on this lesson.