Custom Error Page

Custom Error Page #

Halaman error bawaan Nginx sangat minimalis — teks putih di layar dengan kode status dan tulisan “nginx/1.x.x”. Untuk website production, kita hampir pasti ingin menggantinya: halaman error kustom yang sesuai tampilan brand, memberikan informasi yang berguna bagi pengguna, dan yang paling penting, tidak mengekspos versi server kita kepada penyerang.

Artikel ini akan membahas error_page secara mendalam: cara kerjanya, berbagai pola konfigurasi, menangani error dari backend, dan implementasi production-ready.

Cara Kerja error_page #

error_page mendefinisikan apa yang dikembalikan Nginx ketika respons memiliki kode status tertentu. Directive ini melakukan internal redirect — Nginx seolah-olah menerima request baru untuk URI yang ditentukan, lalu menyervisnya.

sequenceDiagram
    participant B as Browser
    participant N as Nginx
    participant F as Filesystem

    B->>N: GET /page-yang-tidak-ada HTTP/1.1
    N->>F: Cari file: /var/www/html/page-yang-tidak-ada
    F-->>N: Tidak ada (ENOENT)
    Note over N: Status 404 terpicu\nerror_page 404 /404.html
    N->>N: Internal redirect ke GET /404.html
    N->>F: Cari file: /var/www/html/404.html
    F-->>N: File ada
    N->>B: HTTP/1.1 404 Not Found\nContent-Type: text/html\n[konten 404.html]

Poin kunci: browser tetap menerima kode status 404 — bukan 200. Nginx hanya mengubah konten yang dikembalikan, bukan kode statusnya (kecuali kita secara eksplisit mengubahnya, yang akan dibahas nanti).


Sintaks Dasar #

server {
    root /var/www/html;

    # Satu kode status → satu file
    error_page 404 /404.html;

    # Beberapa kode status → satu file yang sama
    error_page 500 502 503 504 /50x.html;

    # Lindungi file error dari akses langsung
    # (pengguna tidak bisa akses http://example.com/404.html langsung)
    location = /404.html {
        internal;
    }

    location = /50x.html {
        internal;
    }
}

Directive internal pada location block error page mencegah pengguna mengakses file tersebut secara langsung dari browser. Tanpa internal, seseorang bisa mengakses http://example.com/404.html dan mendapatkan respons 200 OK — yang membingungkan dan bermasalah untuk SEO.


Menyimpan Error Page di Direktori Terpisah #

Untuk memudahkan manajemen, simpan semua halaman error di satu direktori khusus:

server {
    root /var/www/html;

    # Semua error page ditangani di sini
    error_page 400 /errors/400.html;
    error_page 401 /errors/401.html;
    error_page 403 /errors/403.html;
    error_page 404 /errors/404.html;
    error_page 405 /errors/405.html;
    error_page 408 /errors/408.html;
    error_page 429 /errors/429.html;
    error_page 500 502 503 504 /errors/50x.html;

    # Satu location untuk semua file error
    # File ada di /var/www/html/errors/
    location ^~ /errors/ {
        internal;   # Hanya bisa diakses sebagai internal redirect
        root /var/www/html;
        # Path: root + URI = /var/www/html + /errors/404.html
    }

    # Atau jika file error ada di lokasi terpisah dari web root:
    location ^~ /errors/ {
        internal;
        alias /opt/nginx-error-pages/;
        # Path: alias + (URI setelah /errors/) = /opt/nginx-error-pages/404.html
    }
}

Mengubah Kode Status Respons #

Secara default, error_page mempertahankan kode status asli. Tapi kita bisa mengubahnya menggunakan operator =:

