Build CoreOrdered learning track

Cache-Control, ETag, and Last-Modified

Learn NGINX In Action - Part 022

Deep production treatment of browser cache semantics in NGINX: Cache-Control, Expires, ETag, Last-Modified, conditional requests, immutable assets, HTML revalidation, and cache policy safety.

12 min read2320 words
PrevNext
Lesson 22105 lesson track20–57 Build Core
#nginx#cache-control#etag#last-modified+4 more

Part 022 — Browser Cache Semantics: Cache-Control, ETag, Last-Modified

Cache policy adalah bagian dari API contract. Salah cache header bisa membuat bug yang tidak bisa di-fix dengan reload NGINX, karena browser/CDN sudah menyimpan keputusan lama. Untuk static assets, cache yang agresif bisa sangat baik. Untuk HTML dan authenticated content, cache yang agresif bisa menjadi incident. Production-grade NGINX harus membedakan freshness, validation, invalidation, dan representation negotiation.

Part ini membahas browser cache semantics dari sudut pandang NGINX: bagaimana expires, add_header, etag, Last-Modified, conditional request, immutable assets, SPA HTML, error page, dan proxied response harus dirancang agar cepat tanpa mengorbankan correctness.

Referensi resmi utama:


1. Mental model: cache adalah kontrak waktu

Ketika NGINX mengirim response, ia bukan hanya mengirim bytes. Ia memberi instruksi ke client/cache:

Cache-Control: public, max-age=31536000, immutable
ETag: "686ac0f2-1f4ab"
Last-Modified: Mon, 06 Jul 2026 03:12:18 GMT
Vary: Accept-Encoding

Instruksi ini menjawab beberapa pertanyaan:

PertanyaanHeader terkait
Boleh disimpan?Cache-Control
Berapa lama dianggap fresh?max-age, s-maxage, Expires
Harus validasi ulang?no-cache, must-revalidate
Tidak boleh disimpan sama sekali?no-store
Bagaimana validasi ulang?ETag, Last-Modified
Representation bergantung pada request header apa?Vary

Diagram sederhana:

Kesalahan umum: menganggap cache hanya soal “cepat”. Padahal cache adalah distributed state yang Anda kirim ke luar sistem.


2. Freshness vs validation

Ada dua konsep besar:

KonsepArtiContoh
FreshnessCache boleh dipakai tanpa bertanya ke servermax-age=31536000
ValidationCache harus bertanya apakah masih validETag + If-None-Match

Contoh response fresh lama:

Cache-Control: public, max-age=31536000, immutable

Browser tidak perlu request ulang selama masih fresh.

Contoh response perlu revalidation:

Cache-Control: no-cache
ETag: "abc123"

no-cache bukan berarti “jangan simpan”. Artinya cache boleh menyimpan, tetapi harus revalidate sebelum dipakai.

Untuk benar-benar melarang penyimpanan:

Cache-Control: no-store

3. Browser cache bukan proxy cache

Part ini fokus browser/private cache dan static file serving. NGINX proxy cache akan dibahas khusus di Phase 5.

Perbedaan penting:

AreaBrowser cacheProxy/CDN cache
LokasiUser agentShared infrastructure
Risiko data userUser sendiriBisa bocor antar user jika salah
Header pentingCache-Control, ETag, Last-Modifiedplus s-maxage, private, Vary, surrogate policy
InvalidationHard karena di clientBisa purge/ban tergantung platform

Walau Part ini fokus browser, header yang Anda kirim sering juga dibaca CDN. Jadi desainnya tetap harus shared-cache-safe.


4. Cache-Control: directive yang harus dipahami

Directive umum:

DirectiveMakna praktis
publicboleh disimpan shared cache
privatehanya private cache seperti browser user
max-age=Nfresh selama N detik
s-maxage=Nfresh duration khusus shared cache
no-cacheboleh simpan, wajib revalidate sebelum pakai
no-storejangan simpan sama sekali
must-revalidatesetelah stale, cache harus validasi sebelum pakai
immutableselama fresh, resource tidak berubah

Pola umum:

ResourcePolicy
Content-hashed JS/CSS/imagepublic, max-age=31536000, immutable
HTML shell SPAno-cache atau max-age=0, must-revalidate
API authenticatedprivate, no-cache atau no-store tergantung sensitivitas
Error pagependek atau no-cache
Download privateprivate, no-store bila sensitif

