Serving Static Files

Serving Static Files #

Melayani file statis adalah salah satu hal yang dilakukan Nginx dengan sangat baik — bahkan menjadi salah satu alasan utama Nginx diciptakan. Tapi untuk melakukannya dengan benar di lingkungan production, ada banyak lapisan yang perlu kita pahami: bagaimana Nginx menentukan path file yang harus dibaca, bagaimana transfer file dioptimalkan di level kernel, bagaimana browser diarahkan untuk melakukan caching, dan bagaimana kita memastikan file sensitif tidak terekspos.

Artikel ini akan membedah setiap lapisan secara mendalam.

Bagaimana Nginx Menentukan Path File #

Ketika sebuah request masuk, Nginx perlu menjawab satu pertanyaan: “File mana di filesystem yang harus saya kirimkan?” Jawabannya ditentukan oleh directive root dan URI dari request.

Formula Dasar #

Path file = nilai root + URI request (penuh)

Ini terdengar sederhana, tapi implikasinya penting. Mari kita lihat secara konkret:

server {
    listen 80;
    server_name example.com;

    root /var/www/html;

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

Dengan konfigurasi di atas:

RequestKalkulasiFile yang dibaca
GET //var/www/html + //var/www/html/index.html (via index)
GET /about.html/var/www/html + /about.html/var/www/html/about.html
GET /images/logo.png/var/www/html + /images/logo.png/var/www/html/images/logo.png
GET /css/style.css/var/www/html + /css/style.css/var/www/html/css/style.css

Poin kritis: seluruh URI, termasuk segmen pertama, ditambahkan ke nilai root. Ini berbeda dengan alias yang akan kita bahas di artikel Root & Alias.

Alur Request ke File #

flowchart TD
    A["Browser mengirim request\nGET /images/logo.png HTTP/1.1\nHost: example.com"] --> B["Nginx menerima request\ndi worker process"]
    B --> C{"Cari server block\nyang cocok dengan\nHost header"}
    C --> D["Cocok: server_name example.com\nroot /var/www/html"]
    D --> E{"Cari location block\nyang cocok dengan\nURI /images/logo.png"}
    E --> F["Cocok: location /\ntry_files aktif"]
    F --> G["Kalkulasi path file\n/var/www/html + /images/logo.png\n= /var/www/html/images/logo.png"]
    G --> H{"File ada\ndi filesystem?"}
    H -- Ya --> I["Baca file dari disk\nkirim ke browser\nHTTP 200 OK"]
    H -- Tidak --> J["try_files: coba berikutnya\natau return 404"]

Root di Level Server vs Location #

root bisa diletakkan di context http, server, atau location. Nilai dari context yang lebih dalam akan menimpa context di atasnya (bukan menambahkan). Praktik terbaik adalah meletakkan root di level server, lalu override di location tertentu jika memang diperlukan:

server {
    listen 80;
    server_name example.com;

    # Root default untuk seluruh site
    root /var/www/example.com;

    location / {
        # Mewarisi root dari server block: /var/www/example.com
        try_files $uri $uri/ =404;
    }

    location /docs/ {
        # Override root khusus untuk /docs/
        # GET /docs/guide.html → /var/www/documentation/guide.html
        root /var/www/documentation;

        # PERHATIAN: /docs/ TETAP ikut ke path!
        # Artinya file harus ada di /var/www/documentation/docs/guide.html
        # Kalau file ada di /var/www/documentation/guide.html, gunakan alias!
        try_files $uri =404;
    }

    location /static/ {
        # Jika file ada di /var/www/assets/ (bukan /var/www/assets/static/)
        # gunakan alias, bukan root
        alias /var/www/assets/;
        try_files $uri =404;
    }
}

Ini adalah jebakan yang paling sering membuat developer bingung. Ketika kita menulis root /var/www/documentation di dalam location /docs/, Nginx tetap akan mencari file di /var/www/documentation/docs/ — bukan /var/www/documentation/.


Directive try_files: Cara Cerdas Melayani File #

try_files adalah directive yang paling penting untuk melayani file statis. Ia mencoba lokasi-lokasi yang disebutkan secara berurutan, dan menggunakan yang pertama ditemukan:

location / {
    # Urutan: coba URI sebagai file → coba sebagai direktori → return 404
    try_files $uri $uri/ =404;
}

Mari kita bedah apa yang terjadi untuk GET /blog/post-satu:

  1. $uri — coba /var/www/html/blog/post-satu sebagai file langsung
  2. $uri/ — coba /var/www/html/blog/post-satu/ sebagai direktori (lalu cari index.html di dalamnya)
  3. =404 — jika keduanya gagal, kembalikan 404

Pola try_files untuk Berbagai Kasus #

server {
    root /var/www/app;

    # ─── Static site biasa ───────────────────────────────────
    location / {
        try_files $uri $uri/ =404;
    }

    # ─── SPA (React, Vue, Angular) ───────────────────────────
    # Semua path yang tidak ada filenya dialihkan ke index.html
    # Agar client-side router bisa menangani routing
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ─── PHP dengan index.php sebagai front controller ────────
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # ─── File download dengan fallback ke halaman info ────────
    location /downloads/ {
        try_files $uri =404;
        # Tidak perlu cek $uri/ karena tidak mau listing direktori
    }
}

Mengapa try_files Lebih Baik dari if #

Ada godaan untuk menulis kondisi seperti ini:

# ANTI-PATTERN: gunakan if untuk cek keberadaan file
location / {
    if (!-e $request_filename) {
        return 404;
    }
}

try_files lebih baik karena:

  • Atomik — seluruh pengecekan terjadi dalam satu operasi
  • Lebih aman — tidak ada edge case race condition
  • Lebih efisien — dioptimalkan di internal Nginx
  • Lebih jelas — urutan fallback eksplisit dan mudah dibaca

Optimasi Transfer File: sendfile, tcp_nopush, tcp_nodelay #

Ini adalah trio directive yang bekerja di level kernel untuk memaksimalkan efisiensi transfer file statis.

sendfile: Zero-Copy Transfer #

Tanpa sendfile, proses transfer file melalui jalur yang panjang:

Disk → Kernel buffer → User space (Nginx) → Kernel buffer → Network socket

Dengan sendfile on, Nginx menggunakan system call sendfile() yang memungkinkan kernel mentransfer data langsung dari file descriptor ke socket tanpa menyalinnya ke user space:

Disk → Kernel buffer → Network socket  (langsung, tanpa melewati user space)

Hasilnya: CPU usage jauh lebih rendah, terutama untuk file besar dan traffic tinggi.

http {
    # Aktifkan zero-copy transfer via sendfile()
    # Sangat direkomendasikan untuk semua serving file statis
    sendfile on;

    # Batasi ukuran per sendfile() call
    # Mencegah satu request file besar memblok worker process terlalu lama
    # 0 = tanpa batas (default) — sebaiknya set ke 1m untuk production
    sendfile_max_chunk 1m;
}

tcp_nopush: Batching Paket #

tcp_nopush on mengaktifkan opsi TCP_CORK pada socket. Nginx akan menahan pengiriman data sampai buffer penuh, kemudian mengirimkan semuanya sekaligus dalam satu batch paket TCP:

http {
    sendfile on;

    # Aktifkan TCP_CORK — buffer paket hingga penuh sebelum dikirim
    # Mengurangi jumlah paket TCP (lebih efisien untuk jaringan)
    # Hanya efektif saat sendfile on
    tcp_nopush on;
}

Manfaatnya: mengurangi jumlah paket TCP yang dikirim, yang berarti lebih sedikit overhead header TCP/IP dan lebih sedikit context switch di kernel.

tcp_nodelay: Kirim Segera untuk Data Kecil #

tcp_nodelay on menonaktifkan Nagle’s algorithm — yang biasanya menahan pengiriman data kecil untuk dibundel bersama data lainnya. Untuk koneksi keep-alive yang aktif mengirim data kecil (seperti response API atau HTML kecil), ini mencegah latensi yang tidak perlu:

http {
    sendfile on;
    tcp_nopush on;

    # Kirim data segera tanpa menunggu buffer penuh
    # Berguna untuk koneksi keep-alive setelah sendfile selesai
    tcp_nodelay on;
}

Konfigurasi Lengkap Transfer #

http {
    # Trio optimal untuk serving file statis
    sendfile            on;
    sendfile_max_chunk  1m;
    tcp_nopush          on;
    tcp_nodelay         on;

    # Keep-alive connection — kurangi overhead TCP handshake berulang
    keepalive_timeout   65;
    keepalive_requests  1000;
}

MIME Types: Memberitahu Browser Cara Membaca File #

Browser perlu tahu cara menangani file yang diterima. Informasi ini disampaikan lewat header Content-Type, yang Nginx tentukan berdasarkan ekstensi file dan file mime.types.

Cara Kerja MIME Types di Nginx #

http {
    # Load mapping ekstensi → MIME type dari file bawaan Nginx
    include /etc/nginx/mime.types;

    # Fallback jika ekstensi tidak dikenali
    # application/octet-stream = download binary
    default_type application/octet-stream;
}

File /etc/nginx/mime.types berisi ratusan mapping, contohnya:

text/html                           html htm shtml;
text/css                            css;
text/javascript                     js;
application/json                    json;
image/png                           png;
image/jpeg                          jpeg jpg;
image/svg+xml                       svg svgz;
font/woff                           woff;
font/woff2                          woff2;
application/wasm                    wasm;

Menambah atau Override MIME Type #

server {
    # WebAssembly: pastikan MIME type benar agar browser bisa jalankan
    location ~* \.wasm$ {
        add_header Content-Type application/wasm;
    }

    # Manifest file untuk PWA
    location = /manifest.json {
        add_header Content-Type application/manifest+json;
        expires 1d;
    }

    # Source maps untuk debugging (jangan di production publik!)
    location ~* \.map$ {
        # Batasi hanya dari IP internal
        allow 10.0.0.0/8;
        allow 192.168.0.0/16;
        deny all;
        add_header Content-Type application/json;
    }
}

Cache Header: Optimalkan Kecepatan Loading #

File statis seperti CSS, JavaScript, gambar, dan font jarang berubah. Dengan mengatur header cache yang tepat, kita bisa mengurangi request ke server secara dramatis — pengunjung yang kembali akan mendapatkan konten langsung dari browser cache mereka.

Strategi Cache Busting #

Cara paling efektif adalah dengan menyisipkan hash konten di nama file. Build tool modern (Webpack, Vite, Parcel) sudah melakukan ini secara otomatis:

style.css          → style.a1b2c3d4.css
app.js             → app.e5f6g7h8.js
logo.png           → logo.i9j0k1l2.png

Karena nama file selalu berubah ketika isi file berubah, kita bisa memberi tahu browser untuk menyimpan file ini selamanya — kita yakin nama file yang berbeda = konten yang berbeda.

Konfigurasi Cache per Tipe File #

server {
    root /var/www/html;

    # ─── HTML: jangan cache atau cache sangat singkat ─────────
    # HTML adalah entry point — perubahan harus langsung terlihat
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
    }

    # ─── CSS dan JavaScript dengan hash (cache busting) ───────
    # Nama file sudah mengandung hash: style.abc123.css
    # Aman di-cache selamanya karena nama file PASTI berubah jika isi berubah
    location ~* \.(css|js)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ─── Gambar dan font ──────────────────────────────────────
    location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        log_not_found off;
    }

    # ─── File yang mungkin berubah (JSON, XML, manifest) ──────
    location ~* \.(json|xml)$ {
        expires 1h;
        add_header Cache-Control "public, max-age=3600";
    }
}