server {
    # ─── Default: tampilkan halaman 404, kembalikan status 404 ────────────────
    error_page 404 /404.html;

    # ─── Tampilkan halaman tapi ganti kode status ─────────────────────────────

    # Ubah 404 → 200 (berguna untuk SPA yang butuh semua path
    # mengembalikan index.html dengan status 200)
    error_page 404 =200 /index.html;

    # Ubah 403 → 404 (menyembunyikan keberadaan resource yang terlarang)
    # Penyerang tidak tahu apakah resource ada tapi terlarang, atau tidak ada sama sekali
    error_page 403 =404 /404.html;

    # Ubah error server ke 503 Service Unavailable
    # (berguna saat maintenance)
    error_page 500 502 504 =503 /maintenance.html;
}

Kasus Khusus: SPA dengan error_page #

Framework SPA seperti React, Vue, dan Angular menangani routing di sisi client. Semua path (termasuk yang “tidak ada” dari perspektif server) harus mengembalikan index.html:

server {
    root /var/www/spa;

    location / {
        try_files $uri $uri/ /index.html;
        # Cara yang lebih direkomendasikan untuk SPA
    }

    # Alternatif menggunakan error_page (kurang direkomendasikan):
    error_page 404 =200 /index.html;
    # Masalah: semua 404 akan jadi 200, termasuk missing asset (CSS, JS, gambar)
    # yang seharusnya tetap 404 agar browser tidak salah cache
}

Error dari Backend: proxy_intercept_errors #

Ketika Nginx digunakan sebagai reverse proxy, error bisa datang dari backend (aplikasi Node.js, Python, PHP, dsb). Secara default, Nginx meneruskan halaman error dari backend langsung ke browser.

Dengan proxy_intercept_errors on, Nginx mengambil alih dan menampilkan halaman error kustom kita:

server {
    location / {
        proxy_pass http://backend;

        # Aktifkan intercepting error dari backend
        proxy_intercept_errors on;

        # Sekarang error_page di server ini berlaku untuk error backend juga
        error_page 502 503 504 /maintenance.html;
        error_page 500 /500.html;
    }

    location = /maintenance.html {
        root /var/www/html;
        internal;
    }

    location = /500.html {
        root /var/www/html;
        internal;
    }
}
flowchart LR
    B["Browser"] --> N["Nginx\nReverse Proxy"]
    N --> BK["Backend\nNode.js, Python, atau PHP"]
    BK -- "Error 502 dari backend" --> N
    N -- "proxy_intercept_errors on\nError 502 di-intercept" --> EP["Ambil /maintenance.html\nKembalikan ke browser"]
    EP --> B

Perbedaan dengan dan tanpa proxy_intercept_errors #

Tanpa proxy_intercept_errors:
  Browser ← Nginx ← Backend mengirim halaman error sendiri (template error Express.js, dsb)

Dengan proxy_intercept_errors on:
  Browser ← Nginx menampilkan /maintenance.html kita
              (halaman error backend diabaikan)

Named Location sebagai Error Handler #

Untuk logika error yang lebih kompleks, kita bisa menggunakan named location:

server {
    location / {
        proxy_pass http://backend;
        proxy_intercept_errors on;

        error_page 502 503 @maintenance;
        error_page 500 @server_error;
    }

    location @maintenance {
        root /var/www/html;
        try_files /maintenance.html =503;

        # Set header tambahan
        add_header Retry-After 3600;
        add_header Cache-Control "no-store";
    }

    location @server_error {
        root /var/www/html;
        try_files /500.html =500;

        # Log error yang tidak terduga
        access_log /var/log/nginx/500-errors.log;
    }
}

Redirect ke URL Eksternal saat Error #

error_page bisa juga redirect ke URL eksternal, meskipun ini akan mengubah kode status menjadi redirect (301/302):

# Redirect ke halaman status eksternal
error_page 503 https://status.example.com;

# Catatan: ini akan mengembalikan 302 ke browser,
# bukan 503. Browser mengikuti redirect.

# Lebih baik: gunakan internal redirect ke file lokal
# yang bisa mengambil konten dari halaman status jika perlu

Menyembunyikan Versi Nginx #

Halaman error bawaan Nginx secara default menampilkan versi:

<hr><center>nginx/1.24.0</center>

