Point www to CloudFront With a Route 53 Alias Record

Glowing lines of light connecting points across a dark digital globe

Photo by NASA on Unsplash

A Route 53 alias record for CloudFront on www is an A record (plus AAAA if IPv6 is on) named www.example.com whose alias target is your distribution’s *.cloudfront.net domain with hosted zone ID Z2FDTNDATAQYW2. The distribution must list www.example.com as an alternate domain name, using an ACM certificate from us-east-1 that covers it.

One thing to get straight first: an alias record only makes www.example.com resolve to CloudFront. It doesn’t redirect anyone. Visitors who type www get the same site on the www address, with a 200 response. If you want them moved to example.com with a 301, you also need something that answers HTTP requests with a redirect, and this example covers both parts.

You’ll get a dry-run-first TypeScript script for the AWS SDK for JavaScript v3 that checks the distribution and certificate before it touches DNS, then a short CloudFront Function that turns www requests into redirects. It’s one of the AWS SDK v3 practical examples, and the sibling guide to troubleshoot a Route 53 domain that isn’t serving CloudFront picks up if something still fails afterwards.

Does a Route 53 alias record redirect www to the base domain?

No. DNS answers “which IP addresses serve this name”; it can’t send an HTTP status code or a Location header. An alias record is Route 53’s way of answering with CloudFront’s current IP addresses, and unlike a CNAME it also works at the zone apex (example.com) and costs nothing to query when it points at CloudFront.

A redirect is an HTTP response, 301 or 308, sent by whatever serves the request. MDN’s guide to HTTP redirections explains the status codes and how browsers and search engines treat them. With CloudFront, the usual options are:

  • A CloudFront Function on the viewer request of a distribution that serves both names. It returns a 301 for any request whose Host starts with www.. This is the simplest option, shown below.
  • An S3 redirect bucket named www.example.com, configured as a static website that redirects all requests to example.com. S3 website endpoints don’t serve HTTPS, so you still put a second CloudFront distribution with its own certificate in front, and the www alias points at that distribution instead.

Either way, www needs an alias record pointing at a distribution that lists www.example.com. That’s the part the script automates.

What does the script do?

  1. Check the distributionGetDistribution returns the distribution’s domain name, status and alternate domain names. If www.example.com isn’t among the aliases, CloudFront would reject requests for it, so the script stops.
  2. Check the certificateThe viewer certificate must be an ACM certificate in us-east-1, issued, and covering www.example.com directly or through *.example.com. DescribeCertificate gives the status and names.
  3. Find the public hosted zoneListHostedZonesByName finds the public zone for the apex domain, skipping private zones with the same name.
  4. Look for conflictsListResourceRecordSets shows what already exists at www. A CNAME there blocks alias A records with the same name, so the script reports it instead of failing mid-change.
  5. Plan, or applyIt prints UPSERT changes for an A record, plus AAAA when the distribution has IPv6 enabled. With --apply it calls ChangeResourceRecordSets and waits with waitUntilResourceRecordSetsChanged until the change is INSYNC.

Prerequisites

  • A public hosted zone for your domain in Route 53, with the registrar delegated to its name servers. If several zones share the name, the script to find unused Route 53 hosted zones tells you which one is delegated.
  • A CloudFront distribution with example.com and www.example.com as alternate domain names, and an ACM certificate requested in us-east-1 that covers both. CloudFront only accepts ACM certificates from us-east-1. Once it’s attached, check the CloudFront minimum TLS version so the distribution doesn’t still accept TLS 1.0.
  • Node.js 18 or later, npm, tsx, and @aws-sdk/client-route-53, @aws-sdk/client-cloudfront and @aws-sdk/client-acm.

Which IAM permissions does it need?

This policy can only create or update A and AAAA records named www.example.com in one hosted zone. Route 53 condition keys expect the record name in lowercase, without the trailing dot. Replace the zone ID, distribution ID and account ID with yours.

