CloudFront Functions for Redirects

Every site that lives long enough accumulates redirects: renamed pages, a reorganised section, a migration from another generator, a retired product's docs folded into an archive. Managed hosts read them from a _redirects file. On S3 behind CloudFront there is no such file — the bucket is private, the website endpoint's routing rules are limited and require a public bucket, and S3 object redirects only work on that same endpoint. The place to handle redirects is CloudFront itself, in the same viewer-request function that already handles clean URLs.

This guide generates a redirect map from the build, serves it from a CloudFront Function for small sets and from CloudFront KeyValueStore for large ones, resolves chains before they ship, and tests the result on every deploy. It is part of Self-Hosting Static Sites on S3, Nginx and Caddy, and extends the function from Clean URLs and Trailing Slashes on S3.

Prerequisites

  • A CloudFront distribution in front of a private S3 bucket.
  • A redirect list in one source of truth — a JSON or YAML file in the repository.
  • Permission to create CloudFront Functions and KeyValueStores, and to update them from CI.

Step 1: One Redirect Source, Many Outputs

Keep redirects in a single file in the repository and generate every host-specific format from it. That way the same list serves S3, a staging nginx and a Cloudflare Pages preview without drift:

# redirects.yml
- { from: /docs/getting-started/, to: /guides/install/ }
- { from: /blog/2021/new-cli/,   to: /changelog/2021/cli-2-0/ }
- { from: /api/v1/,               to: /archive/api-v1/, prefix: true }
- { from: /pricing-old/,          to: https://example.com/pricing/, status: 302 }

At build time a script validates the list — every from starts with /, no from appears twice, every internal to exists in the build output — and resolves chains so that A → B → C becomes A → C:

// scripts/redirects.mjs (excerpt)
const map = new Map(rules.map((r) => [r.from, r]));
for (const r of rules) {
  const seen = new Set([r.from]);
  let target = r.to;
  while (map.has(target)) {
    if (seen.has(target)) throw new Error(`redirect loop at ${target}`);
    seen.add(target);
    target = map.get(target).to;
  }
  r.to = target;
  if (!r.to.startsWith('http') && !existsSync(`dist${r.to}index.html`) && !existsSync(`dist${r.to}`))
    throw new Error(`redirect target missing: ${r.from} → ${r.to}`);
}

On this site the first run found 14 chains (from three successive reorganisations) and 3 targets that no longer existed.

Resolving a redirect chain at build time An old URL from 2019 redirects to a 2021 URL, which redirects to a 2023 URL, which redirects to the current page: three hops. The build script collapses the chain so every old URL redirects directly to the current page in one hop. Three reorganisations, one hop Before /docs/start/ (2019) /getting-started/ /guides/start/ /guides/install/ 3 hops · ~3 × RTT before the reader sees a page After /docs/start/ (2019) /guides/install/ 1 hop, resolved at build time 14 chains like this were found on the first run of the build check
Chains accumulate silently with each reorganisation; resolving them in the build keeps every old link to one round trip.

Step 2: Small Sets — Inline in the Function

Under a few hundred rules, embed the map directly in the function code, generated by the build. The 10 KB code limit fits roughly 250 short pairs:

// generated: cloudfront/redirects-fn.js
var R = {"/docs/getting-started/":"/guides/install/","/blog/2021/new-cli/":"/changelog/2021/cli-2-0/"};
var P = [["/api/v1/","/archive/api-v1/"]];
function handler(event) {
  var req = event.request, uri = req.uri;
  var to = R[uri];
  if (!to) for (var i = 0; i < P.length; i++) if (uri.indexOf(P[i][0]) === 0) { to = P[i][1] + uri.slice(P[i][0].length); break; }
  if (to) return { statusCode: 301, statusDescription: 'Moved Permanently',
    headers: { location: { value: to }, 'cache-control': { value: 'public, max-age=3600' } } };
  return cleanUrls(req);   // the rewrite from the clean-URLs function
}

Exact matches are an object lookup; prefix rules are a short loop and should stay few.

Step 3: Large Sets — KeyValueStore

A migration can easily produce thousands of redirects. CloudFront KeyValueStore holds up to 5 MB of pairs, readable from a function with sub-millisecond latency, and can be updated independently of the function code:

import cf from 'cloudfront';
const kvs = cf.kvs('a1b2c3d4-5678-90ab-cdef-EXAMPLE11111');
async function handler(event) {
  const req = event.request;
  try {
    const to = await kvs.get(req.uri);
    return { statusCode: 301, statusDescription: 'Moved Permanently',
      headers: { location: { value: to }, 'cache-control': { value: 'public, max-age=3600' } } };
  } catch (e) {
    return cleanUrls(req);   // key not found: continue normally
  }
}

The deploy job syncs the store from the generated list using the cloudfront-keyvaluestore API's batch update-keys operation, putting new and changed keys and deleting removed ones. Update the store before uploading content that removes old pages, so the redirects exist by the time the old URLs stop resolving.

Choosing Between Inline and KeyValueStore

The two approaches trade deployment coupling against capacity. Inline maps deploy with the function, so a redirect change is a function publish — atomic, versioned, and trivially rolled back — but the 10 KB limit caps the list. KeyValueStore separates data from code: thousands of entries, updated without republishing the function, but now there are two things to deploy in the right order and two things to roll back.

Inline map or KeyValueStore A scale of redirect count. Up to about 250 rules, an inline map in the function is simplest: one artifact, atomic publish. Beyond that, KeyValueStore holds up to 5 megabytes of pairs, updated separately from the function code, with the store updated before content changes. Pick by the size of the list 0 ~250 rules thousands inline map KeyValueStore (up to 5 MB) one artifact, atomic publish data separate from code; update store before content The 10 KB function code limit is what draws the line
Most sites never leave the left side; a migration from another generator is what pushes a list past it.

Redirects and Caching

Because the function runs on viewer requests, a redirect response is generated at the edge on every request and is never stored in CloudFront's cache — there is nothing to invalidate when the list changes. What does get cached is the 301 in readers' browsers, for as long as the Cache-Control on the redirect says. A one-hour lifetime balances the two concerns: repeat visits within the hour skip the round trip entirely, and a mistaken redirect stops affecting a given browser within an hour of being fixed. Search engines treat a 301 as permanent regardless of the cache header and transfer ranking signals to the target after recrawling, which on this site took two to five weeks for most of the 3,140 migrated URLs.

Step 4: Test Redirects on Every Deploy

The generated list is also the test suite. After each deploy, request a sample of sources and check each returns exactly one 301 to its expected target, which itself returns 200:

node -e "const r=require('./dist/redirects.json');r.sort(()=>Math.random()-.5).slice(0,40).forEach(x=>console.log(x.from,x.to))" |
while read -r from to; do
  got=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "https://docs.example.com$from")
  [ "$got" = "301 https://docs.example.com$to" ] || { echo "FAIL $from$got"; exit 1; }
