Start HereOrdered learning track

MIME Types, default_type, and Content Negotiation

Learn NGINX In Action - Part 019

Deep dive into MIME types, default_type, types blocks, content negotiation boundaries, browser sniffing, security headers, and production-safe content classification in NGINX.

11 min read2115 words
PrevNext
Lesson 19105 lesson track01–19 Start Here
#nginx#mime-types#content-type#security-headers+3 more

Part 019 — MIME Types, default_type, dan Content Negotiation

Browser tidak hanya membaca bytes. Browser membaca bytes melalui kontrak metadata. Content-Type yang salah bisa membuat JavaScript gagal load, CSS diabaikan, download berubah menjadi render, SVG menjadi attack surface, JSON disalahartikan, atau browser melakukan MIME sniffing yang membuka celah security. Di NGINX, MIME type bukan detail kosmetik; ia adalah bagian dari protocol contract.

Part ini membahas bagaimana NGINX menentukan Content-Type, apa fungsi mime.types, kapan default_type dipakai, bagaimana types block mengubah mapping, dan bagaimana merancang content negotiation yang aman tanpa menganggap NGINX seperti framework aplikasi.

Referensi resmi utama:


1. Mental model: Content-Type adalah contract, bukan label

Ketika NGINX melayani static file, ia harus mengirim response seperti:

HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 10432
Cache-Control: public, max-age=31536000, immutable

body bytes...

Content-Type menjawab pertanyaan:

"bytes ini harus ditafsirkan sebagai apa?"

Bukan:

"nama file ini kelihatannya apa?"

NGINX biasanya menentukan MIME type dari extension file. Mapping extension → MIME type diambil dari types table, umumnya melalui file mime.types.

Alur sederhananya:

Kalau extension tidak dikenali, NGINX memakai default_type.

Default bawaan NGINX untuk default_type adalah:

default_type text/plain;

Namun banyak production config mengubahnya menjadi:

default_type application/octet-stream;

Ini bukan sekadar preferensi. Perbedaannya penting:

DefaultDampak
text/plainUnknown file bisa dirender sebagai text di browser. Ini lebih ramah debugging tetapi bisa membuka accidental exposure.
application/octet-streamUnknown file diperlakukan sebagai binary/download-like. Ini biasanya lebih defensif untuk static asset hosting.

Mental model production:

known extension     -> explicit Content-Type
unknown extension   -> safe default, usually octet-stream
sensitive extension -> deny, not merely change MIME type

2. Baseline MIME config

Baseline yang umum:

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    server {
        listen 80;
        server_name static.example.com;

        root /srv/www/static;

        location / {
            try_files $uri =404;
        }
    }
}

Artinya:

  1. NGINX memuat mapping dari /etc/nginx/mime.types.
  2. Jika extension ditemukan, gunakan mapping itu.
  3. Jika extension tidak ditemukan, gunakan application/octet-stream.
  4. Jika file tidak ada, kembalikan 404.

Jangan menaruh default_type sebagai pengganti mime.types.

Buruk:

http {
    default_type text/html;
}

Konsekuensi:

  • .css bisa dikirim sebagai HTML jika tidak ada types mapping.
  • .js bisa ditolak browser karena strict MIME checking.
  • .json bisa dirender sebagai HTML/text.
  • Unknown files terlihat aman padahal tidak diklasifikasi dengan benar.

Lebih baik:

http {
    include       mime.types;
    default_type  application/octet-stream;
}

3. types block: table lokal, bukan runtime condition

Directive types mendefinisikan mapping extension ke MIME type.

Contoh:

types {
    text/html                  html htm;
    text/css                   css;
    application/javascript     js mjs;
    application/json           json;
    image/svg+xml              svg svgz;
    application/wasm           wasm;
}

Bentuk mentalnya:

extension -> media type

Bukan:

path -> media type
client -> media type
Accept header -> media type

types bisa ditempatkan di beberapa context seperti http, server, dan location. Semakin dalam context-nya, semakin lokal efeknya.

Contoh override di satu location:

server {
    root /srv/www/app;

    location /downloads/ {
        types { }
        default_type application/octet-stream;
        try_files $uri =404;
    }
}

