Why your CSS won’t load from S3 behind CloudFront

Unstyled pages after a Terraform deploy to S3 + CloudFront? The culprit is usually the Content-Type. Here is how to set it reliably for every file.

1 min read

You deploy a static site to S3, put CloudFront in front of it, open the URL — and get a page of unstyled HTML. The CSS file is there, the path is right, the browser even downloads it. It just refuses to apply it.

The symptom

Open the browser console and you will see something like:

Refused to apply style from 'https://example.com/_astro/index.css'
because its MIME type ('binary/octet-stream') is not a supported stylesheet MIME type.

The browser is doing exactly what it should. With X-Content-Type-Options: nosniff (which you want on), it will only apply a stylesheet served as text/css.

The cause

When Terraform uploads files with aws_s3_object, S3 stores whatever content_type you give it. Give it nothing and you get binary/octet-stream — for every file. CloudFront then faithfully serves that header to the browser.

The fix: a MIME map

Derive the extension from each file name and look it up in a map:

locals {
  site_dir = "${path.module}/../../apps/web/dist"

  mime_types = {
    html  = "text/html; charset=utf-8"
    css   = "text/css; charset=utf-8"
    js    = "text/javascript; charset=utf-8"
    json  = "application/json"
    xml   = "application/xml"
    txt   = "text/plain; charset=utf-8"
    md    = "text/markdown; charset=utf-8"
    svg   = "image/svg+xml"
    png   = "image/png"
    webp  = "image/webp"
    avif  = "image/avif"
    woff2 = "font/woff2"
  }
}

resource "aws_s3_object" "site" {
  for_each = fileset(local.site_dir, "**")

  bucket = aws_s3_bucket.site.id
  key    = each.value
  source = "${local.site_dir}/${each.value}"
  etag   = filemd5("${local.site_dir}/${each.value}")

  content_type = lookup(
    local.mime_types,
    element(split(".", each.value), length(split(".", each.value)) - 1),
    "application/octet-stream",
  )
}

The element(split(...)) expression takes the last segment after a dot, so index.4f2a.css resolves to css, not 4f2a.

Check it

curl -sI https://example.com/_astro/index.css | grep -i content-type

If CloudFront still serves the old header, remember it cached the old object: invalidate the path (or deploy with hashed file names, which Astro does for you).

Takeaways

  • S3 never guesses MIME types for you when uploading through the API.
  • Keep nosniff on; fix the metadata instead.
  • Put the MIME map in your static-site module once, and every site you deploy gets it right.