www-alias-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "FindZone",
      "Effect": "Allow",
      "Action": "route53:ListHostedZonesByName",
      "Resource": "*"
    },
    {
      "Sid": "ReadZoneRecords",
      "Effect": "Allow",
      "Action": "route53:ListResourceRecordSets",
      "Resource": "arn:aws:route53:::hostedzone/Z0123456789EXAMPLE"
    },
    {
      "Sid": "UpsertWwwAliasOnly",
      "Effect": "Allow",
      "Action": "route53:ChangeResourceRecordSets",
      "Resource": "arn:aws:route53:::hostedzone/Z0123456789EXAMPLE",
      "Condition": {
        "ForAllValues:StringEquals": {
          "route53:ChangeResourceRecordSetsNormalizedRecordNames": ["www.example.com"],
          "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA"],
          "route53:ChangeResourceRecordSetsActions": ["UPSERT"]
        }
      }
    },
    {
      "Sid": "WaitForChange",
      "Effect": "Allow",
      "Action": "route53:GetChange",
      "Resource": "arn:aws:route53:::change/*"
    },
    {
      "Sid": "ReadDistributionAndCertificate",
      "Effect": "Allow",
      "Action": [
        "cloudfront:GetDistribution",
        "acm:DescribeCertificate"
      ],
      "Resource": [
        "arn:aws:cloudfront::123456789012:distribution/E1ABCDEF2GHIJK",
        "arn:aws:acm:us-east-1:123456789012:certificate/*"
      ]
    }
  ]
}

For the dry run, drop the UpsertWwwAliasOnly and WaitForChange statements. The free IAM policy generator for TypeScript AWS code gives you a starting point for variants, and the guide to review an IAM policy for least privilege shows how to tighten it.

The script: Route 53 alias record for CloudFront on www

www-alias-to-cloudfront.ts

// www-alias-to-cloudfront.ts
// Creates (UPSERTs) Route 53 alias records for www.<domain> that point at a CloudFront
// distribution: an A record, plus AAAA when the distribution has IPv6 enabled.
// Checks the distribution and its ACM certificate first. Dry run by default; pass --apply to change DNS.
// Usage: npx tsx www-alias-to-cloudfront.ts example.com E1ABCDEF2GHIJK [--apply]
import {
  Route53Client,
  ListHostedZonesByNameCommand,
  ListResourceRecordSetsCommand,
  ChangeResourceRecordSetsCommand,
  waitUntilResourceRecordSetsChanged,
  type Change,
} from "@aws-sdk/client-route-53";
import { CloudFrontClient, GetDistributionCommand } from "@aws-sdk/client-cloudfront";
import { ACMClient, DescribeCertificateCommand } from "@aws-sdk/client-acm";

const [apexArg, distributionId] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const APPLY = process.argv.includes("--apply");
if (!apexArg || !distributionId) throw new Error("Usage: www-alias-to-cloudfront.ts <apex-domain> <distribution-id> [--apply]");
const apex = apexArg.toLowerCase().replace(/\.$/, "");
const www = `www.${apex}`;

// Fixed hosted zone ID that every CloudFront alias target uses.
const CLOUDFRONT_ZONE_ID = "Z2FDTNDATAQYW2";

// Route 53 and CloudFront are global; ACM certificates for CloudFront live in us-east-1.
const route53 = new Route53Client({ region: "us-east-1" });
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const acm = new ACMClient({ region: "us-east-1" });

const covers = (pattern: string, name: string): boolean =>
  pattern === name || (pattern.startsWith("*.") && name.split(".").slice(1).join(".") === pattern.slice(2));

