How to Serve Embedded Static Files from an HTTP Server

Web
Serve embedded static files in Go by creating an embed.FS variable and passing it to http.FileServer.

The single-binary dream

You built a Go web app. It works perfectly on your machine. Now you need to ship it. You have a static/ folder containing your HTML, CSS, and JavaScript. Deploying usually means copying the binary and the static/ folder to the server, hoping the paths match. Or you configure a reverse proxy like Nginx to serve the files while Go handles the API. That adds infrastructure complexity. You now manage two processes, two configurations, and two potential points of failure.

Go lets you bake the files directly into the binary. When you run the program, the files are already there. No external dependencies. No missing file errors. No Nginx config. The embed package, added in Go 1.16, turns your static assets into part of the executable. The HTTP server reads from this embedded data instead of the disk.

Concept in plain words

Think of the compiled binary as a suitcase. Usually, a suitcase just holds the program logic. With Go's embed package, you can zip up your static files and sew them inside the suitcase. The embed.FS type acts like a virtual filesystem. It looks like a normal directory tree to the rest of your code, but the data lives inside the executable. The HTTP server reads from this virtual filesystem instead of the disk.

The embed.FS type implements the fs.FS interface. This interface defines a single method: Open(name string). It returns a file handle for the requested path. The http.FileServer handler expects an http.FS interface, which is compatible with fs.FS. You hand the embedded filesystem to the file server, and it serves requests by opening files from the embedded data.

Minimal example

Here's the smallest setup: embed a directory, wrap it, and hand it to the file server.

package main

import (
	"embed"
	"net/http"
)

//go:embed static
var staticFS embed.FS

func main() {
	// http.FileServer expects an http.FS interface.
	// embed.FS implements fs.FS, which satisfies http.FS.
	// The server reads files from the embedded data, not the disk.
	handler := http.FileServer(http.FS(staticFS))
	http.Handle("/", handler)

	// Start the server. Requests for /index.html serve from the binary.
	http.ListenAndServe(":8080", nil)
}

The //go:embed static comment tells the compiler to embed the static directory. The comment must appear immediately before the variable declaration. No blank lines allowed. The compiler scans the directory, reads every file, and embeds the raw bytes into the binary. The staticFS variable becomes a handle to this data.

Walk through what happens

The magic happens in two phases. At compile time, the compiler sees the //go:embed static comment. It validates the pattern, reads the files, and embeds them. If the directory is missing, the compiler rejects the program with //go:embed: pattern static: no matching files found. If the pattern is invalid, you get //go:embed: pattern static: invalid pattern.

At runtime, http.FS(staticFS) creates an adapter. The http.FileServer uses this adapter to answer requests. When a browser asks for /style.css, the server calls Open("style.css") on the embedded filesystem. The filesystem finds the file inside the binary and returns a handle. The server streams the bytes back to the client. The disk is never touched after the binary starts.

The embedded filesystem is immutable. You cannot write to it. Attempts to modify files will fail. This is a feature, not a bug. It ensures your assets cannot be tampered with at runtime.

Realistic example: paths and prefixes

Real apps often need to serve assets at a specific URL path, like /static/. The embedded filesystem keeps the directory structure relative to the embed point. If you embed static/, the root of staticFS is static/. Files are at style.css. If you want to serve at /static/, you need to align the paths.

Here's how to serve embedded files at a sub-path using fs.Sub and http.StripPrefix.

package main

import (
	"embed"
	"io/fs"
	"log"
	"net/http"
)

//go:embed static
var staticFS embed.FS

func main() {
	// fs.Sub creates a sub-filesystem rooted at "static".
	// This ensures the embedded path matches the URL path.
	// Without this, Open("style.css") works, but Open("static/style.css") fails.
	subFS, err := fs.Sub(staticFS, "static")
	if err != nil {
		log.Fatal(err)
	}

	// Strip the /static/ prefix from the URL before serving.
	// Without this, the server looks for /static/static/file.css.
	handler := http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))
	http.Handle("/static/", handler)

	// Start the server. /static/style.css serves the embedded file.
	http.ListenAndServe(":8080", nil)
}