Ini memberikan informasi yang tidak perlu kepada penyerang — mereka bisa mencari CVE yang spesifik untuk versi tersebut. Matikan dengan server_tokens:

http {
    # Sembunyikan versi Nginx dari:
    # - Header "Server" di setiap response
    # - Halaman error bawaan Nginx
    server_tokens off;

    # Dengan server_tokens off:
    # Header: Server: nginx   (tanpa versi)
    # Error page: nginx       (tanpa versi)
}

Jika ingin menyembunyikan sepenuhnya bahwa server menggunakan Nginx (bukan hanya versinya):

# Dengan Nginx Plus atau modul nginx-headers-more:
more_set_headers 'Server: MyApp';

# Tanpa modul tambahan, kita hanya bisa hide versi, bukan nama server
# (kecuali compile Nginx dengan custom build)

Template Halaman Error HTML #

Berikut contoh template halaman error yang profesional, responsif, dan informative:

404 Not Found #

<!-- /var/www/html/errors/404.html -->
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>404 — Halaman Tidak Ditemukan</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f8fafc;
            color: #334155;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 2rem;
        }
        .container {
            text-align: center;
            max-width: 500px;
        }
        .code {
            font-size: 8rem;
            font-weight: 900;
            color: #e2e8f0;
            line-height: 1;
            margin-bottom: 1rem;
        }
        h1 { font-size: 1.5rem; margin-bottom: 0.75rem; }
        p { color: #64748b; margin-bottom: 2rem; line-height: 1.6; }
        .btn {
            display: inline-block;
            background: #3b82f6;
            color: white;
            padding: 0.75rem 2rem;
            border-radius: 8px;
            text-decoration: none;
            font-weight: 600;
            transition: background 0.2s;
        }
        .btn:hover { background: #2563eb; }
    </style>
</head>
<body>
    <div class="container">
        <div class="code">404</div>
        <h1>Halaman tidak ditemukan</h1>
        <p>Halaman yang kamu cari mungkin telah dipindahkan, dihapus, atau URL yang kamu ketik tidak tepat.</p>
        <a href="/" class="btn">Kembali ke Beranda</a>
    </div>
</body>
</html>

50x Server Error #

<!-- /var/www/html/errors/50x.html -->
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Terjadi Kesalahan — Coba Lagi Nanti</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #fff7f7;
            color: #334155;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 2rem;
        }
        .container { text-align: center; max-width: 500px; }
        .icon { font-size: 4rem; margin-bottom: 1.5rem; }
        h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #dc2626; }
        p { color: #64748b; margin-bottom: 1rem; line-height: 1.6; }
        .btn {
            display: inline-block;
            background: #dc2626;
            color: white;
            padding: 0.75rem 2rem;
            border-radius: 8px;
            text-decoration: none;
            font-weight: 600;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="icon">⚠️</div>
        <h1>Terjadi kesalahan pada server</h1>
        <p>Server mengalami masalah sementara dan tidak dapat memproses permintaanmu sekarang.</p>
        <p>Tim kami sudah mendapat notifikasi dan sedang menangani masalah ini. Coba lagi dalam beberapa menit.</p>
        <a href="javascript:location.reload()" class="btn">Coba Lagi</a>
    </div>
</body>
</html>

Konfigurasi error_page Production Lengkap #

Berikut adalah konfigurasi error_page yang komprehensif untuk server production:

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

    root /var/www/example.com;

    # ─── Keamanan: sembunyikan versi Nginx ────────────────────────────────────
    server_tokens off;

    # ─── Error pages untuk berbagai kode status ───────────────────────────────
    error_page 400 /errors/400.html;      # Bad Request
    error_page 401 /errors/401.html;      # Unauthorized
    error_page 403 /errors/403.html;      # Forbidden
    error_page 404 /errors/404.html;      # Not Found
    error_page 408 /errors/408.html;      # Request Timeout
    error_page 429 /errors/429.html;      # Too Many Requests (rate limiting)
    error_page 500 /errors/500.html;      # Internal Server Error
    error_page 502 /errors/502.html;      # Bad Gateway (backend down)
    error_page 503 /errors/503.html;      # Service Unavailable (maintenance)
    error_page 504 /errors/504.html;      # Gateway Timeout

    # ─── Location untuk semua error pages ────────────────────────────────────
    location ^~ /errors/ {
        internal;
        root /var/www/example.com;
        # File ada di /var/www/example.com/errors/404.html dsb.
    }

    # ─── Main routing ─────────────────────────────────────────────────────────
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Intercept error dari backend dan tampilkan halaman kustom kita
        proxy_intercept_errors on;

        # Timeout yang wajar
        proxy_connect_timeout  5s;
        proxy_read_timeout    60s;
        proxy_send_timeout    60s;
    }

    # ─── Maintenance mode: aktifkan dengan flag file ──────────────────────────
    # Buat file /var/www/example.com/maintenance.flag untuk masuk maintenance mode
    set $maintenance 0;
    if (-f $document_root/maintenance.flag) {
        set $maintenance 1;
    }

    location / {
        if ($maintenance = 1) {
            return 503;
        }
        proxy_pass http://backend;
        proxy_intercept_errors on;
        error_page 503 /errors/503.html;
    }
}

Debugging error_page yang Tidak Berjalan #

Ada beberapa kasus yang membuat error_page tidak berjalan seperti yang diharapkan:

Kasus 1: Recursion Protection #

Jika file error page itu sendiri menghasilkan error, Nginx tidak akan infinite loop — tapi juga tidak akan menampilkan error page lebih lanjut:

# Cek apakah file error page ada dan bisa dibaca
ls -la /var/www/html/errors/404.html
cat /var/www/html/errors/404.html | head -5

# Cek error log untuk "open() ... failed"
tail -f /var/log/nginx/error.log

Kasus 2: error_page Tidak Berlaku untuk Backend Error #

# SALAH: proxy_intercept_errors tidak aktif
location / {
    proxy_pass http://backend;
    error_page 502 /maintenance.html;  # Tidak akan berjalan!
}

# BENAR: proxy_intercept_errors harus aktif
location / {
    proxy_pass http://backend;
    proxy_intercept_errors on;  # ← Ini yang mengaktifkan
    error_page 502 /maintenance.html;
}

Kasus 3: File error_page Tidak Ada #

# Nginx akan log error dan mungkin menampilkan error bawaan
# jika file yang direferensikan di error_page tidak ada:
# [error] open() "/var/www/html/404.html" failed (2: No such file or directory)

Kasus 4: Konflik dengan try_files #

location / {
    # try_files mengembalikan =404 secara internal
    # ini BUKAN error HTTP 404 yang biasa — ini adalah "pseudo-request"
    # yang di-handle secara berbeda
    try_files $uri $uri/ =404;
    # error_page 404 AKAN terpicu oleh ini
}

Ringkasan #

  • error_page 404 /404.html — directive dasar untuk menentukan halaman error kustom per kode status. Nginx melakukan internal redirect ke URI tersebut.
  • Selalu tambahkan internal; di location block yang melayani error page — mencegah akses langsung dari browser.
  • proxy_intercept_errors on diperlukan agar Nginx menampilkan halaman error kustom saat backend mengembalikan error — tanpanya, halaman error dari backend yang diteruskan langsung.
  • server_tokens off di http context untuk menyembunyikan versi Nginx dari header Server dan halaman error bawaan — wajib di production.
  • error_page 403 =404 untuk menyembunyikan keberadaan resource terlarang — penyerang tidak bisa membedakan apakah resource tidak ada atau ada tapi diblokir.
  • error_page 404 =200 /index.html untuk SPA, tapi try_files $uri $uri/ /index.html lebih direkomendasikan karena lebih spesifik (hanya non-existent paths, bukan semua 404).
  • Gunakan ^~ /errors/ + internal untuk mengelola semua error page dalam satu direktori.

← Sebelumnya: Index & Autoindex   Berikutnya: Konsep Reverse Proxy →

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