A private chat page on S3 and CloudFront with HTTPS, no public bucket

Published

From the course: AWS in Action: AI edition, session 3: Employee chat page: private S3 + CloudFront + HTTPS, whole replies

You have a single chat page, but making the S3 bucket public just to serve index.html feels like the wrong trade-off. You also want the page to call /ask without managing cross-origin resource sharing rules or exposing a separate API address to the browser.

The solution is one CloudFront distribution with two origins: a private S3 bucket for the page and an existing HTTP API for /ask. CloudFront provides the HTTPS address, signs requests to S3, and routes API calls through the same address the page already uses.

One distribution gives the page and API the same address

The browser sees one CloudFront address, such as:

https://d123example.cloudfront.net

A request for / goes to the private S3 origin and returns index.html. A request for /ask goes to the HTTP API origin instead.

That shared address matters. The page can use a relative request:

const response = await fetch("/ask", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ question }),
});
const data = await response.json();
if (response.ok) add("bot", data.answer);
else add("err", data.error || "Something went wrong. Try again.");

Because the page and /ask have the same scheme, host and port, the browser treats the request as same-origin. You do not need CORS rules for this route.

The page’s add helper shows the answer by setting textContent, so it is displayed as text, never interpreted as markup. A failed request (response.ok is false) shows the API’s error message instead. This page waits for the complete JSON response before displaying the whole answer. It does not stream partial replies.

Four resources create the private HTTPS route

These resources are added to an existing CloudFormation stack whose HTTP API is named HelpdeskApi and uses its $default stage:

SiteBucket:
  Type: AWS::S3::Bucket
  Properties:
    OwnershipControls:
      Rules:
        - ObjectOwnership: BucketOwnerEnforced
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      BlockPublicPolicy: true
      IgnorePublicAcls: true
      RestrictPublicBuckets: true
    BucketEncryption:
      ServerSideEncryptionConfiguration:
        - ServerSideEncryptionByDefault:
            SSEAlgorithm: AES256

SiteOriginAccessControl:
  Type: AWS::CloudFront::OriginAccessControl
  Properties:
    OriginAccessControlConfig:
      Name: !Sub "${Project}-${Environment}-site"
      Description: Signs CloudFront requests to the private site bucket
      OriginAccessControlOriginType: s3
      SigningBehavior: always
      SigningProtocol: sigv4

SiteDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Comment: Helpdesk chat page and /ask
      Enabled: true
      HttpVersion: http2
      PriceClass: PriceClass_100
      DefaultRootObject: index.html
      Origins:
        - Id: site
          DomainName: !GetAtt SiteBucket.RegionalDomainName
          OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
          S3OriginConfig:
            OriginAccessIdentity: ""
        - Id: api
          DomainName: !Sub "${HelpdeskApi}.execute-api.${AWS::Region}.amazonaws.com"
          CustomOriginConfig:
            OriginProtocolPolicy: https-only
            OriginSSLProtocols:
              - TLSv1.2
      DefaultCacheBehavior:
        TargetOriginId: site
        ViewerProtocolPolicy: redirect-to-https
        AllowedMethods:
          - GET
          - HEAD
        CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
      CacheBehaviors:
        - PathPattern: /ask
          TargetOriginId: api
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
            - PUT
            - PATCH
            - POST
            - DELETE
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac

SiteBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref SiteBucket
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Sid: AllowCloudFrontServicePrincipalReadOnly
          Effect: Allow
          Principal:
            Service: cloudfront.amazonaws.com
          Action: s3:GetObject
          Resource: !Sub "${SiteBucket.Arn}/*"
          Condition:
            StringEquals:
              AWS:SourceArn: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}"

The bucket keeps all four S3 Block Public Access settings enabled. BucketOwnerEnforced disables access control lists for the bucket, while the bucket policy grants only the read permission CloudFront needs.

The policy principal is the CloudFront service, but that permission is not open to every distribution. Its AWS:SourceArn condition names this one SiteDistribution. A stranger who tries to request the object directly from the bucket receives a 403 response.

The origin access control uses Signature Version 4 and an always signing behavior. In practical terms, CloudFront signs every request it sends to the private bucket. The bucket remains private while CloudFront can retrieve index.html.

The path behavior keeps chat responses out of the cache