5. Expires vs Cache-Control

Expires adalah HTTP/1.0-era absolute timestamp.

Expires: Tue, 06 Jul 2027 03:12:18 GMT

Cache-Control: max-age lebih modern dan relatif terhadap waktu response.

NGINX directive expires dapat mengatur Expires dan Cache-Control sekaligus.

Contoh:

location /assets/ {
    expires 1y;
}

NGINX akan menghasilkan header cache sesuai nilai expires.

Namun untuk production modern, sering lebih eksplisit memakai add_header Cache-Control ... always;, terutama untuk policy seperti immutable.

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

Keduanya bisa dikombinasikan, tetapi jangan membuat policy kontradiktif.


6. expires directive: cepat tapi harus dipahami

Contoh dari headers module:

location /images/ {
    expires 30d;
}

Makna praktis:

  • Nilai positif atau zero menghasilkan Cache-Control: max-age=t.
  • Nilai negatif menghasilkan Cache-Control: no-cache.
  • expires epoch menghasilkan waktu lama di masa lalu dan no-cache.
  • expires max menghasilkan expiry jauh di masa depan.
  • expires off menonaktifkan modifikasi header Expires/Cache-Control oleh directive ini.

Contoh variable-driven policy:

map $sent_http_content_type $expires_policy {
    default                 off;
    text/html               -1;
    text/css                1y;
    application/javascript  1y;
    ~image/                 30d;
}

server {
    location / {
        expires $expires_policy;
    }
}

Ini kuat, tetapi hati-hati: policy berdasarkan Content-Type bergantung pada MIME resolution yang benar.


7. add_header: inheritance dan status code trap

add_header terlihat sederhana, tapi sering menjadi sumber bug.

Contoh:

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Masalah: tanpa always, add_header hanya berlaku pada status code tertentu sesuai dokumentasi NGINX. Untuk error response tertentu, header mungkin tidak ditambahkan.

Versi lebih eksplisit:

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

Namun always juga bisa berbahaya jika error page 404/500 ikut mendapat long cache.

Karena itu, untuk immutable assets, pastikan missing asset mengembalikan 404 yang tidak di-cache lama atau dipisahkan dengan error handling.

Contoh hati-hati:

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

error_page 404 /404.html;
location = /404.html {
    internal;
    add_header Cache-Control "no-cache" always;
}

Tetapi ingat: internal error redirect dan header inheritance perlu diuji. Jangan percaya intent; lihat header aktual dengan curl -I.


8. Header inheritance: satu add_header bisa menghapus warisan lain

Di NGINX, add_header diwariskan dari level sebelumnya hanya jika level saat ini tidak mendefinisikan add_header sama sekali.

Contoh problem:

server {
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;

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

Banyak orang berharap /assets/ memiliki tiga header:

X-Frame-Options
X-Content-Type-Options
Cache-Control

Tetapi karena location /assets/ mendefinisikan add_header, header dari parent bisa tidak diwariskan.

Pola aman:

# snippets/security-headers.conf
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

server {
    include snippets/security-headers.conf;

    location /assets/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }
}

Ini terasa repetitive, tapi lebih audit-friendly daripada berharap inheritance.


9. ETag: validator kuat secara praktis, tapi pahami boundary

ETag adalah validator resource representation.

Response:

ETag: "686ac0f2-1f4ab"

Request berikutnya:

If-None-Match: "686ac0f2-1f4ab"

Jika sama:

HTTP/1.1 304 Not Modified

NGINX core module punya directive:

etag on;

Default umumnya on untuk static resources menurut dokumentasi core module.

Kapan ETag berguna:

  • HTML yang perlu revalidate.
  • Static files tanpa content-hashed filename.
  • File yang sering dicek tetapi jarang berubah.

Kapan hati-hati:

  • Multi-node static serving dengan metadata file berbeda.
  • Build/deploy yang preserve content tapi ubah mtime/inode/size behavior.
  • CDN yang melakukan transform/compression sendiri.

Jika semua static asset sudah content-hashed dan long-cache immutable, ETag tidak terlalu penting untuk assets itu karena browser jarang revalidate selama fresh.