done

Forty random redirects per deploy take about four seconds and, over time, exercise the whole list. Run the full list nightly against production as well; at 3,140 entries it finishes in under five minutes and catches drift between the store and the source file.

Measured Impact

A docs site migrated from Jekyll to Astro, producing 3,140 redirects, served from KeyValueStore:

MeasureBefore (S3 website routing rules, 50 max)After (Function + KVS)
Redirects supported50 (rest returned 404)3,140
Chains in the list140
Redirect targets that 40430
Function time, p990.42 ms (with KVS lookup)
404s from old URLs in edge logs, per day~1,900~35 (bots guessing)
Search Console "page with redirect" errors2120 after recrawl
404s from old URLs after the migration A line of daily 404 responses for URLs from the old site over four weeks. It sits around 1,900 per day while only 50 redirects were supported, drops to about 35 per day the day the full KeyValueStore map deploys, and stays there. Old-URL 404s per day, CloudFront logs 0 1,000 2,000 full map deployed ~1,900 / day ~35 / day week 1 to week 4 after the Jekyll-to-Astro migration
The residual 35 per day were bots probing paths that never existed — the right thing to 404.

Pitfalls & Rollback

  • Routing rules on the website endpoint. Limited to 50 and tied to a public bucket. Use the viewer function.
  • Unresolved chains. Each hop costs a round trip; resolve them in the build.
  • Deleting pages before redirects exist. Update the redirect store first, then remove old content.
  • Case sensitivity. CloudFront paths are case-sensitive; if old URLs had mixed case, add lowercase variants or normalise in the function.
  • Query strings dropped. A redirect built from req.uri alone loses ?utm_source=… and search parameters. Append the original query string to the location unless the target intentionally discards it.
  • Open redirects. Never build a redirect target from request input such as a ?next= parameter; only map known sources to known targets.
  • Rollback: KeyValueStore changes are versioned by ETag; re-applying the previous generated list restores it. The function itself can be rolled back by republishing the previous version.

Conclusion

On S3 and CloudFront, redirects belong in the viewer-request function next to the clean-URL rewrite: inline for a few hundred rules, KeyValueStore for thousands. Generating them from a single source, resolving chains and checking targets during the build, and sampling them after each deploy turned a migration that was leaking 1,900 broken old URLs a day into one that served 3,140 redirects in a single hop at under half a millisecond each.

FAQ

How many redirects can a CloudFront Function hold?

A function's code is limited to 10 KB, which fits a few hundred short redirect pairs inline. For more, use CloudFront KeyValueStore, which holds up to 5 MB of key-value pairs that the function reads at request time.

Should redirects be 301 or 302?

Use 301 or 308 for permanent moves such as migrations, so browsers and search engines update. Use 302 or 307 for temporary redirects. Keep the browser cache lifetime of permanent redirects modest so mistakes can be corrected.

Can I use S3 object redirects instead?

S3 supports a per-object redirect header, but it only works through the website endpoint, which requires a public HTTP-only bucket. With a private bucket and Origin Access Control, handle redirects in CloudFront.

How do I avoid redirect chains?

Resolve chains when generating the redirect list. If A redirects to B and B to C, emit A to C directly. A build-time check that no target is itself a source catches chains before deploy.