All cloud projects
LIVE global (edge) 2026
CLOUD PROJECT · 2026

Edge link shortener at go.andruxa.dev

Short links for the job hunt — go.andruxa.dev/gh, /li, /imdb — resolved entirely at the CDN edge. A CloudFront Function reads a CloudFront KeyValueStore and returns the 302 itself: no Lambda, no database, no origin request, sub-millisecond. Everyone builds the Lambda + DynamoDB shortener; this one runs on the cheapest compute AWS sells and costs nothing at all.

CloudFront FunctionsCloudFront KeyValueStoreACMRoute 53GitHub ActionsTerraform

The problem

A job hunt generates links. A resume PDF, a LinkedIn profile, an IMDb page, a repo, this site’s own case studies — all long, all ugly to type, none of them retargetable once printed. I wanted go.andruxa.dev/gh and go.andruxa.dev/li: short, memorable, and mine.

The interesting part isn’t the redirect. It’s where the redirect happens.

The architecture

The textbook serverless shortener is API Gateway → Lambda → DynamoDB. It works, it’s what most portfolios show, and for this problem it is three services more than the job needs. A redirect is a lookup and a header — there is nothing in it that requires a runtime, a VPC, or a database.

So this one runs entirely at the edge:

There is no origin fetch. The distribution declares an origin because CloudFront requires one, but nothing ever reaches it: a viewer-request function that returns a response short-circuits the whole distribution, and a function that errors returns a CloudFront error rather than falling through. Every response the viewer sees is generated in the POP nearest to them.

Link data lives in links/links.json in the repo. A GitHub Actions job syncs that file into the KeyValueStore on every push to main, so adding a short link is a pull request — not a console click, and not a terraform apply.

Key decisions & trade-offs

302, not 301. Almost every shortener tutorial reaches for 301 Moved Permanently. That is exactly wrong for a link printed on a resume: browsers cache a 301 indefinitely, so the first person to click /cv pins that destination in their browser forever, and I can never retarget it. A 302 with Cache-Control: max-age=300 keeps the link mine — a retarget lands within five minutes, and repeat clicks still don’t re-resolve.

Security headers live in the function, not a response-headers policy. The usual place for HSTS and X-Content-Type-Options is a CloudFront response headers policy. But when the response is generated by a viewer-request function, whether that policy applies is ambiguous — and a security header you have to reason about is a security header you don’t have. Setting them inside the function is the one placement guaranteed to apply, because the function is the only thing that ever answers. Every 302 carries HSTS, nosniff, X-Frame-Options: DENY and a referrer policy.

The store is validated on both sides. The KeyValueStore is only ever written by CI, but an unvalidated redirect target is an open redirect waiting to happen — a single bad value would turn my domain into someone’s phishing hop. So the sync script rejects any target that isn’t https:// before it writes, and the edge function re-checks the scheme before it redirects. Belt and braces, because the failure mode is reputational.

Its own certificate, not a SAN on the site cert. Adding go.andruxa.dev to the existing certificate would have re-issued the certificate that the live apex, www and demos distributions are all serving. A new subdomain is not worth touching production TLS. It gets its own ACM certificate in us-east-1 — the same call I made for place.andruxa.dev.

The store is eventually consistent, and the pipeline has to admit it. Retargeting a link during the build made this concrete: for about a minute after the write, different points of presence served the old value and the new one. That is the correct behaviour for a store replicated to every edge on earth, but it means a deploy job that asserts the new target the instant the write returns will go red for a system that is working. The verification step retries for two minutes before failing — the honest fix, rather than dropping the check that makes the pipeline worth having.

No click counter in the function — a design constraint, not an omission. CloudFront Functions have read-only access to a KeyValueStore. There is no write path from the edge, by design: a store that any POP could write would have to reconcile across every edge location on earth. So per-link click counts can’t come from the function; they have to come from CloudFront access logs, aggregated off the request path.

That’s exactly what the analytics pipeline now does — clicks per short code appear on /traffic/, counted from the CDN’s own logs. Worth noting the sequence: this limitation was written down here as a limitation first, and the fix came later as a separate project. The constraint was real, and naming it beat inventing a number.

What it costs

ComponentMonthly cost
CloudFront Functions ($0.10 per million, first 2M free)$0.00
CloudFront KeyValueStore$0.00 — bundled with Functions
ACM certificate$0.00
Route 53 records in the existing zone$0.00
CloudFront requests + data transfer (a 302 is ~300 bytes)rounds to $0.00
Total$0.00/mo

That is not a rounding trick. At portfolio traffic this genuinely bills nothing, and it would still bill nothing at a hundred times the traffic. Compare the Lambda version: request charges, GB-second charges, a DynamoDB table, an API Gateway, and a cold start on the first click after an idle hour — for a lookup that fits in a hash map.

How it’s tested

The edge function is not a file I get to hand-wave. The test suite loads the real index.js that Terraform ships to CloudFront, stubs out the KeyValueStore handle, and drives the actual handler — so a change to the deployed artifact fails the build, rather than a re-implementation drifting quietly away from it. Fifteen cases cover the open-redirect guard, path normalization (/GH/gh), query-string passthrough including repeated parameters, malformed percent-encoding, oversized keys, and the presence of every security header on every response path.

The sync script’s diff is tested separately, for a blunt reason: links.json is the source of truth, so anything absent from it gets deleted from the store. A wrong diff doesn’t produce a bad link — it silently deletes live ones.

What I’d do differently at scale

The design’s ceiling is real and worth naming. A KeyValueStore caps at 5 MB total with a 1 KB value limit, so this holds thousands of links, not millions. Writes are ETag-guarded and go through a single control-plane API, which is perfect for a CI job and wrong for user-generated links at volume.

If this had to serve a real product — user-created links, click analytics, expiry — the shape would change: DynamoDB as the system of record, the KeyValueStore demoted to a cache of the hot set, and a Lambda@Edge or origin Lambda on the miss path. Analytics would come from CloudFront standard logs into S3 and an Athena rollup, never from the request path. What I’d keep is the core insight: the common case is a lookup and a header, and it belongs in the POP, not in a region.