10. Last-Modified: validator berbasis waktu

NGINX dapat mengirim:

Last-Modified: Mon, 06 Jul 2026 03:12:18 GMT

Client bisa mengirim:

If-Modified-Since: Mon, 06 Jul 2026 03:12:18 GMT

Jika file belum berubah:

304 Not Modified

Directive terkait:

if_modified_since exact;

Opsi umum:

OptionMakna praktis
exactcocok hanya jika waktu sama persis
beforecocok jika resource belum dimodifikasi sejak waktu request
offdisable check If-Modified-Since

Untuk kebanyakan static file serving, default sudah cukup. Jangan ubah kecuali ada alasan jelas.


11. Conditional request lifecycle

Flow:

Manfaat 304:

  • Mengurangi bandwidth.
  • Tetap memberi server kesempatan memutuskan apakah resource berubah.
  • Cocok untuk HTML entrypoint yang harus update cepat.

Namun 304 tetap butuh request. Jika Anda punya millions of users, no-cache untuk semua asset berarti banyak conditional requests tetap masuk ke edge.


12. Immutable hashed assets: policy paling agresif yang aman

Jika filename mengandung content hash:

/assets/app.1a2b3c4d.js
/assets/vendor.9f8e7d6c.css
/assets/logo.aabbccdd.svg

Maka URL berubah saat content berubah. Ini memungkinkan:

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

Kenapa aman?

content changes -> filename changes -> URL changes -> old cached URL no longer referenced

Invariant:

Never reuse the same hashed filename for different content.

Jika build system gagal menjaga invariant ini, long cache menjadi incident.


13. SPA/HTML entrypoint: jangan long-cache sembarangan

Untuk SPA:

/index.html

sering berisi referensi ke asset terbaru:

<script src="/assets/app.1a2b3c.js"></script>

Jika index.html di-cache 1 tahun, user bisa terus memakai app lama.

Policy aman:

location = /index.html {
    add_header Cache-Control "no-cache" always;
    try_files /index.html =404;
}

Atau:

location = /index.html {
    add_header Cache-Control "max-age=0, must-revalidate" always;
    try_files /index.html =404;
}

Perbedaan praktis:

PolicyBehavior
no-cacheboleh simpan, wajib revalidate sebelum pakai
max-age=0, must-revalidatelangsung stale, harus revalidate
no-storejangan simpan sama sekali; lebih berat

Untuk public SPA shell, no-cache biasanya cukup.


14. SPA fallback dan cache leakage

Config sering terlihat begini:

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

Jika Anda memberi cache policy di location /:

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

Bug: fallback /index.html bisa ikut long-cache.

Pola lebih aman:

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

location = /index.html {
    try_files /index.html =404;
    add_header Cache-Control "no-cache" always;
}

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

Tetap test header untuk:

curl -I https://app.example.com/
curl -I https://app.example.com/index.html
curl -I https://app.example.com/some/spa/route
curl -I https://app.example.com/assets/app.1a2b3c.js

Pastikan SPA route tidak diam-diam mendapat immutable cache jika body-nya index.html.


15. Error pages: jangan cache incident terlalu lama

Jika asset missing:

GET /assets/app.missing.js

Anda tidak ingin browser menyimpan 404 selama setahun karena add_header ... always ada di /assets/.

Ada dua pendekatan:

Pendekatan A — jangan pakai always untuk immutable assets

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

Header hanya muncul untuk status code tertentu. Tapi ini bergantung pada daftar status NGINX.

Pendekatan B — pisah error handling dengan eksplisit

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

location @asset_missing {
    add_header Cache-Control "no-cache" always;
    return 404;
}

Namun perhatikan inheritance/phase behavior. Selalu validasi dengan curl -I.

Production rule:

Every cache policy must be tested for 200, 301/302, 304, 404, and 500 paths.

16. Redirect cache policy

Redirect juga bisa di-cache.

Contoh canonical redirect:

server {
    listen 80;
    server_name example.com;
    return 301 https://example.com$request_uri;
}

Browser bisa menyimpan 301. Itu baik jika canonical permanent benar. Berbahaya jika redirect sementara diberi 301 atau cache panjang.

Untuk redirect migration yang belum final:

return 302 https://new.example.com$request_uri;

Atau eksplisit:

add_header Cache-Control "no-cache" always;
return 302 https://new.example.com$request_uri;

Jangan jadikan 301 sebagai default untuk eksperimen.


17. Vary dan representation cache

Dari Part 021, compressed response perlu:

Vary: Accept-Encoding

Vary memberi tahu cache bahwa response berbeda berdasarkan request header tertentu.

Common Vary:

HeaderKapan relevan
Accept-Encodinggzip/br/identity representation
OriginCORS dynamic origin response
Accept-Languagelocalized content
Authorizationjarang sebagai vary; biasanya jangan shared-cache

Hati-hati dengan Vary: User-Agent; cardinality tinggi dan bisa menghancurkan cache efficiency.

Untuk static assets, biasanya cukup:

gzip_vary on;

Untuk CORS dynamic origin, akan dibahas lebih dalam di Part 026.


18. Authenticated content: default konservatif

Untuk authenticated pages/API, cache policy tergantung data.

Contoh private dashboard HTML:

location /dashboard/ {
    proxy_pass http://app_backend;
    add_header Cache-Control "private, no-cache" always;
}

Untuk response sangat sensitif:

location /billing/export/ {
    proxy_pass http://app_backend;
    add_header Cache-Control "no-store" always;
}

Perbedaan:

PolicyKapan
private, no-cachebrowser user boleh simpan tapi harus revalidate
no-storejangan simpan; cocok untuk sensitive data/export/token
publichampir tidak cocok untuk user-specific response

Jangan menaruh public, max-age=... di parent server untuk semua response.


19. Proxied response: siapa owner cache policy?

Ketika NGINX reverse proxy ke app, ada dua opsi:

App owns cache policy

location /api/ {
    proxy_pass http://app_backend;
}

App mengirim:

Cache-Control: private, no-cache

NGINX hanya meneruskan.

Edge owns cache policy

location /public-api/ {
    proxy_pass http://app_backend;
    proxy_hide_header Cache-Control;
    proxy_hide_header Expires;
    add_header Cache-Control "public, max-age=60" always;
}

Ini harus dilakukan hati-hati. Edge mengambil alih kontrak HTTP dari app.

Decision rule:

If cache policy depends on domain/user/business semantics, app should usually own it.
If cache policy depends on edge/static representation semantics, NGINX can own it.

20. Cache policy by content type dengan map

Untuk static server besar, Anda bisa menggunakan map.

map $sent_http_content_type $cache_control {
    default                 "no-cache";
    text/html               "no-cache";
    text/css                "public, max-age=31536000, immutable";
    application/javascript  "public, max-age=31536000, immutable";
    image/svg+xml           "public, max-age=31536000, immutable";
    ~image/                 "public, max-age=2592000";
}

server {
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control $cache_control always;
    }
}

Namun ada trap: jika SPA route /foo fallback ke /index.html, Content-Type akan text/html, jadi policy no-cache. Itu bisa baik.

Tapi untuk missing files, error response content type bisa membuat policy tidak sesuai. Test status code.

Lebih audit-friendly untuk aplikasi kecil:

/assets/   -> immutable
/index.html -> no-cache
/          -> fallback carefully

21. Cache busting: filename hash vs query string

Dua pola:

/assets/app.1a2b3c.js
/assets/app.js?v=1a2b3c

Content-hashed filename lebih kuat.

Kenapa?

AspectFilename hashQuery string
CDN compatibilitysangat baikkadang policy khusus
Human auditjelasbisa tersembunyi
Static file lookupnaturalsame file path
Rollbackmudahtergantung HTML query
Cache key ambiguityrendahtergantung proxy/CDN config

Recommendation:

Use content hash in filename for immutable assets.
Use HTML revalidation to point to latest hashed assets.

22. Deployment invariants untuk long cache

Long cache aman hanya jika deployment memenuhi invariant.

Invariant 1: Hashed filenames are content-addressed.
Invariant 2: Old assets remain available after new HTML deploy.
Invariant 3: HTML is revalidated frequently.
Invariant 4: Rollback does not reference deleted assets.
Invariant 5: Missing assets are not cached for a long time.

Kenapa old assets harus tetap ada?