The default behavior sends ordinary page requests to the site origin. Its managed cache policy ID is CloudFront’s CachingOptimized policy, which is suitable for the static page.

The /ask behavior changes both the origin and the request handling:

  • CachingDisabled, identified by 4135ea2d-6df8-44a3-9df3-4b5a84be39ad, prevents one chat request or response from being served as another request’s cached result.
  • AllViewerExceptHostHeader, identified by b689b0a8-53d0-40ab-baf2-68738e2966ac, forwards the viewer request information except its Host header.
  • CloudFront supplies the API origin’s host name, allowing the HTTP API to receive its own execute-api host rather than the CloudFront host.

This is also why there is no API stage path in the origin configuration. The existing HTTP API uses its $default stage, so /ask can be forwarded as /ask.

Both cache behaviors use redirect-to-https. An HTTP request is redirected to HTTPS, and the default CloudFront domain already has an HTTPS certificate. A custom domain is not required for this setup.

Deploy the stack before uploading the page

Deploy the existing stack template with the four resources included:

aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name helpdesk \
  --capabilities CAPABILITY_IAM

CloudFront deployment can continue for a while after CloudFormation begins creating the distribution. Wait for the deployment command to complete before testing the address.

Read the stack outputs rather than reconstructing resource names:

aws cloudformation describe-stacks \
  --stack-name helpdesk \
  --query 'Stacks[0].Outputs' \
  --output table

From that output, copy the bucket name and the ChatUrl value. The template must expose those values as outputs for this workflow.

Upload the page with an explicit HTML content type:

aws s3 cp index.html "s3://BUCKET_NAME/index.html" \
  --content-type text/html

The upload command does not add public-read access. CloudFront reads the object through the origin access control and the narrowly scoped bucket policy.

Test the page and both routes

Open the ChatUrl value in a browser. The browser should load index.html over HTTPS.

A direct request to the CloudFront address should return the page with a successful status:

curl -i "CHAT_URL"

Test the API path with the JSON body required by the existing /ask handler:

curl -i \
  -X POST "CHAT_URL/ask" \
  -H "content-type: application/json" \
  --data '{"question":"How can I reset my password?"}'

The important routing check is that the request goes to CHAT_URL/ask, not directly to the HTTP API address. The exact request fields must continue to match the handler’s established contract.

You can also request the S3 object directly to confirm the privacy boundary. Without an independently authorised S3 identity, that request should receive 403, while the CloudFront address returns the page.

Empty the bucket before deleting the stack

This setup costs money while its resources exist. Remove it when you no longer need it.

CloudFormation cannot delete a non-empty S3 bucket. Empty the exact bucket first:

aws s3 rm "s3://BUCKET_NAME" --recursive

Then request stack deletion and wait for it to finish:

aws cloudformation delete-stack \
  --stack-name helpdesk

aws cloudformation wait stack-delete-complete \
  --stack-name helpdesk

If the bucket still contains an object, stack deletion fails. Emptying it first makes the teardown predictable.

Choose the public website route only when its limits fit

The older public S3 website endpoint route can be enough when a public bucket is acceptable and you do not need this private-origin, CloudFront HTTPS and same-address API arrangement. TechEazy Consulting’s step-by-step guide to hosting a static website on S3 covers that simpler route.

This setup answers a narrower requirement: keep the bucket private, serve one page through CloudFront over HTTPS, and let that page call /ask on its own address.

Key takeaways

  • Keep all S3 Block Public Access settings enabled.
  • Use origin access control so CloudFront signs every request to the bucket.
  • Restrict the bucket policy to the one distribution with AWS:SourceArn.
  • Put the private bucket and HTTP API behind one CloudFront distribution.
  • Use CachingOptimized for the page and CachingDisabled for /ask.
  • Use AllViewerExceptHostHeader so the HTTP API receives its own host name.
  • Render data.answer as text after the complete JSON response arrives.
  • Empty the bucket before deleting the stack.

This settles the single-page, non-streaming route through CloudFront’s default HTTPS address. It does not cover custom domains, streamed responses, authentication, web application firewall rules or a multi-page site.


This post is made from chapter 3 of AWS in Action: AI edition, where you build it step by step. To go further with mentoring, see the Cloud and AI Solutions Architect program.