Memahami immutable #

immutable di Cache-Control adalah instruksi khusus: “File ini tidak akan pernah berubah selama periode cache. Browser tidak perlu mengirim conditional request (If-Modified-Since atau If-None-Match) untuk memverifikasi — langsung pakai dari cache saja.”

Tanpa immutable, browser biasanya tetap mengirim request ke server untuk memverifikasi freshness file, walau file sudah ada di cache. Dengan immutable, browser benar-benar skip step ini — lebih cepat.

Tanpa immutable:
  Browser → Server: GET /style.abc123.css (If-Modified-Since: ...)
  Server → Browser: 304 Not Modified (tetap ada round trip!)

Dengan immutable:
  Browser: File ada di cache dan belum expired → pakai langsung, tanpa request ke server

Gzip Compression: Kurangi Ukuran Transfer #

Gzip compression mengurangi ukuran file teks secara signifikan sebelum dikirim ke browser. File HTML, CSS, dan JavaScript biasanya bisa dikompres 60-80% dari ukuran aslinya.

http {
    # Aktifkan gzip
    gzip on;

    # Tingkat kompresi: 1 (cepat, kompresi kecil) – 9 (lambat, kompresi besar)
    # Level 4-6 adalah sweet spot antara kecepatan dan ukuran
    gzip_comp_level 6;

    # Hanya kompres file di atas ukuran tertentu
    # File kecil (<1KB) tidak worth untuk dikompres (overhead lebih besar dari manfaat)
    gzip_min_length 1024;

    # Tambahkan header Vary: Accept-Encoding
    # Penting untuk CDN — memastikan CDN cache versi gzip dan non-gzip secara terpisah
    gzip_vary on;

    # Kompres untuk proxy requests juga (bukan hanya direct requests)
    gzip_proxied any;

    # Tipe file yang dikompres
    gzip_types
        text/plain
        text/css
        text/html
        text/javascript
        application/javascript
        application/json
        application/xml
        application/rss+xml
        image/svg+xml
        font/ttf
        font/otf
        application/font-woff
        application/font-woff2;
}