The fs.Sub function returns a filesystem rooted at the given directory. It adjusts the Open method so that paths are relative to the subdirectory. This is crucial for embed.FS because the embed point becomes the root. If you embed static/, the root is static/. fs.Sub lets you treat static/ as the root for the HTTP handler.

Embedding templates

Go's html/template package also supports fs.FS. You can embed templates and parse them at startup. This is common for CLI tools or small web apps that need dynamic content.

package main

import (
	"embed"
	"html/template"
	"log"
	"net/http"
)

//go:embed templates/*.html
var templateFS embed.FS

// templates holds the parsed template set.
// It is populated once at startup to avoid parsing on every request.
var templates *template.Template

func init() {
	// Parse all templates from the embedded filesystem.
	// The pattern "*.html" matches all HTML files in the templates directory.
	templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Execute the index.html template.
		// The template is already parsed and cached in memory.
		if err := templates.ExecuteTemplate(w, "index.html", nil); err != nil {
			http.Error(w, "Template error", http.StatusInternalServerError)
			log.Printf("template error: %v", err)
		}
	})

	http.ListenAndServe(":8080", nil)
}

The template.ParseFS function parses templates from an fs.FS. It returns a *template.Template set. You call ExecuteTemplate to render a specific template. The templates are parsed once at startup. This is faster than reading files from disk on every request.

Pitfalls and errors

Embedding is straightforward, but a few gotchas exist.

If the //go:embed comment is not immediately before the variable, the compiler ignores it. You get undefined: staticFS if you try to use it, or worse, the variable is empty and you get open static: file does not exist at runtime. The comment must be on the line directly above the variable.

// This fails. Blank line breaks the association.
//go:embed static

var staticFS embed.FS

The compiler rejects this with //go:embed: pattern static: no matching files found if the directory is missing. It also rejects invalid patterns with //go:embed: pattern static: invalid pattern.

Embedding directories requires the directory name. //go:embed static embeds the directory. //go:embed static/ also works. Embedding a single file uses the file name. //go:embed index.html embeds the file. You cannot embed a directory and a file with the same name.

Build tags can exclude embedded files. If you use //go:build dev to disable embedding in development, the variable will be empty. You must handle this case.

//go:build !dev

//go:embed static
var staticFS embed.FS

In development, you might use http.Dir("static") instead. This requires conditional logic.

var handler http.Handler

//go:build !dev
func init() {
	subFS, _ := fs.Sub(staticFS, "static")
	handler = http.FileServer(http.FS(subFS))
}

//go:build dev
func init() {
	handler = http.FileServer(http.Dir("static"))
}

The http.FileServer handles path traversal safely. Requests for ../../etc/passwd are rejected. The embedded filesystem is also safe. You cannot escape the embedded root.

Decision: when to use this vs alternatives

Use embed.FS when you want a single binary with no external dependencies. Use embed.FS when deploying to environments where file system access is restricted or unreliable. Use embed.FS for CLI tools that need to serve help pages or templates. Use embed.FS for small web apps where the overhead of a reverse proxy is not justified.

Use http.Dir when files change frequently and you don't want to rebuild the binary. Use http.Dir when serving large assets that would bloat the binary size. Use http.Dir when you need to hot-reload assets without restarting the server.

Use a reverse proxy like Nginx when serving large assets to high traffic. Use Nginx when you need advanced caching, compression, or SSL termination. Use Nginx when the static files are too large to fit in memory or the binary.

Use http.FileServer with embed.FS for small apps, CLIs, or demos. Use http.FileServer with http.Dir for development or when files are external. Use template.ParseFS when you need dynamic templates embedded in the binary.

Embedding is easy. Path prefixes are not. Trust fs.Sub to align the paths.

Where to go next