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

If a CNAME record points directly to an app server:

www.example.com -> app.onrender.com

Every static asset request occupies a worker process, which reads the file from disk:

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

Those workers queue, and application response times degrade.

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

A CDN that serves the request 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, and it shows the padlock to the client 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 same options in the JavaScript API:

import * as esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["js/app.ts", "css/app.css"],
  bundle: true,
  minify: true,
  outdir: "public",
  entryNames: "[dir]/[name]-[hash]",
});

The output is public/css/app-2H67SL6V.css. The web server finds the hashed name at startup so templates can link to it. Ruby example:

app_css_path = Dir.glob("public/css/app*.css").first&.split("public")&.last

The build runs before the web server starts. A Render build command for the Go version, where bin/build-js runs the program above:

./bin/build-js

See cmd/blog for another build-time 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. Dev mode skips fingerprinting for live reload (see env).

type Server struct {
    env        Env
    cssPath    string            // Fingerprinted CSS path
    imgPaths   map[string]string // Original -> fingerprinted
    fontPaths  map[string]string // Original -> fingerprinted
    cssContent []byte            // Processed 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(env Env) *Server {
    s := &Server{
        env:       env,
        imgPaths:  make(map[string]string),
        fontPaths: make(map[string]string),
    }

    // In dev mode, skip fingerprinting, load as-is
    if s.env.Dev() {
        s.cssPath = "/ui/app.css"
        return s
    }

    // 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)
        }
    }

    // Fingerprint fonts
    fontFiles, _ := filepath.Glob("ui/font/*")
    for _, file := range fontFiles {
        name := filepath.Base(file)
        ext := filepath.Ext(name)
        base := name[:len(name)-len(ext)]
        if hash, err := fileDigest(file); err == nil {
            s.fontPaths[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)
    }
    for orig, fp := range s.fontPaths {
        cssContent = strings.ReplaceAll(cssContent, "font/"+orig, "font/"+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
}

A binary asset hashes directly. For CSS url() or JavaScript that loads WASM, the server rewrites the URLs in memory first, then hashes the result.

Production serves the hashed content with a one-year header. Development serves from disk with no-cache:

func (s *Server) Handler() http.Handler {
    mux := http.NewServeMux()

    if s.env.Dev() {
        mux.HandleFunc("GET /ui/app.css", func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Type", "text/css")
            w.Header().Set("Cache-Control", "no-cache")
            http.ServeFile(w, r, "./ui/app.css")
        })
    } else {
        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)
        })
    }

    mux.HandleFunc("GET /", s.index)
    return mux
}

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

← All articles