Brotli: Kompresi Lebih Baik dari Gzip #

Jika server mendukung modul Brotli (tersedia di Nginx Plus atau melalui modul third-party), Brotli menghasilkan kompresi 15-25% lebih baik dari gzip untuk file teks:

# Catatan: membutuhkan ngx_brotli module
http {
    # Gunakan Brotli untuk static files (pre-compressed)
    brotli_static on;

    # Aktifkan kompresi Brotli on-the-fly
    brotli on;
    brotli_comp_level 6;
    brotli_types text/plain text/css application/javascript;
}

Keamanan: Blokir Akses File Sensitif #

Static file serving yang tidak hati-hati bisa mengekspos file yang tidak seharusnya publik. Ini adalah konfigurasi keamanan yang wajib ada di setiap setup production:

server {
    root /var/www/html;

    # ─── Blokir file tersembunyi (diawali titik) ──────────────
    # .env, .git, .htaccess, .DS_Store, dll.
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # ─── Blokir file konfigurasi dan backup ───────────────────
    location ~* \.(env|git|svn|bak|backup|sql|sh|py|rb|pl|conf|ini|log)$ {
        deny all;
        log_not_found off;
    }

    # ─── Blokir file PHP yang tidak sengaja ada di public dir ─
    # (berguna jika public dir dan app dir tidak terpisah)
    location ~* \.php$ {
        deny all;
    }

    # ─── Blokir akses ke direktori upload (hanya file langsung) ─
    location /uploads/ {
        # Izinkan akses file gambar saja
        location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
            expires 7d;
        }
        # Blokir semua file lain di /uploads/
        deny all;
    }
}

