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:
- 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_redirectssplat rule per alias (/l/foo/*). The function normalizes variant request forms so the splat rule matches:/l/foogets a trailing slash,/l/foo.mdbecomes/l/foo/index.md. Requests already in/l/foo/or/l/foo/index.mdform pass through unchanged. - If
Acceptdoes not includetext/markdown→ pass through unchanged (HTML response served as before). - 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
- 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.