# Keep the site static, put a Go server in front of it

How we gave a Hugo site per-user access control without giving up static output: a small Gin server, Hanko for sign-in, and the whole site embedded in the binary.

A valid sign-in turned out not to be enough. Our Hanko project accepts self-registration, so if a session had been the only check on our internal knowledge base, every stranger who created an account would have been let in. What we wanted was more specific: give individual people access, and take it away from one person without touching anyone else. The knowledge base is a Hugo site that was on Netlify, and that combination could not do it.

If you would rather read the code first, [demo-static-gating](https://github.com/amfeltlabs/demo-static-gating) has a working version: a small Hinode site and the Go server that gates it. It runs locally without a Hanko account. It is a proof of concept, not a hardened server, and its README lists what it leaves out.

---

## What the hosted options gave us

Netlify's access controls were thin for what we needed. We could put the whole site behind a shared password, or connect Auth0. A shared password answers "is this a person we trust" once and then forgets who they are, so we could not grant or revoke a single user. Netlify Identity was the other candidate, but Netlify had announced it would be discontinued, and it only [reversed that decision](https://answers.netlify.com/t/netlify-identity-is-staying-feb-2026-reversal-what-changed-whos-affected-and-how-to-proceed/162733) in February 2026. Common wisdom says a static site belongs on a static host with authentication added around it; that holds until you want to decide per person, which is where they ran out.

---

## The server we built instead

We already write Go, so we stopped looking for a bolt-on and wrote the front door ourselves: a small web server on the Gin framework. Hanko handles sign-in. Hugo still builds the site exactly as before.

We chose [Hanko](https://www.hanko.io) for two reasons. The first is how easy it is to make your own: its login screens ship as web components (Hanko Elements) you style with CSS and translate with your own strings, so the sign-in page can look like the rest of the site instead of a vendor's page dropped into it. The open source [mod-hanko](https://github.com/gethinode/mod-hanko) module (MIT-licensed) mounts that template into a Hinode site and dispatches Hanko session events as plain browser events, which is how we return people to their page after they sign in again.

The second is independence from someone else's hosting: the backend is open source under the AGPL-3.0, runs from Docker Compose, and Hanko says you can move between self-hosted and its cloud service at any time. We use the hosted service today; knowing we could run it ourselves is what made us comfortable depending on it. The AGPL covers changes you offer over a network, and Hanko sells a commercial license if that does not suit you.

The finished Hugo output is embedded into the server binary with Go's `embed` package, and the server serves it straight from there. There is no directory of pages on the host for a misconfigured route to expose, and nothing to deploy separately from the code that guards it. The site and its gate ship as one file. It runs from a container image, and because the artifact is one Go binary, moving between hosts changed where it runs and nothing about how it is built.

That is why the session is only the first check. The server also requires a staff role, granted per person: the per-user control the hosted options could not give us. The check sits in the authentication middleware and runs only once the session is known valid, so the user ID it looks up can be trusted:

```go
c.Set("userID", userID)

if o.RequireRole != "" {
    roles, err := o.Resolver.Roles(ctx, userID)
    if err != nil {
        c.AbortWithStatus(http.StatusServiceUnavailable)
        return
    }
    if !slices.Contains(roles, o.RequireRole) {
        c.Data(http.StatusForbidden, forbiddenContentType, forbiddenBody)
        c.Abort()
        return
    }
}

c.Next()
```

A signed-in user without the role gets a 403 rather than a redirect, which would loop them back into the same session.

One detail cost us care: everything embedded in the binary is one request away from anyone who passes the gate, so what goes in matters. In the [`//go:embed` directive](https://pkg.go.dev/embed#hdr-Directives), we name the output directory with a plain `dist` pattern rather than `all:dist`:

```go
// A bare `dist`, not `all:dist`: the plain pattern skips files
// whose names start with a dot or an underscore.
//
//go:embed dist
var content embed.FS
```

The plain pattern keeps a stray `.env` or `.DS_Store` out of the binary. The static handler adds a second check, refusing paths with a segment that starts with a dot or an underscore, apart from `.well-known`, and answering 404, same as a missing page, if one slips in anyway.

---

## Static output, dynamic server

A dynamic server in front of static files trades a CDN for a single process, so caching has to carry the difference. It does, in two layers. Hugo fingerprints its CSS and JavaScript bundles by content hash, so a bundle's name changes with its contents. The server marks them cacheable for a year and immutable, and browsers stop asking about them at all.

```go
// Hugo's fingerprint: main.min.<sha256>.css, core.bundle.en.<sha256>.mjs
var fingerprinted = regexp.MustCompile(`\.[0-9a-f]{64}\.(?:css|js|mjs)$`)

func ImmutableAssets() gin.HandlerFunc {
    return func(c *gin.Context) {
        if fingerprinted.MatchString(c.Request.URL.Path) {
            c.Header("Cache-Control", "public, max-age=31536000, immutable")
        }
        c.Next()
    }
}
```

The pattern matches the hash, not the folder. Plenty of files under `/css/` and `/js/` keep a stable name while their contents change; marking one immutable would pin a stale copy in every visitor's browser for a year.

Everything else, mainly the HTML, revalidates on every request, and ETags carry that. The server hashes each embedded file on first request and keeps the hash in memory; embedded content cannot change until the binary restarts, so it never hashes twice.

```go
var etags sync.Map // path -> hash, never evicted

func getFileHash(path string, r io.Reader) (string, error) {
    if v, ok := etags.Load(path); ok {
        return v.(string), nil
    }
    h := sha256.New()
    if _, err := io.Copy(h, r); err != nil {
        return "", err
    }
    etag := fmt.Sprintf("%x", h.Sum(nil))
    etags.Store(path, etag)
    return etag, nil
}
```

The hash goes out as the ETag through the standard library's `http.ServeContent`. When the browser sends `If-None-Match`, `ServeContent` answers 304 with no body, so revalidating an unchanged page costs a round trip rather than a transfer; after a release, only the pages that changed get a new hash.

The cost that does grow is the binary. Everything you embed travels with every release and sits in the running process. Ours holds the full site and the release tarball is typically between 65 and 85 MB, which is comfortable. A site heavy on images would look different; keep those in a dedicated digital asset manager and let the pages link out, or the binary grows with every photo.

---

I expected Hugo to be a site generator and nothing more. It proved to be a good build step for a dynamic application, if you are willing to write the thin server that owns the front door. Static content and a dynamic gate combine well, and the gate is small enough to read in an afternoon.

If you are wondering where all that internal documentation came from, [it started as a folder of Markdown files](/blog/markdown-knowledge-base-for-ai-development/) that AI agents filled as we worked.