types { } kosong berarti: jangan gunakan mapping extension biasa di location ini. Semua file di /downloads/ akan jatuh ke default_type application/octet-stream.

Ini berguna untuk download area, tetapi berbahaya jika digunakan sembarangan.


4. MIME correctness untuk web modern

Beberapa type yang penting untuk static application:

ExtensionMIME type yang umum/benarCatatan
.htmltext/htmlEntry point dokumen.
.csstext/cssBrowser biasanya strict.
.jsapplication/javascriptHindari legacy ambiguity.
.mjsapplication/javascriptModule script butuh MIME JS valid.
.jsonapplication/jsonAPI/static manifest.
.mapapplication/jsonSource map; pertimbangkan jangan publish di prod.
.svgimage/svg+xmlBisa mengandung script/link; perlakukan sebagai aktif-ish content.
.wasmapplication/wasmRequired untuk WebAssembly streaming compile.
.webmanifestapplication/manifest+jsonPWA manifest.
.avifimage/avifPastikan mapping tersedia di distro lama.
.webpimage/webpPastikan mapping tersedia.
.woff2font/woff2Font modern.
.ttffont/ttf atau application/font-sfntMapping bervariasi.
.wasm.gzBergantung strategiJangan hanya mengandalkan double extension.

Masalah production sering muncul karena mime.types bawaan OS tertinggal dari kebutuhan asset modern. Distro lama bisa tidak mengenali .wasm, .mjs, .avif, atau .webmanifest.

Strategi aman:

http {
    include mime.types;

    types {
        application/javascript     mjs;
        application/wasm           wasm;
        application/manifest+json  webmanifest;
        image/avif                 avif;
        font/woff2                 woff2;
    }

    default_type application/octet-stream;
}

Namun perhatikan: mendefinisikan types di context yang sama dapat mengganti/menambah tergantung cara config digabung. Untuk menghindari ambiguity, banyak team lebih memilih membuat file mime.override.types yang eksplisit dan diuji melalui smoke test.


5. MDX/static docs special case

Untuk situs dokumentasi modern, file yang dilayani bisa meliputi:

/index.html
/assets/app.abc123.js
/assets/app.abc123.css
/assets/chunk.def456.mjs
/assets/icon.svg
/assets/app.wasm
/manifest.webmanifest
/robots.txt
/sitemap.xml

Contract yang diinginkan:

HTML              -> text/html
JS/MJS            -> application/javascript
CSS               -> text/css
SVG               -> image/svg+xml
WASM              -> application/wasm
manifest          -> application/manifest+json
robots/sitemap    -> text/plain / application/xml
unknown           -> application/octet-stream or 404

Production smoke test:

curl -sI https://docs.example.com/ | grep -i '^content-type:'
curl -sI https://docs.example.com/assets/app.js | grep -i '^content-type:'
curl -sI https://docs.example.com/assets/app.css | grep -i '^content-type:'
curl -sI https://docs.example.com/app.wasm | grep -i '^content-type:'
curl -sI https://docs.example.com/manifest.webmanifest | grep -i '^content-type:'

Expected:

Content-Type: text/html
Content-Type: application/javascript
Content-Type: text/css
Content-Type: application/wasm
Content-Type: application/manifest+json

Invariant:

No frontend asset should depend on browser MIME sniffing to work.

6. X-Content-Type-Options: nosniff

Browser historically tried to infer content type from bytes when header looked suspicious. This is called MIME sniffing.

For production static sites, you normally want:

add_header X-Content-Type-Options nosniff always;

Why?

If Content-Type is wrong, fail closed instead of guessing.

This changes failure mode:

Without nosniffWith nosniff
Browser may guess.Browser should respect declared type more strictly.
Misconfig can hide longer.Misconfig fails visibly.
Some attacks become easier.Safer default.

But nosniff is not magic. If you set correct security header but wrong MIME type, browser may correctly refuse to execute/load the asset.

Bad:

include mime.types;
default_type text/plain;
add_header X-Content-Type-Options nosniff always;

Then .mjs missing from mime.types might be served as text/plain, and module scripts can fail.

Good:

include mime.types;

types {
    application/javascript mjs;
    application/wasm       wasm;
}

default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;

