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:

  1. Content-Disposition: attachment set on the object, which overrides the content type entirely. Same --metadata-directive REPLACE copy, without the disposition.
  2. 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 →

Related