web / cdn

I front my web apps with a CDN. The CDN serves static assets from edge locations close to users. The origin serves application logic.

Architecture

A CDN pulls content from its origin server during HTTP requests:

DNS -> CDN -> Origin

Example:

Cloudflare DNS -> Cloudflare CDN -> Caddy on a VM

Without a CDN

Pointing a DNS record directly to an application server routes every static asset request through application workers:

200 GET /css/app.css
200 GET /js/app.js

Serving static files from application workers increases latency and consumes application capacity.

With a CDN

The first request for an asset misses the cache. The CDN fetches it from the origin, saves the response at the edge, and returns it:

200 GET /css/app-a1b2c3d4.css

A later GET or HEAD for that URL returns from the edge. Every other method proxies straight to the origin.

Cache invalidation

I set a one-year cache duration and embed a content hash in the file name:

/assets/app-a1b2c3d4.css

A changed file gets a new URL, which misses the cache. Old URLs stay in cache and no client requests them.

Cache-Control header:

Cache-Control: public, max-age=31536000, immutable

The immutable directive eliminates revalidation requests even on page reload.

Compression

The CDN negotiates compression with the client: gzip, Brotli, or zstd. The origin serves uncompressed bytes.

I do not add gzip middleware to the app. A gzip response from the origin makes the CDN skip Brotli, which compresses text better.

TLS to the origin

The CDN terminates TLS at the edge. The hop from the edge to the origin is a second connection, and the CDN offers a mode for it.

I choose the strict mode. The CDN opens TLS to the origin and validates the origin certificate. The origin holds a real certificate for that reason.

The permissive mode sends that hop in plaintext. It accepts an origin that serves port 80 only. The client sees the padlock either way, so the weaker hop leaves no trace in the browser.

Errors

A CDN caches on the Cache-Control header, not the status code. A 404 with a one-year header caches for a year.

During a rolling deploy, a request for a new asset can hit an old server that lacks the file. The edge then serves that 404 for a year.

The origin sets long cache headers on 200 responses only, and Cache-Control: no-store on errors.

Build-time fingerprinting

When a project bundles TypeScript, esbuild fingerprints as part of the build. esbuild has a Go API, so the project needs no js runtime:

result := api.Build(api.BuildOptions{
	EntryPoints:  []string{"js/app.ts", "css/app.css"},
	Bundle:       true,
	MinifySyntax: true,
	Outdir:       "public",
	EntryNames:   "[dir]/[name]-[hash]",
	Write:        true,
})
if len(result.Errors) > 0 {
	os.Exit(1)
}

The output is public/css/app-2H67SL6V.css. The web server detects the hashed filename at startup. A build script runs esbuild before starting the server:

./bin/build-js

See cmd/blog for a static site generator example.

In-process fingerprinting

For a Go-only server, I hash files at startup and serve assets at fingerprinted URLs. This fits handwritten CSS, fonts, and WASM that nothing bundles, and a single-binary deploy with no build step. Once there is TypeScript to bundle, I use the esbuild Go API.

type Server struct {
    cssPath    string            // Fingerprinted CSS path
    imgPaths   map[string]string // Original -> fingerprinted
    cssContent []byte            // CSS with rewritten URLs
}

func fileDigest(path string) (string, error) {
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }
    defer f.Close()

    h := md5.New()
    if _, err := io.Copy(h, f); err != nil {
        return "", err
    }
    return fmt.Sprintf("%x", h.Sum(nil)), nil
}

func NewServer() *Server {
    s := &Server{imgPaths: make(map[string]string)}

    // Fingerprint images
    imgFiles, _ := filepath.Glob("ui/img/*")
    for _, file := range imgFiles {
        name := filepath.Base(file)
        ext := filepath.Ext(name)
        base := name[:len(name)-len(ext)]
        if hash, err := fileDigest(file); err == nil {
            s.imgPaths[name] = fmt.Sprintf("%s-%s%s", base, hash[:8], ext)
        }
    }

    // Process CSS: rewrite asset URLs to fingerprinted versions
    cssBytes, err := os.ReadFile("ui/app.css")
    if err != nil {
        log.Fatal(err)
    }
    cssContent := string(cssBytes)
    for orig, fp := range s.imgPaths {
        cssContent = strings.ReplaceAll(cssContent, "img/"+orig, "img/"+fp)
    }
    s.cssContent = []byte(cssContent)

    // Fingerprint CSS from processed content
    h := md5.New()
    h.Write(s.cssContent)
    s.cssPath = fmt.Sprintf("/ui/app-%s.css", fmt.Sprintf("%x", h.Sum(nil))[:8])

    return s
}

The server computes MD5 hashes for static files at startup, updates asset URLs inside stylesheets, and serves the hashed path with a one-year cache header:

mux.HandleFunc("GET "+s.cssPath, func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/css")
    w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
    w.Write(s.cssContent)
})

Templates reference the fingerprinted paths. See html templates to embed assets in the Go binary.

← All articles