
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 by4135ea2d-6df8-44a3-9df3-4b5a84be39ad, prevents one chat request or response from being served as another request’s cached result.AllViewerExceptHostHeader, identified byb689b0a8-53d0-40ab-baf2-68738e2966ac, forwards the viewer request information except itsHostheader.- 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
CachingOptimizedfor the page andCachingDisabledfor/ask. - Use
AllViewerExceptHostHeaderso the HTTP API receives its own host name. - Render
data.answeras 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.