Steve Kinney

Solution: Set Up a CloudFront Distribution

This is the complete solution for the CloudFront Distribution Exercise. Every command is shown with its expected output.

Why This Works

  • Origin Access Control keeps S3 private while still allowing CloudFront to read the files your users need.
  • Custom error responses make a single-page app behave like a frontend router instead of a pile of missing-object errors.
  • The distribution becomes the enforcement point for HTTPS, caching, and security headers, which is why it sits between your users and the bucket.

If the console or CLI output shifts while you’re doing this, keep the CloudFront Developer Guide and the OAC setup guide open.

Create an Origin Access Control

aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "my-frontend-app-oac",
    "Description": "OAC for my-frontend-app-assets S3 bucket",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }' \
  --region us-east-1 \
  --output json

Expected output:

{
  "Location": "https://cloudfront.amazonaws.com/2020-05-31/origin-access-control/E1OAC2EXAMPLE",
  "ETag": "E1ETAG1EXAMPLE",
  "OriginAccessControl": {
    "Id": "E1OAC2EXAMPLE",
    "OriginAccessControlConfig": {
      "Name": "my-frontend-app-oac",
      "Description": "OAC for my-frontend-app-assets S3 bucket",
      "SigningProtocol": "sigv4",
      "SigningBehavior": "always",
      "OriginAccessControlOriginType": "s3"
    }
  }
}

Save the Id value (E1OAC2EXAMPLE).

Create the Distribution

Save the following as distribution-config.json:

{
  "CallerReference": "my-frontend-app-2026-03-18",
  "Comment": "CloudFront distribution for my-frontend-app-assets",
  "Enabled": true,
  "DefaultRootObject": "index.html",
  "PriceClass": "PriceClass_100",
  "HttpVersion": "http2and3",
  "IsIPV6Enabled": true,
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "S3-my-frontend-app-assets",
        "DomainName": "my-frontend-app-assets.s3.us-east-1.amazonaws.com",
        "OriginAccessControlId": "E1OAC2EXAMPLE",
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        }
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-my-frontend-app-assets",
    "ViewerProtocolPolicy": "redirect-to-https",
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "Compress": true,
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": {
        "Quantity": 2,
        "Items": ["GET", "HEAD"]
      }
    }
  },
  "CustomErrorResponses": {
    "Quantity": 2,
    "Items": [
      {
        "ErrorCode": 403,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 10
      },
      {
        "ErrorCode": 404,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 10
      }
    ]
  },
  "ViewerCertificate": {
    "ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "Aliases": {
    "Quantity": 2,
    "Items": ["example.com", "www.example.com"]
  },
  "Restrictions": {
    "GeoRestriction": {
      "RestrictionType": "none",
      "Quantity": 0
    }
  }
}

Create the distribution:

aws cloudfront create-distribution \
  --distribution-config file://distribution-config.json \
  --region us-east-1 \
  --output json

Expected output (abridged to the fields you need):

{
  "Distribution": {
    "Id": "E1A2B3C4D5E6F7",
    "ARN": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7",
    "DomainName": "d1234abcdef.cloudfront.net",
    "Status": "InProgress",
    "DistributionConfig": {
      "CallerReference": "my-frontend-app-2026-03-18",
      "Comment": "CloudFront distribution for my-frontend-app-assets",
      "Enabled": true,
      "DefaultRootObject": "index.html",
      "PriceClass": "PriceClass_100",
      "HttpVersion": "http2and3",
      "IsIPV6Enabled": true,
      "Origins": {
        "Quantity": 1,
        "Items": [
          {
            "Id": "S3-my-frontend-app-assets",
            "DomainName": "my-frontend-app-assets.s3.us-east-1.amazonaws.com",
            "OriginAccessControlId": "E1OAC2EXAMPLE",
            "S3OriginConfig": {
              "OriginAccessIdentity": ""
            }
          }
        ]
      },
      "DefaultCacheBehavior": {
        "TargetOriginId": "S3-my-frontend-app-assets",
        "ViewerProtocolPolicy": "redirect-to-https",
        "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
        "Compress": true,
        "AllowedMethods": {
          "Quantity": 2,
          "Items": ["GET", "HEAD"],
          "CachedMethods": {
            "Quantity": 2,
            "Items": ["GET", "HEAD"]
          }
        }
      },
      "CustomErrorResponses": {
        "Quantity": 2,
        "Items": [
          {
            "ErrorCode": 403,
            "ResponsePagePath": "/index.html",
            "ResponseCode": "200",
            "ErrorCachingMinTTL": 10
          },
          {
            "ErrorCode": 404,
            "ResponsePagePath": "/index.html",
            "ResponseCode": "200",
            "ErrorCachingMinTTL": 10
          }
        ]
      },
      "ViewerCertificate": {
        "ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "SSLSupportMethod": "sni-only",
        "MinimumProtocolVersion": "TLSv1.2_2021"
      },
      "Aliases": {
        "Quantity": 2,
        "Items": ["example.com", "www.example.com"]
      },
      "Restrictions": {
        "GeoRestriction": {
          "RestrictionType": "none",
          "Quantity": 0
        }
      }
    }
  }
}

