Build a static site generator in Go: render pages from one html/template layout, copy assets with os.CopyFS, then embed the finished site in a single binary.
This tutorial takes you from an empty folder to a static website: a program that renders every page from one layout, copies your stylesheet alongside them, and folds the finished site into a single executable.
Go suits this job for three reasons you will see directly. The standard library ships a static file server. go:embed folds the generated HTML and CSS into the compiled program, so deploying is copying one file. And go build produces that file for any operating system and processor from the machine you are on.
Type every line yourself. Each step ends with something you can run.
- Go 1.23 or newer.
go versionshould print something. - A terminal, and
curlfor checking each step.
1 Set up the project
Make a folder and turn it into a Go module.
$ mkdir go-static-site
$ cd go-static-site
$ go mod init sitetutorial
go: creating new go.mod: module sitetutorialCreate main.go next to go.mod. That one file grows through the whole tutorial.
2 Write your first page
A static website is HTML sitting in a folder. Start with one template and one file on disk.
package main
import (
"fmt"
"html/template"
"log"
"os"
)
const layout = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
</head>
<body>
<h1>{{.Title}}</h1>
</body>
</html>
`
func main() {
tmpl := template.Must(template.New("layout").Parse(layout))
if err := os.MkdirAll("public", 0o755); err != nil {
log.Fatal(err)
}
f, err := os.Create("public/index.html")
if err != nil {
log.Fatal(err)
}
defer f.Close()
page := map[string]string{"Title": "Gopher Notes"}
if err := tmpl.Execute(f, page); err != nil {
log.Fatal(err)
}
fmt.Println("wrote public/index.html")
}$ go run .
$ cat public/index.html
wrote public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gopher Notes</title>
</head>
<body>
<h1>Gopher Notes</h1>
</body>
</html>Parse finds the {{.Title}} slots. Execute fills them from the value you pass and writes the result into the file.
Open public/index.html in a browser. The site already exists.
3 Put every page through one layout
A page needs a body as well as a title. Give it a type, and give the layout somewhere to put the content.
// Page is one page of the finished site.
type Page struct {
Slug string
Title string
Body template.HTML
}
const layout = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} | Gopher Notes</title>
</head>
<body>
<header><a href="/">Gopher Notes</a></header>
<main>
<h1>{{.Title}}</h1>
{{.Body}}
</main>
</body>
</html>
`Build a Page in main and render that.
page := Page{
Slug: "index",
Title: "Home",
Body: "<p>Notes on Go, written while learning it.</p>",
}
f, err := os.Create("public/" + page.Slug + ".html")
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := tmpl.Execute(f, page); err != nil {
log.Fatal(err)
}
fmt.Println("wrote public/" + page.Slug + ".html")$ go run .
$ cat public/index.html
wrote public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home | Gopher Notes</title>
</head>
<body>
<header><a href="/">Gopher Notes</a></header>
<main>
<h1>Home</h1>
<p>Notes on Go, written while learning it.</p>
</main>
</body>
</html>Body is a template.HTML, which tells the package the markup is yours and should reach the page as markup. A plain string gets escaped. Use template.HTML only on markup you wrote yourself.
4 Generate the whole site from a list
One page is a template. A site is a list of them.
var pages = []Page{
{
Slug: "index",
Title: "Home",
Body: "<p>Notes on Go, written while learning it.</p>",
},
{
Slug: "about",
Title: "About",
Body: "<p>Written by a gopher, one page at a time.</p>",
},
{
Slug: "colophon",
Title: "Colophon",
Body: "<p>Generated by a Go program. Served by one binary.</p>",
},
}Replace the single-page block with a loop. Add "path/filepath" to the imports.
for _, page := range pages {
f, err := os.Create(filepath.Join("public", page.Slug+".html"))
if err != nil {
log.Fatal(err)
}
if err := tmpl.Execute(f, page); err != nil {
log.Fatal(err)
}
if err := f.Close(); err != nil {
log.Fatal(err)
}
fmt.Println("wrote", f.Name())
}$ go run .
$ ls public
wrote public/index.html
wrote public/about.html
wrote public/colophon.html
about.html
colophon.html
index.htmlClose at the end of each pass so one file is open at a time. The error from Close catches a write that failed on its way to disk.
filepath.Join uses the separator the running machine expects.
5 Add a stylesheet
CSS is a file rather than a template. Keep assets in their own folder and copy it across on every build. Create static/style.css.
body {
max-width: 40rem;
margin: 3rem auto;
padding: 0 1rem;
font-family: system-ui, sans-serif;
line-height: 1.6;
}
header a { font-weight: 700; text-decoration: none; }Link it from the layout, under the title.
<link rel="stylesheet" href="/style.css">Clear the output directory, then copy static into it after the pages are written.
if err := os.RemoveAll("public"); err != nil {
log.Fatal(err)
}
if err := os.MkdirAll("public", 0o755); err != nil {
log.Fatal(err)
}
// ... the loop from step 4 goes here ...
if err := os.CopyFS("public", os.DirFS("static")); err != nil {
log.Fatal(err)
}
fmt.Println("copied static/ into public/")$ go run .
$ ls public
wrote public/index.html
wrote public/about.html
wrote public/colophon.html
copied static/ into public/
about.html
colophon.html
index.html
style.cssos.DirFS turns a directory into a file system value, and os.CopyFS writes it into the destination. CopyFS refuses to overwrite, which is why RemoveAll comes first.
6 Preview it in a browser
Serve the folder over HTTP so absolute paths like /style.css resolve. Move the generator into a build function and give main a flag.
func build() {
// everything main did up to now
}
func main() {
addr := flag.String("serve", "", "address to serve public/ on, for example :8080")
flag.Parse()
if *addr == "" {
build()
return
}
http.Handle("/", http.FileServer(http.Dir("public")))
fmt.Println("serving public/ on http://localhost" + *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}Add "flag" and "net/http" to the imports.
$ go run . -serve :8080
serving public/ on http://localhost:8080Ask it for the stylesheet from a second terminal.
$ curl -I localhost:8080/style.css
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 191
Content-Type: text/css; charset=utf-8http.FileServer set those headers itself: the content type from the extension, and byte ranges so a browser can resume a download. Stop it with Ctrl-C when you are done looking.
Visit http://localhost:8080 and the site is styled, with working links.
7 Ship the whole site as one binary
go:embed reads the folder at compile time and stores it inside the executable.
//go:embed public
var built embed.FSPut that above the Page type, add "embed" and "io/fs", and serve from the embedded copy.
site, err := fs.Sub(built, "public")
if err != nil {
log.Fatal(err)
}
http.Handle("/", http.FileServer(http.FS(site)))Build the binary, copy it somewhere empty, and run it there.
$ go build -o sitegen .
$ mkdir /tmp/deploy && cp sitegen /tmp/deploy && cd /tmp/deploy
$ ls
$ ./sitegen -serve :8080
sitegen
serving public/ on http://localhost:8080From the second terminal:
$ curl -s localhost:8080/about.html | grep h1
<h1>About</h1>That folder holds one file, and the whole website is being served out of it.
fs.Sub trims the prefix, so pages stored at public/index.html serve at index.html. http.FS adapts it to what FileServer takes.
go:embed reads the folder when you compile, so run the generator before you build.
8 Build it for the machine that will run it
Set two environment variables and build for the machine that will run it.
$ GOOS=linux GOARCH=arm64 go build -o sitegen-linux-arm64 .
$ file sitegen-linux-arm64
sitegen-linux-arm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linkedGOOS takes linux, darwin, and windows. GOARCH takes amd64 and arm64. Run go tool dist list to see every pair.
statically linked is the part that matters: the binary carries everything it needs, so you copy it over and run it.
The whole program main.go Show
package main
import (
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
)
//go:embed public
var built embed.FS
// Page is one page of the finished site.
type Page struct {
Slug string
Title string
Body template.HTML
}
var pages = []Page{
{
Slug: "index",
Title: "Home",
Body: "<p>Notes on Go, written while learning it.</p>",
},
{
Slug: "about",
Title: "About",
Body: "<p>Written by a gopher, one page at a time.</p>",
},
{
Slug: "colophon",
Title: "Colophon",
Body: "<p>Generated by a Go program. Served by one binary.</p>",
},
}
const layout = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} | Gopher Notes</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header><a href="/">Gopher Notes</a></header>
<main>
<h1>{{.Title}}</h1>
{{.Body}}
</main>
</body>
</html>
`
func build() {
tmpl := template.Must(template.New("layout").Parse(layout))
if err := os.RemoveAll("public"); err != nil {
log.Fatal(err)
}
if err := os.MkdirAll("public", 0o755); err != nil {
log.Fatal(err)
}
for _, page := range pages {
f, err := os.Create(filepath.Join("public", page.Slug+".html"))
if err != nil {
log.Fatal(err)
}
if err := tmpl.Execute(f, page); err != nil {
log.Fatal(err)
}
if err := f.Close(); err != nil {
log.Fatal(err)
}
fmt.Println("wrote", f.Name())
}
if err := os.CopyFS("public", os.DirFS("static")); err != nil {
log.Fatal(err)
}
fmt.Println("copied static/ into public/")
}
func main() {
addr := flag.String("serve", "", "address to serve public/ on, for example :8080")
flag.Parse()
if *addr == "" {
build()
return
}
site, err := fs.Sub(built, "public")
if err != nil {
log.Fatal(err)
}
http.Handle("/", http.FileServer(http.FS(site)))
fmt.Println("serving public/ on http://localhost" + *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}What you built
You have a static site generator. It renders every page from one layout, copies your stylesheet alongside them, previews over HTTP, and compiles into a single executable.
Six tools did that: html/template rendered and escaped, os.Create and os.CopyFS wrote the output, flag chose the mode, http.FileServer served it, go:embed moved it inside the program, and go build aimed it at another machine.
That last pair is the reason to reach for Go here. The site ships as one file that serves itself, built for whatever machine will run it.