7. Security model: MIME is not access control

Changing MIME type does not protect sensitive files.

Wrong mental model:

"If .env is served as text/plain, it is less dangerous."

Correct mental model:

"If .env is served at all, the system is already broken."

Deny sensitive files explicitly:

location ~ (?i)(^|/)\.(?!well-known/) {
    deny all;
}

location ~* \.(?:env|ini|log|sql|sqlite|db|bak|backup|old|orig|swp|pem|key|crt)$ {
    deny all;
}

But avoid overusing regex if you can solve this structurally:

/srv/www/public        -> only deploy public assets here
/srv/app/config        -> never under NGINX root/alias
/srv/app/secrets       -> never under NGINX root/alias

Best security boundary:

Do not put private files under the served filesystem root.

MIME type is a classification contract. Filesystem layout is the security boundary.


8. SVG: image that behaves like a document

SVG is tricky because it is an image format built on XML and can contain references, scripts, links, and embedded content depending on rendering context.

Baseline:

types {
    image/svg+xml svg svgz;
}

Security decision depends on source:

SVG sourceRecommended handling
Build-generated trusted iconsServe as static asset.
User-uploaded SVGTreat as active content risk; sanitize or disallow.
Third-party SVGReview/sanitize before serving inline.
Unknown SVG from public uploadPrefer reject or force download from isolated domain.

If you host user-uploaded files, do not serve them from the same origin as your application unless you deeply understand browser origin consequences.

Better architecture:

app.example.com        -> app cookies, authenticated UI
uploads.example-cdn.com -> untrusted uploads, no app cookies

For untrusted downloads:

server {
    server_name uploads.example-cdn.com;
    root /srv/uploads/public;

    add_header X-Content-Type-Options nosniff always;
    add_header Content-Security-Policy "default-src 'none'; sandbox" always;

    location / {
        types { }
        default_type application/octet-stream;
        try_files $uri =404;
    }
}

That config intentionally avoids rendering user files as active documents.


9. JSON, APIs, and static JSON files

Static JSON must be served as:

Content-Type: application/json

Config:

types {
    application/json json map;
}

Be careful with .map source maps. They are often JSON and useful for debugging, but may expose:

  • original source code structure
  • comments
  • internal paths
  • sometimes secrets accidentally bundled by bad build process

Production options:

Option A — publish source maps intentionally:

location ~* \.map$ {
    default_type application/json;
    try_files $uri =404;
}

Option B — block source maps from public:

location ~* \.map$ {
    return 404;
}

Option C — serve source maps only from protected internal domain:

server {
    server_name sourcemaps.internal.example.com;
    allow 10.0.0.0/8;
    deny all;

    root /srv/sourcemaps;
    location / {
        try_files $uri =404;
    }
}

Decision rule:

Source map exposure is not a MIME problem. It is an artifact governance problem.

10. WASM and streaming compile

WebAssembly should be served as:

Content-Type: application/wasm

If served as application/octet-stream, the browser may still fetch it, but streaming compilation optimizations may fail or degrade depending on API and browser behavior.

Config:

types {
    application/wasm wasm;
}

Smoke test:

curl -sI https://app.example.com/assets/module.wasm | grep -i '^content-type:'

Expected:

Content-Type: application/wasm

Production invariant:

Performance-critical browser features should not rely on fallback interpretation.

11. Font files and CORS

Fonts often require correct MIME type and sometimes CORS headers depending on how they are loaded and from which origin.

Common mappings:

types {
    font/woff2  woff2;
    font/woff   woff;
    font/ttf    ttf;
    font/otf    otf;
}

If fonts are served from asset domain:

location ~* \.(?:woff2|woff|ttf|otf)$ {
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header X-Content-Type-Options nosniff always;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
}

Do not solve font CORS by blindly adding:

add_header Access-Control-Allow-Origin *;

to all paths. CORS is a resource sharing policy, not a patch for missing MIME types.


12. Content-Disposition: render vs download

MIME type says what bytes are. Content-Disposition influences whether browser renders inline or downloads.

Example download area:

location /downloads/ {
    root /srv/www;

    types { }
    default_type application/octet-stream;

    add_header Content-Disposition "attachment" always;
    add_header X-Content-Type-Options nosniff always;

    try_files $uri =404;
}

