Deploying a Go API alongside a frontend has traditionally been a headache. You build the Go binary, then you have to somehow get the dist/ folder next to it, configure the right static file path, make sure Nginx or Caddy is serving it, add a Docker COPY step, update your systemd unit… it’s a lot of moving parts for something that should be simple.
Go 1.16 shipped a quiet but genuinely useful feature: the embed package. It lets you bake static files — HTML, CSS, JS, images, fonts, whatever — directly into your compiled binary. One binary, zero external file dependencies. Deploy it anywhere, run it, done.
This article covers everything you need to do this properly: the basics, a full SPA setup, the dev/prod split pattern, and the gotchas that will bite you if nobody warns you first.
Why This Actually Matters
If you’ve ever deployed a Go backend + React frontend combo, you know the dance:
- Frontend build artifacts need to live somewhere the server can find them
- Docker images need two separate copy stages or volume mounts
- Systemd units need
WorkingDirectoryset correctly so relative paths resolve - The moment someone runs the binary from the wrong directory, everything breaks
Embedding solves all of that. The binary becomes self-contained. You can scp it to a server, run it, and the frontend works. No Nginx, no path configuration, no ceremony.
It’s particularly compelling for internal tools, dashboards, and small self-hosted apps where you don’t want the operational overhead of a proper CDN + separate API setup.
The Basics: //go:embed
The embed package is part of the standard library. Import it, add a directive above a variable, and the compiler handles the rest at build time.
package main
import (
_ "embed"
"fmt"
)
//go:embed hello.txt
var greeting string
func main() {
fmt.Print(greeting)
}
The //go:embed directive must appear immediately before the variable declaration — no blank lines between them. The file path is relative to the .go source file containing the directive, not the working directory at build or run time.
Three supported variable types:
string— for a single text file[]byte— for a single binary fileembed.FS— for one or more files, supports glob patterns
For serving a frontend, you always want embed.FS.
Setting Up the Project Structure
Here’s the layout we’re working with. Frontend is a Vite/React app; Go is the backend. Classic monorepo setup:
myapp/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ └── server/
│ └── server.go
├── frontend/
│ ├── src/
│ ├── index.html
│ ├── package.json
│ └── vite.config.ts
├── web/ ← embed reads from here
│ └── .gitkeep ← placeholder until you build the frontend
├── go.mod
└── Makefile
The key decision: where does the compiled frontend output land? I put it in web/. During development the folder might be empty (or you run the dev server separately). The CI/CD pipeline or make build copies the Vite output into web/ before compiling Go.
The embed.FS Handler
// internal/server/static.go
package server
import (
"io/fs"
"net/http"
)
// NewStaticHandler wraps an embedded filesystem and serves it over HTTP.
// sub should be the subdirectory inside the FS that contains index.html.
func NewStaticHandler(embedded fs.FS, sub string) (http.Handler, error) {
fsys, err := fs.Sub(embedded, sub)
if err != nil {
return nil, err
}
return http.FileServer(http.FS(fsys)), nil
}
And in main.go:
// cmd/server/main.go
package main
import (
"embed"
"log"
"net/http"
"myapp/internal/server"
)
//go:embed all:../../web
var webFS embed.FS
func main() {
staticHandler, err := server.NewStaticHandler(webFS, "web")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
// API routes first
mux.HandleFunc("/api/", apiHandler)
// Everything else goes to the frontend
mux.Handle("/", staticHandler)
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Notice the all: prefix on the embed directive. More on why that matters in the Gotchas section.
Handling SPA Routing (The Part Everyone Gets Wrong)
The default http.FileServer returns 404 for paths like /dashboard or /settings because those aren’t actual files — they’re client-side routes your JavaScript router handles. React Router, Vue Router, same story everywhere.
The fix is to intercept 404s from the file server and serve index.html instead, letting the SPA take over:
// internal/server/spa.go
package server
import (
"io/fs"
"net/http"
)
type spaHandler struct {
fs http.FileSystem
base http.Handler
}
// NewSPAHandler creates a handler that falls back to index.html for unknown paths.
// This enables client-side routing in single-page applications.
func NewSPAHandler(embedded fs.FS, sub string) (http.Handler, error) {
fsys, err := fs.Sub(embedded, sub)
if err != nil {
return nil, err
}
httpFS := http.FS(fsys)
fileServer := http.FileServer(httpFS)
return &spaHandler{
fs: httpFS,
base: fileServer,
}, nil
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Try to open the requested path
f, err := h.fs.Open(r.URL.Path)
if err == nil {
f.Close()
// File exists — serve it normally (JS, CSS, images, etc.)
h.base.ServeHTTP(w, r)
return
}
// File not found — serve index.html and let the SPA router handle it
r2 := *r
r2.URL.Path = "/"
h.base.ServeHTTP(w, &r2)
}
This approach is clean: real assets (your hashed JS/CSS bundles, favicon, fonts) get served directly; anything else hits index.html. No regex, no special-casing of /api/ in the static handler — that’s handled by route ordering in your mux.
Dev/Prod Split: Serving from Disk in Development
Embedding files at compile time means every frontend change requires a full Go recompile. That’s terrible for development. The standard solution is a build tag or environment variable that switches between the embedded FS and the live filesystem.
// cmd/server/static_prod.go
//go:build !dev
package main
import (
"embed"
"io/fs"
)
//go:embed all:../../web
var rawWebFS embed.FS
func getWebFS() fs.FS {
fsys, _ := fs.Sub(rawWebFS, "web")
return fsys
}
// cmd/server/static_dev.go
//go:build dev
package main
import (
"io/fs"
"os"
)
// In dev mode, serve directly from disk so changes are reflected immediately.
func getWebFS() fs.FS {
return os.DirFS("./web")
}
Then in your SPA handler setup, call getWebFS() instead of doing the fs.Sub yourself.
Run with dev mode:
go run -tags dev ./cmd/server
Run for production (default, no tag needed):
go build -o myapp ./cmd/server
This is cleaner than checking environment variables at runtime because the embedded FS code literally doesn’t compile into the dev binary — no //go:embed directive is triggered.
Makefile: Tying It Together
.PHONY: build dev
# Production build: build frontend first, then embed it into the Go binary
build:
cd frontend && npm run build
cp -r frontend/dist/* web/
go build -o bin/myapp ./cmd/server
# Dev: run the Vite dev server and Go server in parallel
dev:
cd frontend && npm run dev &
go run -tags dev ./cmd/server
# For CI: explicitly fail if web/ is empty before compiling
check-web:
@test -n "$$(ls -A web/)" || (echo "ERROR: web/ is empty. Run 'make build' first." && exit 1)
Docker: The Single-Stage Dream
With an embedded binary, your Dockerfile gets pleasantly minimal:
# Stage 1: Build the frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Stage 2: Build the Go binary with the frontend embedded
FROM golang:1.24-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Copy compiled frontend into the web/ directory before embedding
COPY --from=frontend-builder /app/frontend/dist ./web/
RUN go build -o /app/bin/myapp ./cmd/server
# Stage 3: Minimal runtime image — just the binary
FROM scratch
COPY --from=go-builder /app/bin/myapp /myapp
# If your app needs TLS certs for outbound HTTPS calls:
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/myapp"]
The final image can be under 20MB for a typical app. No Node.js runtime, no Nginx, no Alpine shell. Just the binary. You can even use FROM scratch if you don’t need shell access or TLS certs.
Gotchas
Hidden files are skipped by default. The //go:embed directive ignores dotfiles and directories starting with . or _. If your Vite build outputs anything in .vite/ or your fonts live under a dotfolder, they won’t be embedded. Fix: prefix your path with all::
//go:embed all:../../web
var webFS embed.FS
The all: prefix includes hidden files and directories. Use it whenever embedding a build artifact directory you don’t fully control.
The directive path is relative to the source file. If your main.go is in cmd/server/ and your assets are in web/ at the repo root, the path in the directive is ../../web. If you move the source file, the path breaks. Keep this in mind when restructuring your project.
You can’t embed files outside the module root. The embed package enforces this as a security boundary. Everything you embed must be under the directory containing go.mod. Symlinks to paths outside the module are also rejected.
embed.FS paths always use forward slashes, even on Windows. Don’t use filepath.Join when building paths to pass to fs.FS.Open — use plain string concatenation with / or path.Join (not filepath).
No hot reload with embedded files. This is obvious in retrospect, but worth stating explicitly: once compiled, the files are frozen inside the binary. Changing a file on disk has no effect. This is why the dev build tag pattern above is not optional — it’s essential for any real development workflow.
Large binaries can be surprising. Embedding a React app with code splitting, several MB of fonts, and high-res images adds all of that to the binary size. The binary isn’t compressed by default (Go doesn’t compress embedded files). If binary size matters, look at upx for compression, or be more selective about what you embed — maybe fonts and large images live on a CDN while just the HTML/JS/CSS gets embedded.
fs.Sub strips the directory prefix. When you call fs.Sub(webFS, "web"), the resulting FS has index.html at the root, not at web/index.html. This is what you want for http.FileServer, but it’s a common point of confusion when debugging.
Production-Ready Additions
Cache headers. http.FileServer doesn’t set aggressive caching headers. For production, add a middleware that sets Cache-Control: max-age=31536000, immutable for hashed asset filenames (like main.abc123.js) and no-cache for index.html:
func withCacheHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Vite outputs content-hashed filenames for all assets
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
next.ServeHTTP(w, r)
})
}
Wrap your static handler with this before registering it in the mux.
Compression. Consider a gzip/brotli middleware for the static handler. The standard library doesn’t compress responses. Libraries like nytimes/gziphandler or a simple custom middleware work fine.
Health check. Add a /healthz endpoint that responds before touching the frontend handler. Useful for load balancers and Kubernetes probes, and ensures a misconfigured frontend doesn’t take down your health check.
The Full Picture
Once this is set up, your deployment goes from "copy binary + copy dist folder + configure path + restart service" to "copy binary + restart service." With Docker it’s just replacing the container image.
The embed approach works best for internal tools, admin panels, and small-to-medium apps where the frontend and backend ship together. For high-traffic public sites, a proper CDN for static assets is still the right call — not because embedding doesn’t work, but because CDN edge caching is genuinely better for performance at scale.
For everything else? Shipping one binary is just nicer. The embed package makes it trivial enough that there’s no reason not to.