Save the distribution ID (E1A2B3C4D5E6F7) and domain name (d1234abcdef.cloudfront.net).

Wait for the distribution to deploy:

aws cloudfront wait distribution-deployed \
  --id E1A2B3C4D5E6F7 \
  --region us-east-1

This command blocks until the status changes from InProgress to Deployed. No output means success.

Update the S3 Bucket Policy

Save the following as bucket-policy-oac.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-frontend-app-assets/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
        }
      }
    }
  ]
}

Apply the policy:

aws s3api put-bucket-policy \
  --bucket my-frontend-app-assets \
  --policy file://bucket-policy-oac.json \
  --region us-east-1

No output on success.

Verify the policy:

aws s3api get-bucket-policy \
  --bucket my-frontend-app-assets \
  --region us-east-1 \
  --output json

Test CloudFront access:

curl -I https://d1234abcdef.cloudfront.net/index.html

Expected:

HTTP/2 200
content-type: text/html

Test that direct S3 access is blocked:

curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.html

Expected:

HTTP/1.1 403 Forbidden

Re-enable Block Public Access

aws s3api put-public-access-block \
  --bucket my-frontend-app-assets \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
  --region us-east-1

No output on success.

Verify Block Public Access is enabled:

aws s3api get-public-access-block \
  --bucket my-frontend-app-assets \
  --region us-east-1 \
  --output json

Expected:

{
  "PublicAccessBlockConfiguration": {
    "BlockPublicAcls": true,
    "IgnorePublicAcls": true,
    "BlockPublicPolicy": true,
    "RestrictPublicBuckets": true
  }
}

Verify CloudFront still works:

curl -I https://d1234abcdef.cloudfront.net/index.html

Still 200 OK. The CloudFront service principal policy isn’t considered a “public” policy.

Test SPA Routing

curl -I https://d1234abcdef.cloudfront.net/dashboard/settings

Expected:

HTTP/2 200
content-type: text/html

The custom error response intercepts the 403 from S3 (because /dashboard/settings doesn’t exist as an object) and returns /index.html with a 200 status code. Your client-side router handles the rest.

Test another non-existent path:

curl -I https://d1234abcdef.cloudfront.net/users/123/profile

Same result: 200 OK with text/html.

Attach the Security Headers Policy

Fetch the current distribution config:

aws cloudfront get-distribution-config \
  --id E1A2B3C4D5E6F7 \
  --region us-east-1 \
  --output json > distribution-config-current.json

Note the ETag in the response (e.g., E3ETAG3EXAMPLE).

Edit the DistributionConfig to add ResponseHeadersPolicyId to the DefaultCacheBehavior. The updated behavior should look like this:

{
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-my-frontend-app-assets",
    "ViewerProtocolPolicy": "redirect-to-https",
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "ResponseHeadersPolicyId": "67f7725c-6f97-4210-82d7-5512b31e9d03",
    "Compress": true,
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": {
        "Quantity": 2,
        "Items": ["GET", "HEAD"]
      }
    }
  }
}

Extract the DistributionConfig (without the ETag wrapper) into distribution-config-updated.json and submit:

aws cloudfront update-distribution \
  --id E1A2B3C4D5E6F7 \
  --if-match E3ETAG3EXAMPLE \
  --distribution-config file://distribution-config-updated.json \
  --region us-east-1 \
  --output json

Wait for deployment:

aws cloudfront wait distribution-deployed \
  --id E1A2B3C4D5E6F7 \
  --region us-east-1

Verify the security headers:

curl -I https://d1234abcdef.cloudfront.net/index.html

Expected (among other headers):

strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
referrer-policy: strict-origin-when-cross-origin

Final Verification

Run all checks:

# Distribution status
aws cloudfront get-distribution \
  --id E1A2B3C4D5E6F7 \
  --region us-east-1 \
  --output json \
  --query "Distribution.{Id:Id,Domain:DomainName,Status:Status}"

Expected:

{
  "Id": "E1A2B3C4D5E6F7",
  "Domain": "d1234abcdef.cloudfront.net",
  "Status": "Deployed"
}
# HTTPS works
curl -I https://d1234abcdef.cloudfront.net

# SPA routing works
curl -I https://d1234abcdef.cloudfront.net/any/spa/route

# S3 is locked down
curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.html

Expected results: CloudFront returns 200 OK for both the root and the SPA route. Direct S3 access returns 403 Forbidden.

Summary of Resources Created

ResourceIdentifier
Origin Access ControlE1OAC2EXAMPLE
CloudFront DistributionE1A2B3C4D5E6F7
Distribution Domaind1234abcdef.cloudfront.net
S3 Bucket PolicyUpdated to allow only CloudFront
Response Headers Policy67f7725c-6f97-4210-82d7-5512b31e9d03 (managed SecurityHeadersPolicy)
Cache Policy658327ea-f89d-4fab-a63d-7e88639e58f6 (managed CachingOptimized)

Your distribution is live, secured, and ready for a custom domain. That’s what the Route 53 custom-domain-routing section handles next.

Last modified on .