This is useful for generated reports, exports, and arbitrary attachments.

But avoid one global policy:

add_header Content-Disposition "attachment" always;

That would break normal web assets.

Better split by URI ownership:

/assets/      -> renderable public assets
/downloads/   -> forced download
/uploads/     -> isolated domain or forced download

13. Content negotiation: what NGINX does and does not do

Content negotiation means selecting a representation based on request metadata, often headers like:

Accept: text/html,application/xhtml+xml
Accept-Encoding: gzip, br
Accept-Language: en-US,en;q=0.9,id;q=0.8

Important: NGINX static serving does not behave like a full application content negotiation engine by default.

NGINX can:

  • serve a file by URI
  • map extension to MIME type
  • choose routes based on variables/header via map
  • serve precompressed files with proper module/configuration
  • add Vary headers
  • proxy negotiation decisions to upstream

NGINX should not be treated as:

  • a template renderer
  • a language negotiation framework
  • an API serializer
  • a general policy engine for representation selection

Mental model:

NGINX is excellent at deterministic edge routing.
Application is usually better at semantic representation negotiation.

14. Accept-Encoding vs MIME type

Content-Type and Content-Encoding are different contracts.

Example compressed JavaScript:

Content-Type: application/javascript
Content-Encoding: gzip

This means:

After gzip decoding, the bytes are JavaScript.

It does not mean:

The file type is gzip.

Common mistake with precompressed files:

app.js.gz served as application/gzip

For browser asset delivery, the client requested /app.js, NGINX may serve compressed representation internally, but response should still indicate:

Content-Type: application/javascript
Content-Encoding: gzip

This will be covered deeper in Part 021, but the MIME invariant belongs here.


15. Vary is part of negotiation correctness

If response varies by header, caches must know.

For compressed responses:

Vary: Accept-Encoding

For language variants:

Vary: Accept-Language

For auth/user-specific response:

Vary: Authorization, Cookie

But be careful: adding too many Vary values explodes cache cardinality.

Edge rule:

Only vary on headers that actually change the response representation.

Bad:

add_header Vary "*" always;

Better:

gzip_vary on;

or explicit:

add_header Vary "Accept-Encoding" always;

when you know the response differs by encoding.


16. Language negotiation: prefer explicit paths

For static sites, prefer explicit language paths:

/en/docs/...
/id/docs/...
/ja/docs/...

Instead of making NGINX infer language from Accept-Language for every request.

Why?

  • URLs become shareable.
  • Cache keys are simpler.
  • Debugging is easier.
  • SEO is clearer.
  • User choice can override browser setting.

Possible redirect at root only:

map $http_accept_language $preferred_lang {
    default en;
    ~*^id id;
    ~*^ja ja;
}

server {
    location = / {
        return 302 /$preferred_lang/;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

But avoid negotiating every asset path:

/app.js should not have language variants unless intentionally designed.

17. API content negotiation at NGINX boundary

For APIs, let application own representation selection when possible.

NGINX can enforce coarse contract:

location /api/ {
    proxy_set_header Accept $http_accept;
    proxy_set_header Content-Type $content_type;
    proxy_pass http://api_backend;
}

NGINX can reject obviously invalid input:

if ($request_method = POST) {
    # Avoid complex policy here; use application or API gateway policy engine for semantics.
}

But do not build a complex API negotiation engine with dozens of nested if, regex, and rewrites.

Better boundary:

NGINX: route, limit, normalize, protect, observe
App: validate, authorize, serialize, negotiate business representation

18. add_header inheritance trap with security headers

Security header config often suffers from inheritance surprises.

Example:

server {
    add_header X-Content-Type-Options nosniff always;

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }
}

Depending on directive inheritance rules, defining add_header inside location can mean headers from outer level are not inherited as you expect.

Safer pattern: use snippets deliberately.

# snippets/security-headers.conf
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location /assets/ {
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
}

But even snippet inclusion should be reviewed. Duplicating headers can also cause problems.

Production invariant:

Every location class must have an explicit header contract.

19. MIME testing matrix

For every static host, define expected MIME behavior.

Example matrix:

URLExpected statusExpected Content-TypeExpected notes
/200text/htmlEntry point.
/assets/app.js200application/javascriptNo sniff reliance.
/assets/app.css200text/cssCache immutable if hashed.
/assets/module.wasm200application/wasmWASM streaming-ready.
/manifest.webmanifest200application/manifest+jsonPWA contract.
/assets/app.js.map404 or application/jsonDeliberate source map policy.
/.env403/404irrelevantMust not be served.
/unknown.weird404 or octet-streamDeliberate fallback.

Shell smoke test:

#!/usr/bin/env bash
set -euo pipefail

base="https://static.example.com"

check_type() {
  path="$1"
  expected="$2"
  actual="$(curl -fsSI "$base$path" | awk -F': ' 'tolower($1)=="content-type" {print tolower($2)}' | tr -d '\r')"
  case "$actual" in
    "$expected"*) echo "OK $path -> $actual" ;;
    *) echo "FAIL $path expected $expected got $actual" >&2; exit 1 ;;
  esac
}