Mengapa File .env Sering Terekspos #

Kesalahan paling fatal yang sering terjadi adalah meletakkan seluruh repository di document root server. Struktur seperti ini sangat berbahaya:

/var/www/
└── myapp/              ← document root
    ├── .env            ← TEREKSPOS! mengandung DB password, API key
    ├── .git/           ← TEREKSPOS! seluruh source code bisa diunduh
    ├── config.php      ← TEREKSPOS!
    └── public/         ← seharusnya ini yang jadi document root
        └── index.html

Struktur yang benar:

server {
    # Arahkan root ke subdirektori public/
    root /var/www/myapp/public;

    # .env, config.php, dan .git ada di /var/www/myapp/ — tidak pernah bisa diakses
    # karena root kita adalah /var/www/myapp/public/
}

Konfigurasi Lengkap Production: Static Website #

Berikut konfigurasi yang kita rangkum dari seluruh pembahasan di atas — siap untuk static website production, termasuk hasil build dari React, Vue, Nuxt, Next.js, Hugo, atau framework statis lainnya:

# /etc/nginx/conf.d/example.com.conf

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

    # ─── Redirect ke HTTPS ────────────────────────────────────
    return 301 https://$host$request_uri;
}

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

    root /var/www/example.com;
    index index.html;

    # ─── SSL ──────────────────────────────────────────────────
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # ─── Optimasi Transfer ────────────────────────────────────
    sendfile            on;
    sendfile_max_chunk  1m;
    tcp_nopush          on;
    tcp_nodelay         on;

    # ─── Gzip ─────────────────────────────────────────────────
    gzip            on;
    gzip_comp_level 6;
    gzip_min_length 1024;
    gzip_vary       on;
    gzip_proxied    any;
    gzip_types
        text/plain text/css text/html text/javascript
        application/javascript application/json
        image/svg+xml font/woff2;

    # ─── Security Headers ─────────────────────────────────────
    add_header X-Content-Type-Options   "nosniff"        always;
    add_header X-Frame-Options          "SAMEORIGIN"     always;
    add_header X-XSS-Protection         "1; mode=block"  always;
    add_header Referrer-Policy          "strict-origin"  always;
    server_tokens off;

    # ─── SPA Routing ──────────────────────────────────────────
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ─── CSS dan JS dengan hash (immutable cache) ─────────────
    location ~* \.(css|js)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ─── Gambar dan font ──────────────────────────────────────
    location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|woff|woff2|ttf)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        log_not_found off;
    }

    # ─── File yang sering berubah ─────────────────────────────
    location = /manifest.json {
        expires 1d;
        add_header Cache-Control "public, max-age=86400";
    }

    location = /service-worker.js {
        expires -1;
        add_header Cache-Control "no-cache";
    }

    # ─── Keamanan: blokir file sensitif ───────────────────────
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    location ~* \.(env|git|svn|bak|backup|sql|sh|conf|ini|log)$ {
        deny all;
        log_not_found off;
    }
}

