·2 min read·← All posts
Go embed.FS Deployment

What it does

import "embed"

//go:embed all:web/dist
var webAssets embed.FS

//go:embed config/prompts/*.md
var promptTemplates embed.FS

//go:embed migrations/*.sql
var migrations embed.FS

At compile time, the Go toolchain reads these files into the binary’s data section. At runtime, the binary serves them from memory — no filesystem dependency.

Three places it pays back

1. UI assets

A typical “Go API + React SPA” deploy has two artefacts: the Go binary and the SPA bundle. They get deployed separately; configured separately; CORS-coordinated.

With embed.FS, the SPA is part of the binary:

//go:embed all:web/dist
var webFS embed.FS

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/", apiHandler)
    mux.Handle("/", http.FileServer(http.FS(webFS)))
    http.ListenAndServe(":8080", mux)
}

One artefact. One Docker image. Zero CORS configuration. No “did we deploy the right SPA version” question.

For Genie’s UI, the React build lands in pkg/web/handlers/ui/. The handler embeds and serves. The on-call doesn’t think about it.

2. Prompt templates

Prompts are versioned content. They should be in the repo, reviewed in PRs, deployed as a unit with the code that uses them.

//go:embed prompts/*.tmpl
var promptFS embed.FS

func loadPrompt(name string) (*template.Template, error) {
    data, err := promptFS.ReadFile("prompts/" + name + ".tmpl")
    if err != nil { return nil, err }
    return template.New(name).Parse(string(data))
}

The prompts are in version control next to the code that uses them. A prompt change is a PR. A prompt regression is reverted by reverting the PR. No external prompt-storage system to manage.

3. SQL migrations

The classic Go migration pattern:

//go:embed migrations/*.sql
var migrationFS embed.FS

func runMigrations(ctx context.Context, db *sql.DB) error {
    entries, _ := fs.ReadDir(migrationFS, "migrations")
    for _, e := range entries {
        if !strings.HasSuffix(e.Name(), ".sql") { continue }
        sql, _ := fs.ReadFile(migrationFS, "migrations/"+e.Name())
        if _, err := db.ExecContext(ctx, string(sql)); err != nil {
            return fmt.Errorf("migration %s: %w", e.Name(), err)
        }
    }
    return nil
}

Migrations are part of the binary. The deploy process doesn’t need to ship a separate migration script. Schema drifts between binary version and migration version are impossible.

When NOT to use it

The deployment shape it enables

For Genie:

This is the “single binary” appeal of Go made concrete. The deployment story collapses to one artefact; the operational surface area shrinks accordingly.

For any Go service: audit your deploy. Are you shipping multiple artefacts that always travel together? If yes, embed.FS is probably the right tool.

← Back to all posts