SSL certificate problem: unable to get local issuer certificate. Penyebab yang paling sering adalah Nginx hanya menyajikan sertifikat domain tanpa sertifikat intermediate. Sertifikatnya sendiri tidak salah. Yang salah adalah cara file sertifikat dipasang saat migrasi.
Kenapa browser desktop tetap aman padahal rantainya bolong
Beda cara Apache dan Nginx menerima intermediate
SSLCertificateFile untuk sertifikat domain, SSLCertificateKeyFile untuk private key, dan SSLCertificateChainFile untuk intermediate. Sejak Apache 2.4.8, SSLCertificateChainFile sudah deprecated dan SSLCertificateFile boleh berisi leaf beserta intermediate. Meski begitu, banyak konfigurasi lama masih memakai tiga file terpisah.
Masalah muncul saat migrasi. Orang memetakan SSLCertificateFile ke ssl_certificate dan SSLCertificateKeyFile ke ssl_certificate_key, lalu mencari padanan untuk chain file dan tidak menemukannya. Nginx memang tidak punya directive terpisah untuk intermediate yang dikirim ke pengunjung. File yang ditunjuk ssl_certificate harus berisi leaf lebih dulu, lalu diikuti intermediate, dalam satu file PEM.
Ada dua jebakan lain di sini.
Pertama, urutan isi file itu penting. Nginx mencocokkan private key dengan sertifikat pertama di dalam file. Kalau intermediate ditaruh paling atas, uji konfigurasi biasanya gagal dengan error ketidakcocokan key, misalnya key values mismatch.
Kedua, ssl_trusted_certificate bukan solusinya. Directive itu dipakai untuk memverifikasi OCSP stapling dan sertifikat klien. Isinya tidak dikirim ke pengunjung. Rantai tetap bolong walaupun intermediate sudah ditaruh di situ.
Kalau sertifikatnya dari Let's Encrypt via Certbot, folder /etc/letsencrypt/live/<domain>/ berisi cert.pem (leaf saja), chain.pem (intermediate saja), fullchain.pem (leaf dan intermediate), dan privkey.pem. Kesalahan klasiknya adalah mengarahkan ssl_certificate ke cert.pem karena namanya mirip dengan konfigurasi Apache yang lama.
Cek dari sisi klien dengan openssl s_client
BASH
# Lihat rantai yang benar-benar dikirim server (ganti example.com)
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
# Pembanding dari klien yang tidak menambal rantai: curl tanpa -k
curl -sSv -o /dev/null https://example.com/ 2>&1 | grep -iE 'SSL certificate|subject|issuer'-servername mengirim SNI, yaitu nama domain yang diminta saat handshake. Opsi ini wajib kalau satu IP melayani banyak domain. Tanpa SNI, kamu bisa saja melihat sertifikat dari server block default dan menarik kesimpulan yang salah. Opsi -showcerts menampilkan semua sertifikat yang dikirim server, bukan hanya leaf. Untuk mengetes server baru sebelum DNS dipindah, ganti bagian host di -connect dengan IP server baru dan tetap isi -servername dengan nama domain.
Di output, cari blok Certificate chain. Setiap sertifikat diberi nomor kedalaman. Baris yang diawali 0 s: adalah subject leaf, dan baris i: di bawahnya adalah issuer atau penerbitnya. Rantai yang sehat setidaknya punya entri 1 s: yang subject-nya sama dengan issuer milik entri 0. Kalau yang muncul hanya entri 0, berarti server cuma mengirim leaf.
Bagian bawah output juga memberi petunjuk. Verify return code: 0 (ok) berarti rantai lengkap sampai ke root yang dipercaya. Verify return code: 21 (unable to verify the first certificate) adalah tanda khas intermediate tidak dikirim. Format output ini sedikit berbeda antar versi OpenSSL, jadi kalau ada baris tambahan di blok chain, cukup fokus ke baris s: dan i:.
Cek file sertifikat di server sebelum mengubah apa pun
BASH
# File sertifikat yang benar-benar dimuat Nginx, per server_name (butuh root)
sudo nginx -T 2>/dev/null | grep -E 'server_name|ssl_certificate'
# Hitung isi file: fullchain minimal berisi 2 sertifikat
grep -c 'BEGIN CERTIFICATE' /etc/letsencrypt/live/example.com/fullchain.pem
# Subject dan issuer setiap sertifikat di file, sesuai urutannya
openssl crl2pkcs7 -nocrl -certfile /etc/letsencrypt/live/example.com/fullchain.pem | openssl pkcs7 -print_certs -noout
# Cari dua sertifikat yang menempel di satu baris akibat file digabung tanpa newline
grep -n 'END CERTIFICATE-----.*BEGIN' /etc/letsencrypt/live/example.com/fullchain.pem
# Verifikasi lokal: leaf dicek dengan bantuan intermediate
openssl verify -untrusted /etc/letsencrypt/live/example.com/chain.pem /etc/letsencrypt/live/example.com/cert.pempkcs7 -print_certs dari atas ke bawah. Sertifikat pertama harus leaf domainmu. Issuer-nya harus sama dengan subject sertifikat kedua. Kalau urutannya terbalik atau isinya cuma satu sertifikat, ketemulah sumber masalahnya.
Pengecekan sambungan satu baris sering terlewat. Kalau file sertifikat dari CA tidak diakhiri baris baru lalu digabung begitu saja, penanda akhir sertifikat pertama dan penanda awal sertifikat kedua bisa menempel di satu baris. Hasilnya, file gagal dimuat atau sertifikat kedua tidak terbaca. Kalau grep itu menemukan sesuatu, perbaiki file sumbernya dulu.
Sesuaikan path di atas dengan setup kamu. Panel kontrol dan ACME client selain Certbot sering menyimpan sertifikat di lokasi dan nama file yang berbeda. Path yang benar ada di output nginx -T.
Memperbaiki dan memverifikasi ulang
BASH
# Certbot: arahkan ssl_certificate ke fullchain.pem, bukan cert.pem
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# CA komersial: gabungkan leaf dulu, baru intermediate.
# Hati-hati, tanda > menimpa file tujuan. Pakai nama file baru.
cat example.com.crt intermediate-bundle.crt > example.com.fullchain.crt
# Uji konfigurasi, reload hanya kalau lolos
sudo nginx -t && sudo systemctl reload nginx
# Ulangi cek dari sisi klien, tampilkan hanya baris yang penting
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -E '^ *[0-9]+ s:|Verify return code'cat di atas tidak menyentuh file asli, tapi akan menimpa example.com.fullchain.crt kalau file itu sudah ada. Setelah digabung, jalankan lagi pengecekan menempel dan hitung jumlah sertifikat dari bagian sebelumnya pada file baru, baru arahkan ssl_certificate ke file itu.
Setelah reload, hasil akhirnya harus menampilkan entri 0 s: dan 1 s:, ditambah Verify return code: 0 (ok). Ulangi juga perintah curl dari bagian pertama tanpa opsi -k. Kalau sudah tidak ada pesan SSL certificate problem, klien yang ketat pun seharusnya ikut menerima.
Cek semua varian domain, bukan hanya domain utama. Server block untuk www, subdomain lain, atau blok listen IPv6 yang terpisah bisa saja masih menunjuk ke file lama. Output nginx -T tadi membantu memastikan tidak ada yang tertinggal.
Untuk jangka panjang, mengarah ke fullchain.pem di folder live milik Certbot itu aman. Saat renewal, file itu diperbarui beserta intermediate-nya. Untuk sertifikat dari CA komersial, bundle intermediate bisa saja berubah saat perpanjangan, jadi gabungkan ulang dengan bundle terbaru setiap kali sertifikat diganti.
Kebiasaan yang paling berguna setelah migrasi web server adalah menjadikan openssl s_client dengan -servername dan -showcerts sebagai langkah wajib sebelum menyatakan SSL beres. Tampilan gembok di browser desktop bukan bukti. Rantai yang lengkap berarti server mengirim leaf dan intermediate dengan urutan yang benar, dan klien yang tidak memaafkan kesalahan seperti curl ikut menerimanya.