Sequence:

Untuk zero-downtime frontend deploy, simpan beberapa release asset atau deploy assets sebelum HTML.

Pola aman:

1. Upload new assets
2. Verify assets exist
3. Switch HTML/index to reference new assets
4. Keep old assets for rollback/cache tail window
5. Garbage collect old assets after safe retention

23. Testing cache headers

Minimal test matrix:

# HTML
curl -I https://app.example.com/
curl -I https://app.example.com/index.html
curl -I https://app.example.com/some/spa/route

# Immutable assets
curl -I https://app.example.com/assets/app.1a2b3c.js

# Missing asset
curl -I https://app.example.com/assets/missing.js

# Compression representation
curl -I -H 'Accept-Encoding: gzip' https://app.example.com/assets/app.1a2b3c.js
curl -I -H 'Accept-Encoding:' https://app.example.com/assets/app.1a2b3c.js

# Conditional request
curl -I https://app.example.com/index.html
curl -I -H 'If-None-Match: "<etag-from-previous-response>"' https://app.example.com/index.html

Expected:

PathExpected cache policy
/assets/app.hash.jslong public immutable
/index.htmlno-cache/revalidate
/some/spa/routesame as HTML fallback
/assets/missing.jsno-cache or short
500 pageno-cache/no-store depending sensitivity
gzip varianthas Vary: Accept-Encoding

24. Observability: log what matters

Add fields:

log_format edge_json escape=json
  '{'
    '"time":"$time_iso8601",'
    '"request":"$request",'
    '"status":$status,'
    '"uri":"$uri",'
    '"sent_cache_control":"$sent_http_cache_control",'
    '"sent_expires":"$sent_http_expires",'
    '"sent_etag":"$sent_http_etag",'
    '"sent_last_modified":"$sent_http_last_modified",'
    '"sent_vary":"$sent_http_vary",'
    '"sent_content_encoding":"$sent_http_content_encoding",'
    '"sent_content_type":"$sent_http_content_type"'
  '}';

Pertanyaan yang bisa dijawab:

PertanyaanField
Apakah HTML long-cache?$sent_http_cache_control
Apakah missing asset ikut immutable?status + cache header
Apakah ETag keluar?$sent_http_etag
Apakah compression variant aman?$sent_http_vary, $sent_http_content_encoding
Apakah MIME policy benar?$sent_http_content_type

25. Production config: SPA static cache policy