Verifikasi dan Debugging #

Cek Headers Respons #

# Cek Content-Type dan Cache-Control
curl -I https://example.com/style.a1b2c3.css

# Output yang diharapkan:
# HTTP/2 200
# content-type: text/css
# cache-control: public, immutable
# expires: [tanggal 1 tahun ke depan]

# Cek apakah gzip aktif
curl -H "Accept-Encoding: gzip" -I https://example.com/app.js
# Harus ada: content-encoding: gzip

# Cek apakah file sensitif benar-benar diblokir
curl -I https://example.com/.env
# Harus: HTTP/2 403 atau 404

# Cek apakah path traversal diblokir
curl -I https://example.com/../etc/passwd
# Harus: HTTP/2 400 Bad Request (Nginx sudah handle ini secara otomatis)

Cek Error Log #

# Lihat error terbaru jika file tidak bisa dilayani
tail -f /var/log/nginx/example.com-error.log

# Error umum:
# [error] ... open() "/var/www/html/favicon.ico" failed (2: No such file)
#   → File tidak ada di path yang diharapkan
#
# [error] ... "/var/www/html/" is forbidden
#   → Tidak ada file index, autoindex off → 403
#
# [error] ... failed (13: Permission denied)
#   → Nginx worker tidak punya izin baca file

Cek Izin File #

# Pastikan Nginx worker bisa membaca file
ls -la /var/www/example.com/
# Direktori harus bisa di-execute (x) oleh user nginx
# File harus bisa dibaca (r) oleh user nginx

# Cara cek user Nginx
ps aux | grep nginx | grep worker
# Biasanya: www-data (Ubuntu/Debian) atau nginx (CentOS/RHEL)

# Set izin yang benar
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
sudo chown -R www-data:www-data /var/www/example.com

Ringkasan #

  • Path file = root + URI penuh — Nginx menggabungkan secara langsung, termasuk segmen pertama URI. Gunakan alias jika nama direktori berbeda dari URL.
  • try_files $uri $uri/ =404 adalah pola standar untuk static site; ganti =404 dengan /index.html untuk SPA.
  • sendfile on + tcp_nopush on mengaktifkan transfer zero-copy di level kernel — wajib aktif untuk performa file statis terbaik.
  • Cache agresif (expires 1y; immutable) untuk CSS/JS/gambar yang menggunakan hash di nama file (cache busting).
  • Gzip compression bisa mengurangi ukuran transfer 60-80% untuk file teks — aktifkan di http context.
  • Blokir file tersembunyi (/\.) dan file sensitif (.env, .git, .sql) secara eksplisit di konfigurasi production.
  • Document root harus menunjuk ke subdirektori public/ — jangan pernah jadikan root repository sebagai document root.

← Sebelumnya: Variable Bawaan   Berikutnya: Virtual Host →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact