API Documentation in Go With Swagger UI Embedded in Binary
Serve interactive API docs from a Go binary: Swagger UI embedded with swgui, the spec via go:embed, a password gate, CSP caveats and a build tag.

A good API reference is a page where a developer can read an endpoint, fill in a parameter and press a button. Swagger UI is the best-known way to build that page from an OpenAPI document, and the question for a Go service is where it runs. A separate documentation container is one more thing to deploy, version and keep in sync with the API it describes. Because Go compiles everything into one binary, there is a simpler option: embed the UI and the spec in the service itself, so the docs are always from the same build as the code.
Tested with Go 1.25.6 and swaggest/swgui v1.8.9, macOS on Apple Silicon (arm64)
This article does that with the embed package and swaggest/swgui, measures what it costs, and covers the three things that go wrong in practice: a Content-Security-Policy that blocks the page, docs exposed in production by accident, and a spec that drifts from the code. It follows OpenAPI for Go services and uses the contract style from the server generator article. Everything ran on Go 1.25.6 with swgui v1.8.9.
The pieces
The service has four parts: the OpenAPI file, a route that serves it, a route that serves the UI, and the API itself. The document is small here because the point is the wiring.
openapi: 3.0.3
info:
title: Tasks API
version: 1.0.0
paths:
/v1/tasks:
get:
summary: List tasks
operationId: listTasks
responses:
'200':
description: tasks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: {$ref: '#/components/schemas/Task'}
components:
schemas:
Task:
type: object
properties:
id: {type: integer}
title: {type: string}Two files mount the UI. The first compiles by default:
//go:build !nodocs
package main
import (
"net/http"
v5emb "github.com/swaggest/swgui/v5emb"
)
// mountDocs serves Swagger UI under /docs/. The UI's JavaScript and CSS are embedded in the binary by the library.
func mountDocs(mux *http.ServeMux, gate func(http.Handler) http.Handler) {
ui := v5emb.New("Tasks API", "/openapi.yaml", "/docs/")
mux.Handle("/docs/", gate(ui))
}v5emb.New returns an http.Handler. Its three arguments are the page title, the URL the UI should load the spec from, and the base path where the handler is mounted. The emb in the package name is the point: the Swagger UI JavaScript and CSS are embedded in the library with go:embed, so there is no CDN request and the page works offline and on an air-gapped network. The sibling package v5cdn loads the same UI from a CDN instead and adds nothing to the binary.
The second file compiles only with -tags nodocs and mounts nothing:
//go:build nodocs
package main
import "net/http"
func mountDocs(mux *http.ServeMux, gate func(http.Handler) http.Handler) {}Both define mountDocs with the same signature, so the rest of the program does not know which one it got. That is the whole mechanism of the build tag.
The service
package main
import (
_ "embed"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
)
//go:embed openapi.yaml
var spec []byte
func newMux(gate func(http.Handler) http.Handler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(spec)
})
mux.HandleFunc("GET /v1/tasks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":[{"id":1,"title":"write"}]}`))
})
mountDocs(mux, gate)
return mux
}
// basicGate protects the docs with HTTP basic auth when DOCS_PASSWORD is set; without it the docs are open.
func basicGate(password string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if password == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, p, ok := r.BasicAuth(); !ok || p != password {
w.Header().Set("WWW-Authenticate", `Basic realm="API docs"`)
http.Error(w, "docs require a password", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
func get(h http.Handler, path string, auth ...string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
if len(auth) == 2 {
req.SetBasicAuth(auth[0], auth[1])
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func main() {
h := newMux(basicGate(os.Getenv("DOCS_PASSWORD")))
page := get(h, "/docs/")
fmt.Printf("GET /docs/ %d %s (%d bytes)\n", page.Code, page.Header().Get("Content-Type"), page.Body.Len())
if page.Code != 200 {
return
}
fmt.Printf(" inline <script> blocks: %d, inline <style> blocks: %d (a strict Content-Security-Policy will block these)\n",
strings.Count(page.Body.String(), "<script>"), strings.Count(page.Body.String(), "<style>"))
re := regexp.MustCompile(`(?:src|href)="([^"]+)"`)
seen := map[string]bool{}
for _, m := range re.FindAllStringSubmatch(page.Body.String(), -1) {
u := m[1]
if !strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "./") && !strings.HasPrefix(u, "swagger") && !strings.HasPrefix(u, "favicon") || seen[u] {
continue
}
seen[u] = true
path := "/docs/" + strings.TrimPrefix(strings.TrimPrefix(u, "./"), "/docs/")
if strings.HasPrefix(u, "/") {
path = u
}
res := get(h, path)
fmt.Printf(" asset %-30s %d %-24s %8d bytes\n", u, res.Code, res.Header().Get("Content-Type"), res.Body.Len())
}
s := get(h, "/openapi.yaml")
body, _ := io.ReadAll(s.Body)
fmt.Printf("GET /openapi.yaml %d %s (%d bytes)\n", s.Code, s.Header().Get("Content-Type"), len(body))
}GET /docs/ 200 text/html (3440 bytes)
inline <script> blocks: 2, inline <style> blocks: 1 (a strict Content-Security-Policy will block these)
asset /docs/swagger-ui.css 200 text/css; charset=utf-8 178977 bytes
asset /docs/favicon-32x32.png 200 image/png 628 bytes
asset /docs/favicon-16x16.png 200 image/png 665 bytes
asset /docs/swagger-ui-bundle.js 200 application/javascript 1537593 bytes
asset /docs/swagger-ui-standalone-preset.js 200 application/javascript 256694 bytes
GET /openapi.yaml 200 application/yaml (577 bytes)//go:embed openapi.yaml puts the spec file into a []byte at compile time. The /openapi.yaml route writes it with Content-Type: application/yaml and a five-minute cache. If you generate the spec (code-first) you can serve the generated document instead; the rule is that one artifact feeds the docs, the validator and the client generator.
mountDocs takes a gate, a middleware wrapped around the UI. basicGate reads a password and, when it is set, requires HTTP basic auth on every docs request; with no password it returns the handler unchanged. The program reads DOCS_PASSWORD from the environment, so the same binary is open in development and locked in production without a code change.
The main function then plays the part of a browser: it fetches /docs/, finds the assets the page references and requests each one.
What the page loads
The recorded run:
GET /docs/ 200 text/html (3440 bytes)
inline <script> blocks: 2, inline <style> blocks: 1
asset /docs/swagger-ui.css 200 text/css; charset=utf-8 178977 bytes
asset /docs/favicon-32x32.png 200 image/png 628 bytes
asset /docs/favicon-16x16.png 200 image/png 665 bytes
asset /docs/swagger-ui-bundle.js 200 application/javascript 1537593 bytes
asset /docs/swagger-ui-standalone-preset.js 200 application/javascript 256694 bytes
GET /openapi.yaml 200 application/yaml (577 bytes)Every asset is served by the Go process, with the right content types. The page itself is 3.4 KB of HTML that loads the UI bundle and then, in an inline script, points it at /openapi.yaml. A reader who opens /docs/ gets the interactive reference, and "Try it out" sends requests to the same origin, so there are no CORS rules to configure for the docs to work.
The cost, and the build tag
go build -o app . && go build -tags nodocs -o app-nodocs .
echo "with docs: $(wc -c < app) bytes"
echo "without docs: $(wc -c < app-nodocs) bytes"with docs: 12363554 bytes
without docs: 5704338 bytesWith docs compiled in, the binary is 12.4 MB; with -tags nodocs it is 5.7 MB. The Swagger UI adds 6.7 MB, more than the rest of this small service. For a service that already ships a 40 MB binary this is noise. For a tiny sidecar or a function that is cold-started, it is worth the tag. Two builds from one code base is the same arrangement as build tags for other optional features, and the production Dockerfile can choose.
Whether to ship docs in production at all is a product decision. A public API wants them there. An internal service can serve them only in staging. A partner API may want them behind a login. The code above supports all three: the tag removes them, the gate protects them, and leaving both off exposes them.
Tests
package main
import (
"strings"
"testing"
)
func TestDocsAreServedAndPointAtTheSpec(t *testing.T) {
h := newMux(basicGate(""))
page := get(h, "/docs/")
if page.Code != 200 || !strings.Contains(page.Body.String(), "/openapi.yaml") {
t.Fatalf("docs: %d", page.Code)
}
if js := get(h, "/docs/swagger-ui-bundle.js"); js.Code != 200 || js.Body.Len() < 100_000 {
t.Fatalf("bundle: %d, %d bytes", js.Code, js.Body.Len())
}
}
func TestSpecIsServedWithItsOwnContentType(t *testing.T) {
rec := get(newMux(basicGate("")), "/openapi.yaml")
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/yaml" || !strings.HasPrefix(rec.Body.String(), "openapi: 3.0.3") {
t.Fatalf("%d %q", rec.Code, rec.Header().Get("Content-Type"))
}
}
func TestPasswordGateProtectsDocsButNotTheAPI(t *testing.T) {
h := newMux(basicGate("s3cret"))
if rec := get(h, "/docs/"); rec.Code != 401 || rec.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("anonymous docs: %d", rec.Code)
}
if rec := get(h, "/docs/", "docs", "wrong"); rec.Code != 401 {
t.Fatalf("wrong password: %d", rec.Code)
}
if rec := get(h, "/docs/", "docs", "s3cret"); rec.Code != 200 {
t.Fatalf("right password: %d", rec.Code)
}
if rec := get(h, "/v1/tasks"); rec.Code != 200 {
t.Fatalf("api: %d", rec.Code)
}
}=== RUN TestDocsAreServedAndPointAtTheSpec
--- PASS: TestDocsAreServedAndPointAtTheSpec (0.12s)
=== RUN TestSpecIsServedWithItsOwnContentType
--- PASS: TestSpecIsServedWithItsOwnContentType (0.00s)
=== RUN TestPasswordGateProtectsDocsButNotTheAPI
--- PASS: TestPasswordGateProtectsDocsButNotTheAPI (0.00s)
PASS
ok example.test/lab 1.487sThe tests cover what is easy to break. The docs page must be served and must refer to the spec URL, and the bundle must be a real asset and not an empty file. The spec must come back with its own content type and start with the OpenAPI version line. The gate must return 401 with a challenge for no credentials and for a wrong password, 200 for the right one, and must leave /v1/tasks alone.
Three things that go wrong
The Content-Security-Policy blocks it. The recorded run counts inline blocks in the page: two inline <script> blocks and one <style> block, besides the external scripts. A common hardening header, Content-Security-Policy: default-src 'self', forbids inline scripts, and the UI renders as a blank page with errors in the console. Either give the docs route its own, looser policy (a nonce or a hash for the inline blocks is better than 'unsafe-inline'), or apply the strict policy to the API routes and a relaxed one to /docs/. Test this in a browser, since tests that fetch HTML cannot see it. The wider checklist is in the API security checklist.
The docs are public by accident. An API reference is also a map for an attacker: every route, parameter and error shape. The gate above is one way to protect the UI. Notice that it does not cover /openapi.yaml; the spec route is separate, and anyone who guesses it reads the same information. If the docs are private, gate both, or serve the spec from under /docs/. Do not rely on the UI being unlinked.
The spec is stale. A hand-edited YAML next to a code base is wrong within a month. Keep one source: generate the spec from code and embed the output at build time, or write it first and generate the server. In both cases add a CI step that validates the document (load it with an OpenAPI library, as in the earlier articles) and that fails if the generated spec differs from the committed one.
Other choices
Redoc, Scalar and Stoplight Elements render the same document with different looks, and some are read-only. They load from a script tag and you can embed their built assets with embed in the same way. Swagger UI is the one with a "Try it out" console in the box, which is why it remains the default.
Hosted docs. Publishing a rendered HTML page to static hosting keeps the UI off your servers completely. That is a good fit for a public API whose reference does not need to call your production system.
Versions. swgui bundles a specific Swagger UI release. Pin the module version, and read the changelog when you bump it; the UI has had security fixes like any front-end library.
The library is at pkg.go.dev/github.com/swaggest/swgui, and the embed package that powers the spec route is documented at pkg.go.dev/embed.