check_type /assets/app.js application/javascript
check_type /assets/app.css text/css
check_type /assets/module.wasm application/wasm
check_type /manifest.webmanifest application/manifest+json

This catches config drift before users see a broken frontend.


20. Production config example: static app with MIME contract

http {
    include       mime.types;
    default_type  application/octet-stream;

    # Modern asset extensions that may be missing in older mime.types files.
    types {
        application/javascript     mjs;
        application/wasm           wasm;
        application/manifest+json  webmanifest;
        image/avif                 avif;
        font/woff2                 woff2;
    }

    server {
        listen 80;
        server_name app.example.com;

        root /srv/www/app/current/public;

        # Default security headers for document responses.
        add_header X-Content-Type-Options nosniff always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;

        location = / {
            try_files /index.html =404;
        }

        location /assets/ {
            try_files $uri =404;
            add_header X-Content-Type-Options nosniff always;
            add_header Cache-Control "public, max-age=31536000, immutable" always;
        }

        location = /manifest.webmanifest {
            try_files $uri =404;
            add_header X-Content-Type-Options nosniff always;
            add_header Cache-Control "public, max-age=3600" always;
        }

        location ~* \.map$ {
            return 404;
        }

        location ~ (?i)(^|/)\.(?!well-known/) {
            return 404;
        }

        location / {
            try_files $uri $uri/ /index.html;
        }
    }
}

Read the config as a set of contracts:

/assets/              -> hashed public assets, strict file existence, long cache
/manifest.webmanifest -> known PWA metadata, shorter cache
*.map                 -> not public
hidden files          -> not public
/                     -> SPA fallback to index.html
unknown extension     -> octet-stream only if actually served

21. Anti-patterns

Anti-pattern 1: global default_type text/html

default_type text/html;

This turns unknown files into HTML-like responses. It can hide broken asset mapping and make security behavior confusing.

Use:

default_type application/octet-stream;

unless you have a very specific reason.

Anti-pattern 2: SPA fallback for every path including assets

location / {
    try_files $uri /index.html;
}

Problem:

/assets/missing.js -> 200 text/html

Browser then says something like:

Expected JavaScript module but server responded with MIME type text/html.

Better:

location /assets/ {
    try_files $uri =404;
}

location / {
    try_files $uri $uri/ /index.html;
}

Anti-pattern 3: treating user upload extension as trustworthy

user uploads file named photo.jpg
server serves image/jpeg
actual content is HTML/SVG/script-like payload

Extension-based MIME mapping is fine for build-controlled static assets. It is not enough for untrusted uploads.

Anti-pattern 4: global wildcard CORS for assets and APIs

add_header Access-Control-Allow-Origin * always;

Do not mix MIME correctness with cross-origin policy. Solve each contract separately.

Anti-pattern 5: source maps accidentally published

/assets/app.js.map -> 200 application/json

This may be intended. It may also leak internal source. Decide explicitly.


22. Debugging checklist

When asset loading breaks:

  1. Check status:
curl -sI https://app.example.com/assets/app.js | head
  1. Check Content-Type:
curl -sI https://app.example.com/assets/app.js | grep -i content-type
  1. Check whether SPA fallback returned HTML:
curl -s https://app.example.com/assets/missing.js | head
  1. Check config dump:
nginx -T | grep -n "mime.types\|default_type\|types" -A20 -B5
  1. Check location routing:
nginx -T | grep -n "location /assets" -A20
  1. Check actual deployed file:
ls -l /srv/www/app/current/public/assets/app.js
file /srv/www/app/current/public/assets/app.js
  1. Check browser devtools network panel:
status
content-type
content-encoding
cache-control
x-content-type-options
actual response body preview

Most MIME bugs are actually one of these:

missing file -> SPA fallback -> HTML returned as JS/CSS
missing mapping -> default_type used
wrong context -> types override too local/global
old distro mime.types -> modern extension not known
source map/upload policy -> security governance issue

23. Decision table

ScenarioPreferred NGINX behavior
Build-controlled hashed JS/CSS assetsCorrect MIME, long immutable cache, nosniff.
SPA app routesFallback to index.html only outside strict asset prefixes.
Missing asset404, not HTML fallback.
Unknown extension in public static rootUsually 404 or application/octet-stream.
User uploadsIsolated domain, forced download or sanitized allowlist.
Source mapsExplicit publish/block/internal policy.
WASMapplication/wasm.
FontsCorrect font MIME + scoped CORS if cross-origin.
Language negotiationPrefer explicit language paths.
API representationLet application negotiate; NGINX enforces edge contract.

24. Invariants for production review

Use these invariants in config review:

1. Every public extension class has a deliberate Content-Type.
2. Unknown extension behavior is deliberate.
3. Missing frontend assets return 404, not index.html.
4. Sensitive files are denied by filesystem layout first, config second.
5. User uploads are not served from the same trust boundary as application assets.
6. nosniff is enabled where browser-rendered assets are served.
7. Source map exposure is explicitly decided.
8. MIME type is not used as authorization or confidentiality control.
9. Content-Type and Content-Encoding are tested independently.
10. Any response variation has a matching cache/Vary strategy.

25. Mini lab

Create a test root:

sudo mkdir -p /srv/labs/mime/public/assets
cat <<'HTML' | sudo tee /srv/labs/mime/public/index.html
<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="/assets/app.css">
    <script type="module" src="/assets/app.mjs"></script>
  </head>
  <body>Hello MIME</body>
</html>
HTML

echo 'body { font-family: sans-serif; }' | sudo tee /srv/labs/mime/public/assets/app.css
echo 'console.log("module loaded")' | sudo tee /srv/labs/mime/public/assets/app.mjs
echo '(module)' | sudo tee /srv/labs/mime/public/assets/module.wasm

Config:

server {
    listen 8080;
    server_name _;

    root /srv/labs/mime/public;

    include mime.types;
    default_type application/octet-stream;

    types {
        application/javascript mjs;
        application/wasm wasm;
    }

    add_header X-Content-Type-Options nosniff always;

    location /assets/ {
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Test:

curl -sI http://localhost:8080/assets/app.css | grep -i content-type
curl -sI http://localhost:8080/assets/app.mjs | grep -i content-type
curl -sI http://localhost:8080/assets/module.wasm | grep -i content-type
curl -sI http://localhost:8080/assets/missing.js | head

Expected:

/assets/app.css       -> text/css
/assets/app.mjs       -> application/javascript
/assets/module.wasm   -> application/wasm
/assets/missing.js    -> 404, not 200 text/html

Break it intentionally:

location / {
    try_files $uri /index.html;
}

Then test:

curl -sI http://localhost:8080/assets/missing.js

If it returns 200 and text/html, you have reproduced one of the most common SPA static hosting bugs.


26. What to remember

Content-Type is not decoration. It is part of the protocol contract between edge and client.

For NGINX production static serving:

include mime.types
add missing modern types deliberately
use safe default_type
separate asset paths from SPA fallback
deny sensitive files structurally
turn on nosniff
smoke test Content-Type in CI/CD

The best NGINX configs are boring because every response class has an explicit contract.

Next: Part 020 moves from “what bytes mean” to “how bytes are moved”: sendfile, AIO, directio, and open_file_cache.

Lesson Recap

You just completed lesson 19 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.