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.