server {
    listen 443 ssl http2;
    server_name app.example.com;

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

    include snippets/security-headers.conf;

    location /assets/ {
        include snippets/security-headers.conf;

        try_files $uri @asset_missing;

        gzip_static on;
        gzip_vary on;

        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    location @asset_missing {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache" always;
        return 404;
    }

    location = /index.html {
        include snippets/security-headers.conf;
        try_files /index.html =404;
        add_header Cache-Control "no-cache" always;
    }

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

Review:

PathResult
/assets/app.hash.jsimmutable
/assets/missing.js404 no-cache
/index.htmlno-cache
/deep/spa/routeinternally serves index; verify final cache header

Important: depending on internal redirect behavior and config, validate /deep/spa/route headers directly. Do not infer.


26. Production config: mixed static and proxied app

upstream app_backend {
    server 10.0.20.11:8080;
    server 10.0.20.12:8080;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name www.example.com;

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

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

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

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # App owns dynamic cache policy.
    }
}

Kenapa app owns dynamic cache policy?

Karena app tahu:

  • apakah response user-specific,
  • apakah response punya authorization semantics,
  • apakah data boleh disimpan,
  • apakah response adalah public resource.

NGINX tahu filesystem/static representation. Jangan mencampur domain ownership tanpa alasan.


27. Common incidents

Incident 1 — User stuck on old frontend

Cause:

location / {
    add_header Cache-Control "public, max-age=31536000" always;
    try_files $uri $uri/ /index.html;
}

index.html ikut long-cache.

Fix:

  • HTML no-cache.
  • Assets immutable only if hashed.
  • Test SPA route header.

Incident 2 — Missing JS cached as 404

Cause:

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

Fix:

  • Separate missing asset handling.
  • Avoid long cache for 404.
  • Keep old assets during rollout.

Incident 3 — Security headers disappear on assets

Cause: add_header Cache-Control in child location resets inherited add_header set.

Fix:

  • Repeat/include security headers in child location.
  • Test actual headers.

Incident 4 — CDN caches user-specific API

Cause:

Cache-Control: public, max-age=300

on authenticated response.

Fix:

  • private, no-cache or no-store.
  • Ensure CDN respects Authorization/cache rules.
  • Add tests for authenticated response headers.

Incident 5 — ETag mismatch across nodes

Cause:

  • same content deployed with different file metadata,
  • inconsistent artifact packaging,
  • mixed release nodes.

Fix:

  • atomic deploy,
  • identical artifact across nodes,
  • content-hashed filenames,
  • consider disabling ETag where CDN/cluster behavior is problematic.

28. Review checklist

[ ] Assets with long cache have content hash in filename.
[ ] HTML entrypoint is revalidated, not long-cached.
[ ] SPA fallback route has correct cache policy.
[ ] Missing assets do not get long immutable cache.
[ ] Error pages do not get unsafe cache policy.
[ ] Authenticated content is private/no-cache/no-store as appropriate.
[ ] `add_header` inheritance has been reviewed.
[ ] `always` is used intentionally, not blindly.
[ ] `Vary: Accept-Encoding` exists for compressed variants.
[ ] `ETag`/`Last-Modified` behavior is understood and tested.
[ ] CDN/shared cache behavior is considered if present.
[ ] Curl test matrix covers 200, 301/302, 304, 404, 500.

29. Lab: build a safe cache policy

Create files:

/srv/www/cache-lab/public/index.html
/srv/www/cache-lab/public/assets/app.abc123.js
/srv/www/cache-lab/public/assets/style.def456.css

Config:

server {
    listen 8080;
    server_name cache.local;
    root /srv/www/cache-lab/public;

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

    location @asset_missing {
        add_header Cache-Control "no-cache" always;
        return 404;
    }

    location = /index.html {
        try_files /index.html =404;
        add_header Cache-Control "no-cache" always;
    }

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

Test:

nginx -t

curl -I -H 'Host: cache.local' http://127.0.0.1:8080/
curl -I -H 'Host: cache.local' http://127.0.0.1:8080/index.html
curl -I -H 'Host: cache.local' http://127.0.0.1:8080/deep/link
curl -I -H 'Host: cache.local' http://127.0.0.1:8080/assets/app.abc123.js
curl -I -H 'Host: cache.local' http://127.0.0.1:8080/assets/missing.js

Expected reasoning:

/                       -> HTML behavior, no-cache
/index.html             -> no-cache
/deep/link              -> SPA fallback, verify no-cache
/assets/app.abc123.js   -> immutable
/assets/missing.js      -> 404 no-cache

Then test validators:

curl -I -H 'Host: cache.local' http://127.0.0.1:8080/index.html
# Copy ETag or Last-Modified
curl -I -H 'Host: cache.local' -H 'If-None-Match: "<etag>"' http://127.0.0.1:8080/index.html
curl -I -H 'Host: cache.local' -H 'If-Modified-Since: <last-modified>' http://127.0.0.1:8080/index.html

Observe whether NGINX returns 304 Not Modified.


30. Key takeaways

  1. Cache policy is a distributed contract; once sent to browsers, it is hard to retract.
  2. max-age controls freshness; ETag/Last-Modified support validation.
  3. no-cache means “store but revalidate”, not “do not store”.
  4. no-store means “do not store”.
  5. Content-hashed assets can safely use public, max-age=31536000, immutable.
  6. HTML entrypoints and SPA fallback should usually be revalidated.
  7. add_header inheritance is dangerous if misunderstood.
  8. always is powerful but can accidentally long-cache errors.
  9. Compression requires Vary: Accept-Encoding to keep representation cache safe.
  10. Test cache headers for success, redirect, not found, error, compressed, and conditional request paths.

Part berikutnya membahas error_page, named locations, dan internal error flow: bagaimana NGINX memindahkan request secara internal, bagaimana error handling bisa mengubah status/header/body, dan bagaimana mendesain error responses yang aman untuk API, SPA, reverse proxy, dan static hosting.

Lesson Recap

You just completed lesson 22 in build core. 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.