Skip to content
GRUDGED Free audit
§ Field notes for web developers ·

Your WebP Serves as text/plain in a Slim Docker Image

Python's mimetypes reads /etc/mime.types, and slim base images don't ship it. Static files get guessed as text/plain — and browsers hide it until nosniff shows up.

A hero image rendered perfectly in local development and perfectly in production. Then I checked the response headers on the deployed one:

content-type: text/plain; charset=utf-8

For a .webp. The browser was displaying it anyway, which is exactly why it had gone unnoticed.

The mechanism

Python’s mimetypes module builds its type map at import time by reading system files — on Linux, /etc/mime.types first. Frameworks that serve static files lean on it: FastAPI’s StaticFiles, Starlette, Django’s dev server, http.server, all of them.

python:3.12-slim does not ship /etc/mime.types. Neither do most -slim and alpine variants — dropping that package is part of what makes them slim. So mimetypes falls back to its small built-in table, which covers .html, .css, .js and the older image formats, and doesn’t know .webp, .svg, .avif, .woff2 or anything else added in the last decade or so.

Anything it can’t identify is served as text/plain.

Locally you never see it, because your Mac or your full Debian image has the file and the map is complete. The bug exists only where the base image is thinner than your laptop — which is to say, only in production.

Why it looks fine right up until it doesn’t

Browsers sniff. Handed text/plain with WebP bytes, Chrome and Firefox will look at the magic number and render the image regardless. So the page is correct, nothing errors, and the header is wrong in a way nobody notices.

It stops being invisible the moment anything sets X-Content-Type-Options: nosniff — a CDN, a security header middleware, a reverse proxy someone hardened last week. Sniffing is now disabled, the declared type is honoured, and your images stop rendering everywhere at once. The change that broke it has nothing to do with images, so that’s not where anyone looks.

The same applies to fonts: a .woff2 served as text/plain is refused by some strict CSP setups, and you get invisible text with no console error worth reading.

The fix

Register the types you actually serve, in application code, before anything mounts static files:

import mimetypes

# python:3.12-slim ships no /etc/mime.types, so StaticFiles
# guessed text/plain for the hero webp. Browsers sniff it
# anyway — but only until something sets nosniff.
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("image/svg+xml", ".svg")

Do it in code rather than apt-get install mime-support in the Dockerfile. The package route works, but it makes correct content types depend on a base image detail that a future maintainer will optimise away, and the failure will be silent again. Two explicit lines survive a base image change.

Add .avif, .woff2, .webmanifest and .json as needed — register what you serve, not the whole world.

Checking it in ten seconds

From outside the container, ask for a header only:

curl -sI https://example.org/static/hero.webp | grep -i content-type

If it says anything other than image/webp, you have this. Worth running once against every asset type you serve after a base image change, because none of them will look broken in a browser until the day they all do.


I’m Chris Moore — an independent developer and consultant in Henderson, NV, working under Grudged LLC. I ship and maintain small production systems where this kind of thing surfaces months later. If something works locally and misbehaves deployed, get in touch.

← All field notes Talk to Chris Moore →