async function main(): Promise<void> {
  const problems: string[] = [];

  // 1. The distribution must list www as an alternate domain name (CNAME).
  const { Distribution: dist } = await cloudfront.send(new GetDistributionCommand({ Id: distributionId }));
  const config = dist?.DistributionConfig;
  if (!dist?.DomainName || !config) throw new Error(`Distribution ${distributionId} not found`);
  const aliases = config.Aliases?.Items ?? [];
  console.log(`Distribution ${distributionId}: ${dist.DomainName}, status ${dist.Status}, aliases [${aliases.join(", ")}]`);
  if (!aliases.map((a) => a.toLowerCase()).includes(www)) problems.push(`${www} is not in the distribution's alternate domain names`);

  // 2. Its certificate must be an ACM cert in us-east-1 that covers www.
  const certArn = config.ViewerCertificate?.ACMCertificateArn;
  if (!certArn) {
    problems.push("the distribution has no ACM certificate (default *.cloudfront.net cert or IAM cert)");
  } else if (!certArn.startsWith("arn:aws:acm:us-east-1:")) {
    problems.push(`certificate ${certArn} is not in us-east-1`);
  } else {
    const { Certificate: cert } = await acm.send(new DescribeCertificateCommand({ CertificateArn: certArn }));
    const names = cert?.SubjectAlternativeNames ?? [];
    console.log(`Certificate: ${cert?.Status}, covers [${names.join(", ")}], expires ${cert?.NotAfter?.toISOString()}`);
    if (cert?.Status !== "ISSUED") problems.push(`certificate status is ${cert?.Status}`);
    if (!names.some((n) => covers(n.toLowerCase(), www))) problems.push(`certificate doesn't cover ${www}`);
  }

  // 3. The public hosted zone for the apex domain.
  const zones = await route53.send(new ListHostedZonesByNameCommand({ DNSName: apex }));
  const zone = zones.HostedZones?.find((z) => z.Name === `${apex}.` && !z.Config?.PrivateZone);
  if (!zone?.Id) throw new Error(`No public hosted zone named ${apex} in this account`);
  const zoneId = zone.Id.replace("/hostedzone/", "");
  console.log(`Hosted zone: ${zoneId} (${zone.Name})`);

  // 4. A CNAME at www blocks alias A/AAAA records with the same name.
  const existing = await route53.send(new ListResourceRecordSetsCommand({
    HostedZoneId: zoneId, StartRecordName: www, MaxItems: 10,
  }));
  const atWww = (existing.ResourceRecordSets ?? []).filter((r) => r.Name === `${www}.`);
  for (const r of atWww) {
    const value = r.AliasTarget ? `alias ${r.AliasTarget.DNSName}` : r.ResourceRecords?.map((v) => v.Value).join(", ");
    console.log(`Existing: ${r.Type} ${r.Name} -> ${value}`);
    if (r.Type === "CNAME") problems.push(`a CNAME already exists at ${www}; delete it before adding alias records`);
  }

  const types: ("A" | "AAAA")[] = config.IsIPV6Enabled ? ["A", "AAAA"] : ["A"];
  const changes: Change[] = types.map((Type) => ({
    Action: "UPSERT",
    ResourceRecordSet: {
      Name: www,
      Type,
      AliasTarget: { HostedZoneId: CLOUDFRONT_ZONE_ID, DNSName: dist.DomainName, EvaluateTargetHealth: false },
    },
  }));
  for (const c of changes) console.log(`Plan: UPSERT ${c.ResourceRecordSet?.Type} ${www} -> alias ${dist.DomainName}`);

  if (problems.length > 0) {
    console.log(`\nFix these first:\n- ${problems.join("\n- ")}`);
    process.exitCode = 1;
    return;
  }
  if (!APPLY) {
    console.log("\nDry run: no DNS records were changed. Re-run with --apply to create them.");
    return;
  }

  const result = await route53.send(new ChangeResourceRecordSetsCommand({
    HostedZoneId: zoneId,
    ChangeBatch: { Comment: `www alias to ${distributionId}`, Changes: changes },
  }));
  console.log(`\nSubmitted change ${result.ChangeInfo?.Id}, waiting for INSYNC...`);
  await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: 300 }, { Id: result.ChangeInfo!.Id! });
  console.log("Done: Route 53 has propagated the records to its name servers.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-route-53 @aws-sdk/client-cloudfront @aws-sdk/client-acm
npm install --save-dev tsx typescript

# Dry run: check everything and print the planned records
AWS_PROFILE=readonly npx tsx www-alias-to-cloudfront.ts example.com E1ABCDEF2GHIJK

# Create the records after reviewing the plan
AWS_PROFILE=dns-admin npx tsx www-alias-to-cloudfront.ts example.com E1ABCDEF2GHIJK --apply

Sample output

Output (apply)

Distribution E1ABCDEF2GHIJK: d111111abcdef8.cloudfront.net, status Deployed, aliases [example.com, www.example.com]
Certificate: ISSUED, covers [example.com, *.example.com], expires 2027-08-14T23:59:59.000Z
Hosted zone: Z0123456789EXAMPLE (example.com.)
Plan: UPSERT A www.example.com -> alias d111111abcdef8.cloudfront.net
Plan: UPSERT AAAA www.example.com -> alias d111111abcdef8.cloudfront.net

Submitted change /change/C2682N5HXP0BZ4, waiting for INSYNC...
Done: Route 53 has propagated the records to its name servers.

IDs, domains and dates are illustrative. INSYNC means Route 53’s own name servers have the records; resolvers that cached an older answer for www keep it until that answer’s TTL runs out.

How do you redirect www to the base domain with CloudFront?

Create a CloudFront Function with this code on the cloudfront-js-2.0 runtime, publish it, and associate it with the viewer request event of each cache behavior on the distribution that serves both names. Requests for example.com pass through untouched; requests for www.example.com get a 301 before CloudFront checks its cache or contacts your origin.

redirect-www-to-apex.js

function handler(event) {
  var request = event.request;
  var host = request.headers.host ? request.headers.host.value : "";
  if (host.indexOf("www.") === 0) {
    return {
      statusCode: 301,
      statusDescription: "Moved Permanently",
      headers: { location: { value: "https://" + host.slice(4) + request.uri } },
    };
  }
  return request;
}

This version keeps the path but drops the query string; extend it with request.querystring if campaign parameters matter to you. Test it with the function’s test tab before associating it, since a mistake here affects every request. If your origin is an S3 bucket you publish to from code, the example to upload a file to S3 with S3Client in TypeScript covers the deploy side, and putting CloudFront in front also changes your bill, as the guide to estimate S3 data transfer out cost explains.

Troubleshooting

  • InvalidChangeBatch about a CNAME. A CNAME can’t share a name with other records. Delete the old www CNAME first; the alias A record replaces it.
  • AccessDenied on ChangeResourceRecordSets. The condition values must match exactly: lowercase, no trailing dot, and the action must be UPSERT. The guide to troubleshoot AWS IAM access denied errors walks through checking conditions.
  • The certificate check fails. A certificate from any region other than us-east-1 can’t be attached to CloudFront. Request a new one there and validate it with DNS, and keep the validation CNAME: ACM needs it to renew, which the script to find expiring ACM certificates watches for.
  • www loads the site but doesn’t redirect. That’s the alias doing its job. Add the CloudFront Function above, or the S3 redirect setup.

Ask ChatWithCloud instead

To check the setup rather than change it, ask “Which alternate domain names does my CloudFront distribution E1ABCDEF2GHIJK have, which certificate does it use, and what does Route 53 have for www.example.com?” ChatWithCloud writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and summarizes the answer; how ChatWithCloud answers AWS questions shows the loop. Generated code runs without asking for confirmation, so keep DNS changes for the script and explore with a read-only profile. The guides to connect ChatWithCloud to your AWS account and troubleshoot AWS infrastructure with an AI CLI cover setup and follow-up questions, and ChatWithCloud’s security page explains what data is sent.

Frequently asked questions

What is the hosted zone ID for a CloudFront alias in Route 53?

Z2FDTNDATAQYW2. It’s the same for every CloudFront distribution, so the alias target is that zone ID plus your distribution’s *.cloudfront.net domain name.

Should www be an alias record or a CNAME to CloudFront?

Both work for www, but an alias record is the better choice in Route 53: queries to CloudFront aliases aren’t charged, and you can use the same record type at the apex, where a CNAME isn’t allowed.

Do I need an AAAA record for CloudFront?

Only if IPv6 is enabled on the distribution. Then add an AAAA alias alongside the A alias so IPv6 clients resolve it too. The script decides from the distribution’s IsIPV6Enabled setting.

Why must the certificate be in us-east-1?

CloudFront is a global service and only reads ACM certificates from the us-east-1 region. A certificate in any other region doesn’t appear as an option for the distribution.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud