skip to content

How do you serve a //go:embed asset directory over HTTP without the directory name in every URL?

level: middleimportance: should knowfreq 45%

answer

  1. the directive keeps the path it was given
  2. the directory name becomes part of the URL
  3. re-root the file system, do not rewrite paths
  4. one call in io/fs returns a view
  5. dotfiles need an extra pattern prefix

basics

~20 s

An embed.FS keeps the paths exactly as the directive wrote them, so assets/app.css lives at "assets/app.css" inside it. Call fs.Sub(embedded, "assets") to get an fs.FS rooted one level down, then serve that with http.FileServerFS or http.FS.

solid answer

~40 s

`//go:embed assets` builds an `embed.FS` whose entries are named exactly as they were on disk, so the file is `assets/app.css`, not `app.css`. If you hand that `embed.FS` straight to a file server, every URL has to repeat `/assets/`. `fs.Sub(embedded, "assets")` returns a new `fs.FS` re-rooted at that subdirectory, and serving *that* gives you `/app.css`. From Go 1.22 you can pass it straight to `http.FileServerFS`; before that, wrap it with `http.FS` and pass the result to `http.FileServer`. Combine with `http.StripPrefix` if you mount under a path prefix. Two related details: the embed patterns skip files whose names begin with `.` or `_` unless you write `//go:embed all:assets`, and the embedded `FileInfo` reports a zero `ModTime`, which has consequences for caching headers.

code

go · 12 lines
go
//go:embed all:assets
var embedded embed.FS

func assetHandler() http.Handler {
	// Inside embedded, the file is "assets/app.css".
	// Inside sub, it is "app.css".
	sub, err := fs.Sub(embedded, "assets")
	if err != nil {
		panic(err) // a constant path: this is a build mistake, not a runtime one
	}
	return http.StripPrefix("/static/", http.FileServerFS(sub))
}

go deeper

for a junior

Recall that Go can compile a directory of files into the binary with a //go:embed directive, and that the resulting value can be served over HTTP. Know the variable has to be of type embed.FS and the embed package must be imported.

for a middle

Explain that entries keep the path from the directive, so the source directory name leaks into URLs unless fs.Sub re-roots the file system. Know which function adapts an fs.FS for the older http.FileServer.

for a senior

Bring the operational consequences: read-only assets that cannot drift per host, binary size, zero modification times and what that does to cache validators, and taking an fs.FS parameter so the handler is testable with fstest.MapFS.

for a principal

Weigh single-binary distribution against separate asset delivery. Embedding removes a class of deployment skew but couples every asset change to a rebuild and redeploy, and inflates the artefact you ship everywhere.

## What `//go:embed` actually produces The directive ```go import "embed" //go:embed assets var embedded embed.FS ``` compiles the contents of the `assets` directory into the binary and exposes them through `embedded`, which implements `fs.FS` (and `fs.ReadDirFS`, `fs.ReadFileFS`). The crucial and frequently misunderstood point is **how the entries are named**: they keep the path as written in the directive, relative to the package directory. So the file that lives at `assets/app.css` on disk is opened as `embedded.Open("assets/app.css")`. The `assets` prefix is part of the name, not a root that has been stripped. A few rules of the directive itself: - The variable must be of type `embed.FS`, `[]byte` or `string`, and the `embed` package must be imported (blank import `_ "embed"` suffices for the byte and string forms). - Patterns are relative to the source directory and cannot reach outside the package with `..`. - By default, embedding a directory **skips** files whose names begin with `.` or `_`. Writing `//go:embed all:assets` includes them — which matters the day someone adds a `.well-known` directory or a `_headers` file and it silently does not ship. ## The problem this creates for URLs Hand `embedded` to a file server and it will resolve request paths against the root of the embedded FS — where the only entry is the directory `assets`. So `/app.css` 404s and `/assets/app.css` works. Your URL space is now dictated by the name of a directory in your source tree, and renaming that directory becomes a breaking change for every cached page and hard-coded link. ## `fs.Sub` ```go func Sub(fsys fs.FS, dir string) (fs.FS, error) ``` `fs.Sub` returns an `fs.FS` that is a *view* of `fsys` rooted at `dir`. Opening `"app.css"` on `fs.Sub(embedded, "assets")` opens `"assets/app.css"` on the original. It returns an error only if `dir` is not a valid path; because the directory name is a compile-time constant in your own source, a `panic` or a `log.Fatal` at start-up is a defensible way to handle it — this is a programming error, not a runtime condition. ## Wiring it to a handler Since Go 1.22, `net/http` accepts an `fs.FS` directly: ```go sub, err := fs.Sub(embedded, "assets") // ... mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(sub))) ``` Go 1.22 added `http.FileServerFS`, `http.ServeFileFS` and `http.NewFileTransportFS` as `fs.FS`-taking versions of the existing functions. On older toolchains you go through the adapter added in Go 1.16: ```go mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ``` `http.FS(fsys)` converts an `fs.FS` into the `http.FileSystem` interface that `http.FileServer` requires. Both forms behave identically; the newer one just removes a layer. ## Properties of an embedded file system that differ from disk - **Read-only.** There is no write path, which is usually a feature: assets cannot drift at runtime. - **Zero modification times.** The `fs.FileInfo` for an embedded file reports the zero `time.Time`. Since `http.ServeContent` omits `Last-Modified` and skips the modification-time check when the modtime is zero, embedded assets are served without that validator. Plan your caching strategy knowing this. - **Deterministic contents.** What is in the binary is exactly what was in the tree at build time, which removes a whole class of "the file was missing on that host" deployment failures. That is the reason single-binary distribution is attractive in the first place. - **Binary size.** Everything you embed is resident. A large media tree in the binary costs memory as well as disk. ## Testing the wiring Because `fs.Sub` returns a plain `fs.FS`, the handler can be constructed over an `fstest.MapFS` in tests and over the real `embed.FS` in production, with identical code. That is a good argument for having the constructor take an `fs.FS` parameter rather than reaching for the package-level embedded variable inside the handler.

  • Why might a file present in the directory fail to appear in the embed.FS at all?
    Embedding a directory skips entries whose names begin with `.` or `_` unless the pattern is written `//go:embed all:assets`. A `.well-known` directory or an `_redirects` file therefore ships in development, where it is read off disk, and vanishes from the binary.
  • What does an embedded file report as its modification time, and why does that matter?
    The zero `time.Time`. Because `http.ServeContent` omits `Last-Modified` and skips the modification-time check when the modtime is zero, embedded assets go out with no `Last-Modified` validator, so clients have nothing to revalidate against unless you supply an `ETag` yourself.
  • How would you test a handler built over an embed.FS without depending on the embedded tree?
    Have the constructor take an `fs.FS` parameter rather than reading the package-level embedded variable. Production passes `fs.Sub(embedded, "assets")`; the test passes an `fstest.MapFS` with a couple of files in it. The handler code under test is identical either way.

saying these in an interview costs you the question

  • Expects //go:embed assets to strip the assets prefix
  • Passes an embed.FS where an http.FileSystem is required
  • Thinks fs.Sub copies the files into a new tree
  • Assumes dotfiles are embedded by default
  • Believes embedded files carry their on-disk modification times