# 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.

- Author: Jimmy Morin (Cloud Infrastructure Engineer)
- Published: 2026-10-01
- Updated: 2026-10-01
- Tags: aws, terraform, s3, cloudfront
- Canonical: https://jimmymorin.cloud/posts/s3-cloudfront-css-mime-types-terraform

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:

```text
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:

```hcl
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

```bash
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.
