The browser downloads your S3-hosted report
You upload index.html, open the URL, and the browser saves a file
instead of rendering a page. The file is fine — open it locally and it
renders.
The header, not the file
The browser decides what to do with a response from its
Content-Type. S3 stores that as object metadata at upload time and
serves it verbatim. When the uploader cannot infer a type, it writes
binary/octet-stream, and the browser's only correct response to an
octet-stream is to save it.
Check what your object actually claims:
curl -sI https://bucket.s3.amazonaws.com/index.html | grep -i content-type
# content-type: binary/octet-stream ← this is the bug
Fixing it
On upload:
aws s3 cp report.html s3://my-bucket/ --content-type text/html
For a directory, the CLI's recursive copy needs a pass per
extension, because --content-type applies to everything it touches:
aws s3 cp dist/ s3://my-bucket/ --recursive \
--exclude "*.html" --exclude "*.css" --exclude "*.js"
aws s3 cp dist/ s3://my-bucket/ --recursive \
--exclude "*" --include "*.html" --content-type text/html
aws s3 cp dist/ s3://my-bucket/ --recursive \
--exclude "*" --include "*.css" --content-type text/css
aws s3 cp dist/ s3://my-bucket/ --recursive \
--exclude "*" --include "*.js" --content-type application/javascript
Miss the CSS pass and you get a report with no styling instead of a download — the same class of bug wearing a different hat.
Retroactively, without re-uploading:
aws s3 cp s3://b/k s3://b/k --content-type text/html \
--metadata-directive REPLACE
Terraform:
resource "aws_s3_object" "report" {
bucket = aws_s3_bucket.reports.id
key = "index.html"
source = "dist/index.html"
content_type = "text/html" # omit this and you are back at the top
}
Still downloading after the fix
Two more suspects:
Content-Disposition: attachmentset on the object, which overrides the content type entirely. Same--metadata-directive REPLACEcopy, without the disposition.- CloudFront cached the old response. Invalidate the path —
aws cloudfront create-invalidation --distribution-id … --paths "/*"— and remember that the cached copy is what your colleague is getting even after your own reload looks fixed.
The bigger question
Getting one header right is easy. The list of things that come with it is what to weigh:
- Bucket policy and public-access-block settings, or an OIDC role plus signed URLs if the reports are private.
- A lifecycle rule, or the bucket grows forever.
- Per-extension content types on every deploy, forever.
- CloudFront invalidation on every publish.
- No access control per report, unless you build it.
- No place for the discussion the report exists to start.
That is a reasonable amount of infrastructure for a document three people will read and argue about. The alternative — POST the HTML, get a URL — has none of it: content types, asset rewriting, per-report access (private, invited, link, public) and revision history are the default rather than a checklist. Comparison in S3 static hosting alternatives.
Keep the bucket for what buckets are good at: artifacts other machines consume.
Try it
Comma is free — unlimited reports, unlimited commenters, unlimited revision history.
Publish a report and get a URL →