The Deploy That Wanted Exactly One File
The status page ships as server plus a static/ directory. The first deploy forgets the directory and the page serves 404s; the second environment has a stale copy of the CSS from last month. The service itself is 9 MB — everything it needs to serve is small and read-only. The question is not whether the files can live in the binary. It is why they were ever outside it.
go:embed copies files into the binary at compile time. The directive precedes a variable declaration, and the variable's type decides what you get:
package web
import (
"embed"
"net/http"
)
//go:embed dist/index.html dist/assets/*
var assets embed.FS
//go:embed robots.txt
var robotsTxt string
//go:embed logo.png
var logoPNG []byte
func Handler() http.Handler {
return http.FileServerFS(assets)
}
Three types cover almost every use. embed.FS is a read-only fs.FS, so anything that accepts a filesystem — http.FileServerFS, fs.WalkDir, fs.ReadFile, template parsing — accepts it unchanged. A string suits text you interpolate (a version stamp, a robots file). A []byte suits binary blobs you hand to a decoder. The directive is validated at build time: a pattern that matches nothing is a compile error, not a runtime surprise.
The serving line is the payoff. http.FileServerFS(assets) mounts the embedded tree as an http.Handler with no os.Open, no working-directory assumption, and no directory to ship. The binary is the deployment artifact, and it is complete — the failure mode where the container starts without its static folder stops existing.
Two properties of embedding shape when to reach for it. Content is baked at compile time: edit dist/index.html and nothing changes until you rebuild, which is why local frontend development usually keeps a runtime override. And the bytes live in the executable's read-only data segment: they cost memory once per process image (the operating system shares those pages across processes), they bloat the binary you distribute, and they cannot be updated without a new binary.
A development switch keeps both workflows honest:
func Handler(dir string) http.Handler {
if dir != "" {
return http.FileServer(http.Dir(dir)) // live reload in dev
}
return http.FileServerFS(assets)
}
-static-dir dist during frontend work, empty in production. The production path never reads the disk, so the artifact you test is the artifact you ship — the same single-artifact reasoning that drives scratch containers in Containerizing Go Services.
What the Pattern Actually Matches (and What It Sneaks In)
A team embeds //go:embed static and ships. On the developer's machine the build works; in CI the CSS 404s — or the reverse: CI fails with "no matching files found" for a file that plainly exists locally. Both have the same root cause: the tree go build sees is not the tree in version control. Generated files missing from the repository, or junk files present only locally, are invisible until the build runs somewhere else. Embed patterns have precise rules, and knowing them prevents both the missing-file and the shipped-surprise cases.
Naming a directory embeds the whole subtree, recursively. //go:embed static includes static/css/app.css and everything below it. Naming a file embeds exactly that file. Patterns support * and ? through path.Match semantics, but with an important exclusion: a wildcard that matches a directory's contents skips files whose names begin with . or _.
The all: prefix disables that exclusion. Compare:
//go:embed templates // skips templates/.env and templates/_partial.html
var withDefault embed.FS
//go:embed all:templates // includes dotfiles and underscore files
var withEverything embed.FS
That rule protects you more often than it hurts: .git, .DS_Store, and editor droppings stay out of the binary by default. It bites when a legitimate asset begins with _ — some static-site generators emit _headers — and the symptom is a runtime fs.ErrNotExist for a file that plainly exists on disk.
What cannot be embedded is as important as what can. Patterns may not contain . or .. elements, may not start or end with /, and may not reference files outside the package's module — //go:embed ../../etc/passwd does not compile. Symlinks are not followed: a symlink pointing outside the tree simply does not match. These constraints turn the embed directive into a small auditable surface: anything in the binary had to be a real, in-repo file at build time.
That last property flips the usual secret worry on its head, with one exception. Because the directive only takes real files, you cannot accidentally embed a runtime-mounted secret — but you can accidentally commit a file and then embed it. //go:embed .env.local bakes tokens into an artifact that travels through registries and is trivially unpacked with strings. The rule: embed only files whose contents you would paste into a pull request, and let CI grep the source for go:embed directives next to secret-looking filenames. For runtime configuration that must stay out of the binary, load it from the environment or a mounted file as described in Configuration and Secrets Handling.
For everything else — templates, migration SQL, certificate fixtures, a wasm runtime — embedding is the honest default: the files are versioned with the code that reads them, and a mismatch between the two becomes impossible.
Freshness, Size, and Testing the Files You Cannot See
Embedding introduces two costs that are easy to accept blindly: stale content and a bigger binary. Both are manageable once measured.
Freshness is a build-system problem. The directive is evaluated when the package compiles, so any change to an embedded file must trigger a rebuild. Go's build cache keys on file contents for embedded files, so go build after editing dist/index.html does produce a new binary — but a Makefile that only reruns go build when *.go files change will happily skip it. The fix is boring: make the asset build a prerequisite of go build, or always rebuild in CI. The failure mode to watch for is the dev server serving runtime files while production serves embedded ones; if the two paths diverge for long enough, the embedded copy is a surprise at the worst moment. Keeping one function that returns fs.FS, with the dev/production switch inside it, keeps the divergence to a single reviewed line.
Size is a measurement, not a feeling. A go build -o app followed by ls -l before and after adding the directive gives the exact cost, and for a web UI the dominant term is images — a single 400 KB hero image can outweigh all the code. Compression is not applied to embedded content, so the practical lever is the same one any static host uses: shrink the assets. When a large asset set changes often, the halfway house is embedding the shell of the page and fetching bulk data at runtime, which keeps single-artifact deploys without shipping megabytes per release.
Tests can see embedded files, and that is the point. Because embed.FS satisfies fs.FS, handlers built on it are testable with httptest and fstest:
func TestIndexServed(t *testing.T) {
h := Handler()
srv := httptest.NewServer(h)
defer srv.Close()
res, err := http.Get(srv.URL + "/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("got %d, want 200", res.StatusCode)
}
}
The test runs against the exact bytes production will serve — no fixture directory, no cd into the repo root, no skipped-test-when-file-missing logic. When a test needs a modified tree (missing file, unusual names), testing/fstest.MapFS builds one in memory; that combination is the same recipe as the handler tests in Testing HTTP with httptest.
The habit worth keeping: treat embedded assets as part of the API surface. They are shipped, cached, and versioned with the binary, so a change to them deserves the same review and the same test as a change to the code that reads them.
Quiz
1. A handler does `f, err := fs.ReadFile(assets, "templates/home.html")`. Which declaration makes that compile and return the file from the binary?
2. CI builds fail with "no matching files found" for `//go:embed static/*.map`. On the developer's machine the same build succeeds. What is the most likely cause?
3. A site generator writes a legitimate file named `_headers` into static/, and the handler 404s for it although the file exists. Why, and what fixes it?
4. After embedding a 12 MB folder of images, which statement about the running service is correct?
Flashcards
Question
Which variable types can a //go:embed directive populate, and when do you pick each?
Click to reveal answer
Answer
embed.FS for trees and anything that takes an fs.FS (http.FileServerFS, fs.WalkDir, template parsing); string for text you interpolate like a robots.txt; []byte for blobs you hand to a decoder. Anything else — a struct field, a map, an int — is a compile error.
Question
What is the difference between `//go:embed static` and `//go:embed all:static`?
Click to reveal answer
Answer
The plain pattern skips files whose names begin with . or _, which keeps editor droppings and .git out of the binary. The all: prefix embeds everything in the subtree, which you need when legitimate assets start with an underscore — otherwise they are simply absent at runtime with fs.ErrNotExist.
Question
Why can a //go:embed pattern never reference /etc/passwd or ../../secrets, and what does that guarantee?
Click to reveal answer
Answer
Patterns may not contain . or .. elements and may not match files outside the package's module, and symlinks are not followed. The guarantee is that the binary's contents are auditable from the repository: every embedded byte came from a real, in-tree, usually version-controlled file at build time.
Question
How do you keep frontend hot-reload during development while shipping embedded assets in production?
Click to reveal answer
Answer
Have one function return the handler: if a dev flag names a directory, serve http.Dir(dir) so edits appear immediately; otherwise serve http.FileServerFS(embedded). The switch keeps the runtime-reading path out of production and lets tests exercise the embedded path directly.
Question
When does an edit to an embedded file take effect, and what build setups get this wrong?
Click to reveal answer
Answer
On the next go build — the directive is evaluated at compile time and Go's cache keys on the embedded file's contents. Build systems that trigger go build only when *.go files change will skip the rebuild and deploy yesterday's assets; make the asset build a prerequisite or always rebuild in CI.
Revision Notes
Key Takeaways
- 1. embed.FS is an fs.FS, so file servers, template parsers, and fs.WalkDir consume it without adaptation
- 2. Embedding happens at compile time — freshness is guaranteed only if the build reruns when assets change
- 3. Wildcard patterns silently skip dot- and underscore-prefixed files; all: is the escape hatch
- 4. The embed surface is auditable: no paths outside the module, no symlinks, no match means the build fails
- 5. Secrets in an embedded file ship in the artifact; keep runtime configuration out of the binary
Interview Tips
- • "How would you deploy a Go service with static assets?" — embed them into the binary, serve with http.FileServerFS, and note the deploy artifact is one file with no directory to sync
- • "What does go:embed do at build time?" — copies matched files into the read-only data segment; mention compile-time validation and that a missing match fails the build
- • "How do you test a handler that reads embedded files?" — httptest against the real handler; the embedded FS is already in the test binary, with fstest.MapFS for edge-case trees
- • Be ready with the staleness caveat: edits require a rebuild, and a CI pipeline that only rebuilds on Go file changes ships old assets
Cheat Sheet
Embedding Assets with go:embed
Choose the variable type:
| Need | Declaration |
|---|---|
| Serve/walk a tree | //go:embed dist + var dist embed.FS |
| Inline small text | //go:embed robots.txt + var s string |
| Raw binary blob | //go:embed logo.png + var b []byte |
Pattern rules:
- Directory name → whole subtree, recursive
*/?via path.Match; skips.\*and_*names → useall:to include- No
.,.., leading/trailing slash, no files outside the module, no symlinks - No match → compile error, not runtime
Serve it:
func Handler() http.Handler {
if devDir != "" {
return http.FileServer(http.Dir(devDir))
}
return http.FileServerFS(assets)
}
Never embed: .env, key files, anything gitignored-but-sensitive — embedded bytes ship in every artifact.
Cost model: binary size grows by asset bytes; pages are OS-shared across processes; changes need a rebuild; no runtime update path.
Continue Learning
Before You Start
- No prerequisites listed.