Docs Content Negotiation

CloudFront distribution with HTTP content negotiation for the Hummingbird public documentation site (hummingbird-project.io). When a client sends Accept: text/markdown, a CloudFront Function rewrites the request URI to the pre-built .md equivalent so GitLab Pages serves Hugo’s markdown output instead of HTML.

Overview

Hugo generates .md files alongside .html for every page. GitLab Pages is a static file server — it cannot inspect Accept headers and select a response type. This stack interposes a CloudFront distribution between DNS and GitLab Pages, adding the header-based routing that GitLab Pages cannot provide.

The stack is deployed by the infrastructure repository. After each deploy, Route53 A and AAAA alias records for hummingbird-project.io are updated to point at the CloudFront distribution.

Architecture

flowchart LR
    client["Client\n(browser / agent)"]
    dns["Route53\nhummingbird-project.io"]
    cf["CloudFront\nDistribution"]
    fn_req["CloudFront Function\n(viewer-request)"]
    fn_resp["CloudFront Function\n(viewer-response)"]
    pages["GitLab Pages\nredhat.gitlab.io"]

    client -->|"HTTPS"| dns
    dns -->|"A/AAAA alias"| cf
    cf --> fn_req
    fn_req -->|"rewrite URI if\nAccept: text/markdown"| cf
    cf -->|"forwards viewer Host header"| pages
    pages --> fn_resp
    fn_resp -->|"auth redirects → 404"| client

Content Negotiation Logic

The cloudfront-js-2.0 viewer-request function runs at every edge location before the cache is consulted:

  1. If the URI starts with /l/ → normalize and pass through (regardless of headers). These are stable alias paths that GitLab Pages handles via a single _redirects splat rule per alias (/l/foo/*). The function normalizes variant request forms so the splat rule matches: /l/foo gets a trailing slash, /l/foo.md becomes /l/foo/index.md. Requests already in /l/foo/ or /l/foo/index.md form pass through unchanged.
  2. If Accept does not include text/markdown → pass through unchanged (HTML response served as before).
  3. Rewrite only known HTML-like patterns:
    • /docs/ (trailing slash) → /docs/index.md
    • /docs/page.html → /docs/page.md
    • /docs/page (no extension) → /docs/page/index.md
  4. Everything else (.css, .js, .png, .md, .txt, etc.) → pass through unchanged.

Because the rewrite happens before the cache key is computed (viewer-request event), HTML and markdown responses land in separate cache entries. No custom cache policy is needed — the standard CachingOptimized managed policy is used.

Auth-Redirect Guard

A second CloudFront Function (AuthRedirectGuardFunction) runs on viewer-response. It detects 302 responses whose Location header points to projects.gitlab.io/auth — the GitLab Pages access-control gate — and converts them into a 404 Not Found before they reach the viewer.

GitLab Pages access control causes the origin to return a 302 auth redirect for nonexistent paths instead of a proper 404. CloudFront caches origin responses per Accept-Encoding variant (gzip vs brotli), so these redirects can persist for up to 24 hours. Without the guard, clients follow a redirect chain to gitlab.com/users/sign_in and land on a Cloudflare challenge page.

Origin

The origin is redhat.gitlab.io (the GitLab Pages default domain). GitLab Pages routes requests based on the Host header. The AllViewer origin request policy forwards the viewer’s Host: hummingbird-project.io header to the origin, so the correct project is served regardless of what DNS hummingbird-project.io resolves to.

Components

Resource Type Description
ContentNegotiationFunction CloudFront Function viewer-request; rewrites URI for Accept: text/markdown
AuthRedirectGuardFunction CloudFront Function viewer-response; converts auth redirects to 404
DocsCertificate ACM Certificate TLS for the custom domain; DNS-validated via Route53
DocsDistribution CloudFront Distribution CDN proxy in front of GitLab Pages

Parameters

Parameter Description
ResourcePrefix Prefix for resource names (e.g., arr-hummingbird-prod-docs-cdn)
DocsDomainName Custom domain for the documentation site (e.g., hummingbird-project.io)
HostedZoneId Route53 hosted zone ID — used for ACM DNS validation CNAME auto-creation

Outputs

Output Description
DistributionDomainName CloudFront domain name
DistributionId CloudFront distribution ID

Deployment

Deployed by the infrastructure repository. Stack name and region are configured in samconfig.toml.j2 in the infrastructure repo.

On the first deploy, CloudFormation requests an ACM certificate and auto-creates the DNS validation CNAME in Route53 (via HostedZoneId). Once validated (~2–5 minutes), the distribution is created and Route53 alias records are updated to point at it.

Verification

# Content negotiation: should return text/markdown
curl -H "Accept: text/markdown" https://hummingbird-project.io/docs/ -sI

# Normal browsing: should return text/html (unchanged)
curl https://hummingbird-project.io/docs/ -sI

# /l/ alias paths are not rewritten — GitLab Pages issues HTTP 301 instead
curl -H "Accept: text/markdown" https://hummingbird-project.io/l/adding-images -sI

# Nonexistent paths return 404 (auth-redirect guard converts the origin's 302)
curl https://hummingbird-project.io/this-does-not-exist -sI

# Static assets are excluded from rewriting
curl -H "Accept: text/markdown" https://hummingbird-project.io/favicon.ico -sI

# Alternate domains still redirect to primary (unchanged)
curl https://project-hummingbird.io/ -sI   # expect 308
curl https://hummingbird-project.org/ -sI  # expect 308

# DNS resolves to CloudFront, not the GitLab Pages IP
dig hummingbird-project.io A +short

Scope

Only the primary domain (hummingbird-project.io) is proxied through CloudFront. The alternate domains (project-hummingbird.io, hummingbird-project.org) continue pointing directly to GitLab Pages, which issues 308 redirects to the primary domain.