diff --git a/_posts/2026-11-05-ssl-labs-a-plus-apache-id.md b/_posts/2026-11-05-ssl-labs-a-plus-apache-id.md
new file mode 100644
index 0000000..4ebc14c
--- /dev/null
+++ b/_posts/2026-11-05-ssl-labs-a-plus-apache-id.md
@@ -0,0 +1,390 @@
+---
+layout: post
+title: "Cara Mendapat A+ di SSL Labs dengan Apache (Panduan 2026)"
+description: "Konfigurasi mod_ssl Apache untuk nilai A+ di SSL Labs: TLS 1.3, cipher ECDHE, full chain, HSTS dengan Header always, dan jebakan configtest."
+image: "/img/ssl-labs-a-plus-apache-id.png"
+date: 2026-11-05
+categories:
+ - Linux
+tags:
+ - apache
+ - tls
+ - ssl
+ - security
+ - hsts
+---
+
+Untuk dapat A+ di [SSL Labs Server Test](https://www.ssllabs.com/ssltest/) dari Qualys dengan Apache httpd, syaratnya sama dengan web server lain: hanya TLS 1.2 dan TLS 1.3, cipher ECDHE dengan AEAD (GCM atau ChaCha20-Poly1305), sertifikat dikirim bersama chain lengkapnya, header HSTS dengan `max-age` minimal enam bulan, dan konfigurasi yang sama di semua alamat IP hostname tersebut. Di Apache, ada dua hal yang menentukan berhasil atau tidaknya: header HSTS harus memakai `Header always set`, bukan `Header set`, dan virtual host yang punya `SSLProtocol` atau `SSLCipherSuite` sendiri akan diam-diam mengalahkan pengaturan global.
+
+Tulisan ini memberi konfigurasi yang bisa langsung dipasang di Apache Debian atau Ubuntu, menjelaskan tiap barisnya, dan menunjukkan cara mengecek hasilnya sebelum menjalankan SSL Labs.
+
+Konfigurasi di bawah sudah diuji di Apache 2.4.69 dengan OpenSSL 3.5.7, memakai layout `/etc/apache2` bawaan Debian 13 dengan modul, `tls.conf`, dan file site dari tulisan ini diaktifkan, lalu dicek dengan `openssl s_client` dan `curl`.
+
+**Read in English:** [How to Get an A+ on SSL Labs with Apache httpd]({% post_url 2026-11-05-ssl-labs-a-plus-apache %}). Pakai nginx? Lihat [Cara Mendapat A+ di SSL Labs dengan nginx]({% post_url 2026-11-05-ssl-labs-a-plus-nginx-id %}).
+
+
+
+
+Daftar isi
+
+* TOC
+{:toc}
+
+
+## Cara SSL Labs memberi nilai
+
+SSL Labs menilai dukungan protokol (30%), key exchange (30%), dan kekuatan cipher (40%), lalu memberlakukan batas atas (cap) untuk masalah tertentu. A+ adalah bonus untuk server ber-grade A tanpa warning. Aturan yang berlaku sekarang adalah versi 2009r dari [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), tertanggal 16 Mei 2025. Batas-batas yang relevan untuk server Apache pada umumnya:
+
+| Masalah | Grade maksimal |
+|---|---|
+| SSLv2, SSLv3 sebagai protokol terbaik, cipher export, bug sekelas Heartbleed | F |
+| Tidak mendukung TLS 1.2 | C |
+| TLS 1.0 atau TLS 1.1 aktif | B |
+| Tanpa forward secrecy (tanpa ECDHE/DHE) | B |
+| Tanpa cipher AEAD (GCM atau ChaCha20-Poly1305) | B |
+| Chain sertifikat tidak lengkap | B |
+| Key atau parameter DH di bawah 2048 bit | B |
+| Tidak mendukung TLS 1.3 | A- |
+| HSTS tidak ada atau tidak valid | A- |
+
+Untuk A+, panduan itu mensyaratkan konfigurasi tanpa warning dan HSTS dengan `max-age` minimal enam bulan (15768000 detik). Sejak revisi Mei 2025, sekadar dapat A pun butuh TLS 1.3 sekaligus header HSTS yang valid, dan `TLS_FALLBACK_SCSV` tidak dihitung lagi.
+
+Sertifikat ECDSA P-256 atau RSA 2048 sudah cukup. Nilai key exchange-nya 90 dan tetap dapat A+.
+
+## Cek versi Apache dan OpenSSL dulu
+
+```bash
+apache2 -v
+apache2ctl -M | grep -e ssl -e headers -e rewrite -e http2
+```
+
+Versi OpenSSL yang dipakai Apache tercetak di error log saat start, di baris seperti `Apache/2.4.69 (Debian) OpenSSL/3.5.7 configured -- resuming normal operations`.
+
+| Fitur | Butuh |
+|---|---|
+| TLS 1.3 (dan baris `SSLCipherSuite TLSv1.3 ...`) | Apache 2.4.37+ dengan OpenSSL 1.1.1+ (dukungan TLS 1.3 tercatat di 2.4.36, versi yang tidak pernah dirilis) |
+| Intermediate di `SSLCertificateFile` (tanpa `SSLCertificateChainFile`) | Apache 2.4.8+ |
+| `SSLOpenSSLConfCmd` (dipakai di sini untuk mengatur kurva) | Apache 2.4.8+ dengan OpenSSL 1.0.2+ |
+| `Protocols h2 http/1.1` (HTTP/2) | Apache 2.4.17+ |
+| `SSLProtocol` per virtual host dihormati saat ada SNI | Apache 2.4.42+ dengan OpenSSL 1.1.1+ |
+| Grup hybrid post-quantum `X25519MLKEM768` | OpenSSL 3.5+ |
+
+## Langkah 1: Sertifikat dan full chain
+
+Dengan Let's Encrypt dan Certbot, ambil sertifikatnya saja, lalu tulis konfigurasi TLS sendiri:
+
+```bash
+sudo certbot certonly --apache -d example.com -d www.example.com
+```
+
+Certbot 2.0 ke atas membuat key ECDSA secara default.
+
+Arahkan Apache ke `fullchain.pem`:
+
+```apache
+SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+```
+
+Sejak Apache 2.4.8, `SSLCertificateFile` boleh berisi sertifikat intermediate setelah sertifikat utama, dan [dokumentasi mod_ssl](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslcertificatefile) menyebut `SSLCertificateChainFile` sudah usang. Kalau `SSLCertificateFile` diarahkan ke `cert.pem` dan chain file dibuang, intermediate-nya hilang. Browser sering melengkapinya sendiri, tapi SSL Labs akan menulis "Chain issues: Incomplete" dan membatasi grade di B.
+
+Kalau memakai installer Certbot (`certbot --apache` tanpa `certonly`), ia menambahkan `Include /etc/letsencrypt/options-ssl-apache.conf` di dalam virtual host. File itu mengatur protokol dan cipher sendiri, dan karena letaknya di virtual host, ia mengalahkan file global dari Langkah 2. Hapus baris include itu, tapi lihat dulu apa lagi yang diatur file tersebut: [`options-ssl-apache.conf`](https://github.com/certbot/certbot/blob/main/certbot/src/certbot/_internal/plugins/apache/tls_configs/current-options-ssl-apache.conf) versi sekarang diawali `SSLEngine on`. Cek dengan `grep -n SSLEngine /etc/letsencrypt/options-ssl-apache.conf` dan, kalau ada, tambahkan `SSLEngine on` ke virtual host itu sendiri. Tanpa baris itu, Apache melayani HTTP biasa di port 443 tanpa pesan error.
+
+## Langkah 2: Aktifkan modul dan atur TLS secara global
+
+Aktifkan modul yang dipakai di tulisan ini:
+
+```bash
+sudo a2enmod ssl headers rewrite http2
+```
+
+`a2enmod ssl` sekaligus mengaktifkan `socache_shmcb` untuk session cache. Di Debian 13, `/etc/apache2/mods-available/ssl.conf` bawaan lalu berisi baris-baris ini (komentar dibuang):
+
+```apache
+SSLSessionCache shmcb:${APACHE_RUN_DIR}/ssl_scache(512000)
+SSLSessionCacheTimeout 300
+SSLCipherSuite HIGH:!aNULL
+SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
+SSLSessionTickets off
+```
+
+Protokolnya sudah benar. Daftar cipher-nya yang terlalu longgar. Dengan pengaturan bawaan saja, server tes masih menerima suite CBC, key exchange RSA biasa tanpa forward secrecy, dan DHE:
+
+```text
+-cipher 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' -> Cipher is ECDHE-ECDSA-AES128-SHA
+-cipher 'AES256-GCM-SHA384' -> Cipher is AES256-GCM-SHA384
+-cipher 'DHE-RSA-AES256-GCM-SHA384' -> Cipher is DHE-RSA-AES256-GCM-SHA384
+```
+
+SSL Labs menandai suite CBC dan RSA biasa dengan label WEAK di daftar cipher-nya, dan TLSRef tidak memasukkan DHE sama sekali. Tidak ada alasan untuk tetap menawarkan semuanya. Rilis lain bisa punya nilai bawaan berbeda, jadi cek punya Anda dengan `grep -v '^\s*#' /etc/apache2/mods-available/ssl.conf`.
+
+Biarkan `ssl.conf` apa adanya, lalu taruh pengaturan di `/etc/apache2/conf-available/tls.conf`:
+
+```apache
+# Pengaturan TLS untuk semua virtual host HTTPS (konteks server config)
+SSLProtocol -all +TLSv1.2 +TLSv1.3
+
+# OpenSSL 3.5+: grup hybrid post-quantum di depan.
+# Di OpenSSL 3.0-3.4, hapus X25519MLKEM768 dari daftar ini.
+SSLOpenSSLConfCmd Curves X25519MLKEM768:X25519:prime256v1:secp384r1
+
+# TLS 1.2: hanya key exchange ECDHE + AEAD. TLS 1.3: tiga suite standar.
+SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
+SSLCipherSuite TLSv1.3 TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
+SSLHonorCipherOrder off
+SSLSessionTickets off
+```
+
+Aktifkan:
+
+```bash
+sudo a2enconf tls
+```
+
+`apache2.conf` di Debian membaca `mods-enabled/*.conf` lebih dulu dan `conf-enabled/*.conf` belakangan. Untuk directive-directive ini, nilai global yang datang belakangan menggantikan yang sebelumnya, jadi `tls.conf` menang atas `ssl.conf` tanpa perlu mengedit file itu. Di server tes, ketiga suite lemah di atas ditolak begitu `tls.conf` aktif.
+
+Penjelasan per baris:
+
+- **`SSLProtocol -all +TLSv1.2 +TLSv1.3`** mulai dari kosong dan hanya mengizinkan TLS 1.2 dan 1.3. TLS 1.0 atau 1.1 membuat grade mentok di B; tanpa TLS 1.3 mentok di A-.
+- **`SSLOpenSSLConfCmd Curves`** meneruskan daftar grup key exchange ke OpenSSL. Daftarnya diambil dari [TLSRef guideline 6.0](https://data.tlsref.org/guidelines/6.0.json), penerus Server Side TLS guidelines dari Mozilla, dan sama dengan yang dihasilkan [TLSRef Configurator](https://configurator.tlsref.org/) untuk Apache. `X25519MLKEM768` menggabungkan X25519 dengan algoritma post-quantum ML-KEM; klien yang belum mendukungnya turun ke X25519.
+- **`SSLCipherSuite` pertama** hanya berisi suite ECDHE dengan GCM atau ChaCha20-Poly1305: forward secrecy dan AEAD, tidak ada yang lain. Menurut TLSRef, klien tertua yang masih bisa tersambung adalah Firefox 31, Chrome 49, Safari 9, Android 4.4.2, IE 11 di Windows 10, dan Java 8u161.
+- **`SSLCipherSuite TLSv1.3 ...`** mengatur suite TLS 1.3. Ketiganya memang default OpenSSL; baris ini membuatnya eksplisit.
+- **`SSLHonorCipherOrder off`** membiarkan klien memilih dari daftar. Semua pilihan sudah kuat, dan ponsel tanpa akselerasi AES di hardware akan memilih ChaCha20.
+- **`SSLSessionTickets off`**. Dokumentasi mod_ssl memperingatkan bahwa ticket yang dipakai tanpa me-restart web server secara berkala (misalnya harian) merusak forward secrecy. Apache tidak merotasi key ticket sendiri, jadi TLSRef mematikannya. Session resumption tetap jalan lewat session cache `shmcb` dari `ssl.conf`.
+- **Tanpa parameter DH.** Daftar cipher tidak berisi suite DHE, jadi parameter DH tidak perlu ditambahkan ke file sertifikat.
+
+## Langkah 3: Virtual host, HSTS, dan redirect HTTP
+
+Buat `/etc/apache2/sites-available/example.com.conf`:
+
+```apache
+
+ ServerName example.com
+ ServerAlias www.example.com
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^.*$ https://%{HTTP_HOST}%{REQUEST_URI} [R=308,QSA,L]
+
+
+
+ ServerName example.com
+ ServerAlias www.example.com
+ DocumentRoot /var/www/example.com
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+
+ Protocols h2 http/1.1
+
+ # HSTS: 2 tahun. "always" juga mengirimnya di redirect dan halaman error.
+ Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
+
+```
+
+Lalu aktifkan site-nya dan reload:
+
+```bash
+sudo a2ensite example.com
+sudo apache2ctl configtest && sudo systemctl reload apache2
+```
+
+Baris `RewriteCond` membiarkan `/.well-known/acme-challenge/` tetap di HTTP biasa, supaya validasi HTTP-01 Let's Encrypt dijawab langsung di port 80, tanpa bergantung pada redirect dan konfigurasi HTTPS. Redirect-nya 308, sama dengan config TLSRef.
+
+### Kenapa harus `Header always set`
+
+`mod_headers` menyimpan dua daftar header respons. Daftar default (`onsuccess`) dipakai untuk respons normal; [dokumentasi mod_headers](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) menyebut bahwa untuk respons non-2xx yang dibuat server sendiri, seperti redirect, hanya daftar `always` yang dipakai.
+
+Di server tes, dengan `Header set` alih-alih `Header always set` dan baris `Redirect 302 /old /` di virtual host `*:443`:
+
+```text
+GET / -> HTTP/2 200 strict-transport-security: max-age=63072000; includeSubDomains
+GET /old -> HTTP/2 302 (tanpa header HSTS)
+GET /nope -> HTTP/2 404 (tanpa header HSTS)
+```
+
+Dengan `Header always set`, ketiga respons membawa header itu. Ini paling terasa kalau halaman utama Anda me-redirect, misalnya `/` ke `/en/` atau ke halaman login: respons HTTPS pertama yang diterima pengunjung adalah redirect itu, dan tanpa `always` ia tidak membawa HSTS.
+
+Catatan HSTS lainnya:
+
+- **`max-age=63072000`** sama dengan dua tahun, nilai dari TLSRef. SSL Labs butuh minimal 15768000 (enam bulan) untuk A+.
+- **`includeSubDomains`** membuat semua subdomain wajib HTTPS di browser yang pernah menerima header ini. Jangan dipasang sebelum itu benar untuk semua subdomain.
+- Header ini hanya di virtual host `*:443`. Browser mengabaikan HSTS lewat HTTP biasa, dan [RFC 6797 bagian 7.2](https://www.rfc-editor.org/rfc/rfc6797#section-7.2) melarang server mengirimnya di sana.
+
+Apache tidak punya masalah pewarisan `add_header` seperti nginx. `Header set Cache-Control ...` di dalam blok `` ditambahkan ke header dari virtual host; di server tes, header HSTS tetap ada di respons tersebut.
+
+## Langkah 4: Waspadai virtual host yang menimpa pengaturan global
+
+`tls.conf` mengatur nilai default untuk seluruh server. Virtual host yang punya `SSLProtocol`, `SSLCipherSuite` sendiri, atau meng-`Include` file yang mengaturnya, akan memakai nilai itu. Sejak Apache 2.4.42 dengan OpenSSL 1.1.1 ke atas, [dokumentasi SSLProtocol](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslprotocol) menyebut `SSLProtocol` milik tiap virtual host name-based dihormati kalau klien mengirim SNI. Sebelum itu, protokol dari virtual host pertama di IP dan port tersebut berlaku untuk semuanya.
+
+Cari semua tempat yang mengatur opsi TLS:
+
+```bash
+grep -rn -e SSLProtocol -e SSLCipherSuite -e SSLHonorCipherOrder -e options-ssl-apache /etc/apache2/
+apache2ctl -S
+```
+
+Perintah pertama seharusnya hanya menampilkan `mods-available/ssl.conf` dan `tls.conf` Anda. Perintah kedua menunjukkan virtual host mana yang menjawab di alamat mana, berguna kalau SSL Labs memberi nilai berbeda untuk IP yang berbeda.
+
+## Langkah 5: OCSP stapling dan Let's Encrypt
+
+`SSLUseStapling` mati secara default di Apache, dan untuk sertifikat Let's Encrypt sebaiknya tetap begitu. Let's Encrypt [menghentikan dukungan OCSP](https://letsencrypt.org/2024/12/05/ending-ocsp/): sejak 7 Mei 2025 sertifikatnya tidak lagi memuat URL OCSP, dan responder-nya dimatikan pada 6 Agustus 2025. Tidak ada yang bisa di-staple. (Tulisan saya tahun 2015, [OCSP Stapling di nginx]({% post_url 2015-04-12-ocsp-stapling-di-nginx %}), ditulis sebelum perubahan ini.) OCSP stapling juga tidak memengaruhi grade SSL Labs.
+
+Untuk sertifikat dari CA yang masih menjalankan OCSP, aktifkan di server config (di luar ``), misalnya di akhir `tls.conf`:
+
+```apache
+SSLUseStapling on
+SSLStaplingCache "shmcb:${APACHE_RUN_DIR}/ssl_stapling(32768)"
+```
+
+## Langkah 6: Jebakan configtest
+
+`apache2ctl configtest` hanya mengecek sintaks, bukan apakah OpenSSL menerima nilai yang Anda tulis. Di server tes, nama kurva yang tidak dikenal OpenSSL lolos cek sintaks lalu menghentikan Apache:
+
+```text
+$ apache2ctl configtest
+Syntax OK
+$ apache2ctl graceful
+# error log:
+[ssl:emerg] AH02407: "SSLOpenSSLConfCmd Curves FakeGroup999:X25519:prime256v1:secp384r1" failed for example.test:443
+[ssl:emerg] AH02312: Fatal error initialising mod_ssl, exiting.
+[:emerg] AH00020: Configuration Failed, exiting
+```
+
+Graceful reload tidak menyelamatkan Anda di sini. Setelah reload itu, tidak ada satu pun proses Apache yang tersisa, jadi HTTP biasa ikut mati. OpenSSL sebelum 3.5 tidak mengenal `X25519MLKEM768`, jadi nama itu bisa memicu kegagalan yang sama.
+
+Karena itu, cek nama grup ke OpenSSL sebelum reload. Di server tes (OpenSSL 3.5.7):
+
+```bash
+openssl list -tls-groups | tr ':' '\n' | grep -x X25519MLKEM768
+```
+
+Kalau tidak ada output, atau `openssl` Anda tidak mengenal opsi `-tls-groups`, hapus `X25519MLKEM768` dari `tls.conf`. Debian dan Ubuntu membangun Apache dengan OpenSSL sistem, jadi perintah `openssl` mengecek library yang sama; untuk build sendiri, bandingkan dengan versi di baris log saat Apache start. Setiap kali mengubah TLS, reload lalu pastikan situs masih menjawab (`curl -sI https://example.com/`) sebelum logout.
+
+## Langkah 7: Uji di lokal, lalu jalankan SSL Labs
+
+Ganti `example.com` dengan host Anda:
+
+```bash
+# TLS 1.1 harus ditolak. SECLEVEL=0 supaya klien modern mau mencobanya.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' &1 | grep -E 'alert|Cipher is'
+
+# TLS 1.3 harus jalan. Dengan OpenSSL 3.5+ di kedua sisi, grupnya X25519MLKEM768.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_3 &1 | grep -E 'Negotiated TLS1.3 group|Cipher is'
+
+# Suite CBC, RSA biasa, dan DHE harus ditolak.
+for c in 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' AES256-GCM-SHA384 DHE-RSA-AES256-GCM-SHA384; do
+ openssl s_client -connect example.com:443 -servername example.com -tls1_2 \
+ -cipher "$c" &1 | grep -E 'alert|Cipher is' | head -1
+done
+
+# Full chain: server harus mengirim minimal 2 sertifikat (milik Anda + intermediate).
+openssl s_client -connect example.com:443 -servername example.com -showcerts /dev/null | grep -c 'BEGIN CERTIFICATE'
+
+# HSTS di halaman normal dan halaman error, plus redirect HTTP.
+curl -sI https://example.com/ | grep -i strict-transport-security
+curl -sI https://example.com/does-not-exist | grep -i -e '^HTTP' -e strict-transport-security
+curl -sI http://example.com/ | grep -i -e '^HTTP' -e '^location'
+```
+
+Di server tes, hasilnya seperti ini (diringkas):
+
+```text
+tls1_1 : tlsv1 alert protocol version ... Cipher is (NONE)
+tls1_3 : Negotiated TLS1.3 group: X25519MLKEM768
+ New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
+CBC : ssl/tls alert handshake failure
+RSA : ssl/tls alert handshake failure
+DHE : ssl/tls alert handshake failure
+404 : HTTP/2 404 + strict-transport-security: max-age=63072000; includeSubDomains
+HTTP : HTTP/1.1 308 Permanent Redirect
+```
+
+Setelah itu buka [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/), masukkan hostname, dan centang "Do not show the results on the boards" kalau tidak ingin hasilnya tampil di daftar publik. Setelah mengubah konfigurasi, klik "Clear cache" di halaman hasil supaya scan diulang.
+
+## Troubleshooting: arti pesan di laporan dan cara memperbaikinya
+
+| Yang muncul | Penyebab | Perbaikan |
+|---|---|---|
+| Grade B, "This server supports TLS 1.0 and TLS 1.1" | Ada virtual host atau include yang mengatur `SSLProtocol` sendiri | Jalankan `grep` dari Langkah 4; hapus pengaturan itu |
+| Suite WEAK (CBC, `TLS_RSA_*`) di daftar cipher | `SSLCipherSuite` bawaan distro, atau pengaturan di virtual host | Aktifkan `tls.conf`; cek dengan `grep` dari Langkah 4 |
+| Grade B, "Chain issues: Incomplete" | `SSLCertificateFile` mengarah ke `cert.pem` | Pakai `fullchain.pem` |
+| Grade A-, "This server does not support TLS 1.3" | Apache sebelum 2.4.37, OpenSSL sebelum 1.1.1, atau `SSLProtocol` tanpa `+TLSv1.3` | Upgrade; cek kedua versi di baris log saat Apache start |
+| Grade A-, HSTS tidak terdeteksi padahal sudah dipasang | `mod_headers` belum aktif, header dipasang di virtual host yang salah, atau `Header set` tanpa `always` di halaman yang me-redirect | `a2enmod headers`; pasang `Header always set` di virtual host `*:443` |
+| `Invalid command 'Header'` atau `'RewriteEngine'` | Modul belum aktif | `a2enmod headers rewrite` |
+| Apache berhenti setelah reload padahal `configtest` bilang Syntax OK (`AH02407`, `AH02312`) | OpenSSL menolak suatu nilai, sering `X25519MLKEM768` di OpenSSL sebelum 3.5 | Hapus grup itu, start Apache lagi; lihat Langkah 6 |
+| A+ di satu IP, A- di IP lain | IPv6 dilayani virtual host atau mesin lain | Cek `apache2ctl -S` dan record AAAA |
+| Grade tidak berubah walau Apache sudah diubah | Yang diuji CDN, bukan server Anda | Lihat di bawah |
+
+**Pakai Cloudflare atau CDN lain?** Kalau proxy aktif, SSL Labs menguji edge CDN, bukan Apache Anda. Grade-nya bergantung pada pengaturan CDN (versi TLS minimum, TLS 1.3, HSTS). Tetap pasang konfigurasi di atas di server origin dan pakai mode "Full (strict)" di Cloudflare supaya koneksi ke server Anda juga diverifikasi.
+
+## Opsional: HSTS preload
+
+HSTS preload memasukkan domain Anda ke daftar yang ditanam di dalam browser. SSL Labs tidak mensyaratkannya untuk A+. [hstspreload.org](https://hstspreload.org/) mensyaratkan `max-age` minimal satu tahun, `includeSubDomains`, `preload`, HTTPS di semua subdomain, dan redirect HTTP ke HTTPS di host yang sama. Keluar dari daftar itu butuh berbulan-bulan sampai ke pengguna, jadi daftarkan hanya domain yang pasti tetap HTTPS-only di semua subdomain.
+
+```apache
+Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
+```
+
+## Konfigurasi lengkap
+
+```apache
+# /etc/apache2/conf-available/tls.conf (a2enconf tls)
+SSLProtocol -all +TLSv1.2 +TLSv1.3
+SSLOpenSSLConfCmd Curves X25519MLKEM768:X25519:prime256v1:secp384r1
+SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
+SSLCipherSuite TLSv1.3 TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
+SSLHonorCipherOrder off
+SSLSessionTickets off
+```
+
+```apache
+# /etc/apache2/sites-available/example.com.conf (a2ensite example.com)
+
+ ServerName example.com
+ ServerAlias www.example.com
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^.*$ https://%{HTTP_HOST}%{REQUEST_URI} [R=308,QSA,L]
+
+
+
+ ServerName example.com
+ ServerAlias www.example.com
+ DocumentRoot /var/www/example.com
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+ Protocols h2 http/1.1
+ Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
+
+```
+
+Modul: `a2enmod ssl headers rewrite http2`.
+
+Untuk melayani sertifikat ECDSA dan RSA sekaligus, ulangi pasangan sertifikat di virtual host yang sama; mod_ssl memilih yang cocok untuk tiap klien:
+
+```apache
+# Pasangan ECDSA, dari Langkah 1
+SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+# Pasangan RSA
+SSLCertificateFile /etc/letsencrypt/live/example.com-rsa/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com-rsa/privkey.pem
+```
+
+Di server tes, klien TLS 1.2 yang hanya menawarkan `ECDHE-RSA-AES128-GCM-SHA256` menerima sertifikat RSA 2048 bit, sementara klien lain mendapat ECDSA. Dengan Certbot, jalankan sekali lagi dengan `--key-type rsa --cert-name example.com-rsa` untuk mendapat pasangan RSA.
+
+*Mahyuddin Susanto adalah sysadmin Linux dan jaringan yang mengelola server sendiri dan menulis soal Apache di blog ini sejak 2007, dimulai dari [Apache server dengan module OpenSSL]({% post_url 2007-09-27-apache-server-dengan-module-openssl %}). [Tentang penulis](/about) · [Kontak](/contact)*
+
+## Sumber
+
+- Qualys SSL Labs, [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), versi 2009r, 16 Mei 2025
+- TLSRef, [Server Side TLS guideline 6.0](https://data.tlsref.org/guidelines/6.0.json) dan [TLS Configurator](https://configurator.tlsref.org/)
+- Apache HTTP Server 2.4, [mod_ssl](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html) dan [mod_headers](https://httpd.apache.org/docs/2.4/mod/mod_headers.html)
+- Let's Encrypt, [Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
+- [HSTS Preload List Submission](https://hstspreload.org/)
+- IETF, [RFC 6797: HTTP Strict Transport Security](https://www.rfc-editor.org/rfc/rfc6797)
diff --git a/_posts/2026-11-05-ssl-labs-a-plus-apache.md b/_posts/2026-11-05-ssl-labs-a-plus-apache.md
new file mode 100644
index 0000000..976e31c
--- /dev/null
+++ b/_posts/2026-11-05-ssl-labs-a-plus-apache.md
@@ -0,0 +1,390 @@
+---
+layout: post
+title: "How to Get an A+ on SSL Labs with Apache httpd (2026 Guide)"
+description: "Apache mod_ssl config for an A+ on the Qualys SSL Labs test: TLS 1.3, ECDHE ciphers, full chain, HSTS with Header always, and the configtest trap."
+image: "/img/ssl-labs-a-plus-apache.png"
+date: 2026-11-05
+categories:
+ - Linux
+tags:
+ - apache
+ - tls
+ - ssl
+ - security
+ - hsts
+---
+
+To get an A+ on the [Qualys SSL Labs server test](https://www.ssllabs.com/ssltest/) with Apache httpd, you need the same five things as on any web server: TLS 1.2 and TLS 1.3 only, ECDHE key exchange with AEAD ciphers (GCM or ChaCha20-Poly1305), a certificate served with its full chain, an HSTS header with a `max-age` of at least six months, and the same setup on every IP address your hostname resolves to. On Apache, two details decide whether you get there: the HSTS header needs `Header always set`, not `Header set`, and a virtual host with its own `SSLProtocol` or `SSLCipherSuite` silently overrides your global settings.
+
+This guide gives you a config you can paste into a Debian or Ubuntu Apache, explains each line, and shows how to check the result before you run SSL Labs.
+
+The configuration below was tested on Apache 2.4.69 with OpenSSL 3.5.7, using the stock Debian 13 `/etc/apache2` layout with the modules, `tls.conf` and site file from this guide enabled, and checked with `openssl s_client` and `curl`.
+
+**Baca dalam Bahasa Indonesia:** [Cara Mendapat A+ di SSL Labs dengan Apache]({% post_url 2026-11-05-ssl-labs-a-plus-apache-id %}). Running nginx? See [How to Get an A+ on SSL Labs with nginx]({% post_url 2026-11-05-ssl-labs-a-plus-nginx %}).
+
+
+
+
+Contents
+
+* TOC
+{:toc}
+
+
+## How SSL Labs grades your server
+
+SSL Labs scores protocol support (30%), key exchange (30%) and cipher strength (40%), then applies caps for specific problems. The A+ is a bonus on top of an A with no warnings. The current rules are version 2009r of the [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), dated 16 May 2025. The caps that matter for a typical Apache server:
+
+| Problem | Grade limit |
+|---|---|
+| SSLv2, SSLv3 as the best protocol, export ciphers, Heartbleed-class bugs | F |
+| TLS 1.2 not supported | C |
+| TLS 1.0 or TLS 1.1 enabled | B |
+| No forward secrecy (no ECDHE/DHE) | B |
+| No AEAD cipher (GCM or ChaCha20-Poly1305) | B |
+| Incomplete certificate chain | B |
+| Key or DH parameters under 2048 bits | B |
+| TLS 1.3 not supported | A- |
+| HSTS missing or invalid | A- |
+
+For the A+, the guide asks for a configuration with no warnings and HSTS with a `max-age` of at least six months (15768000 seconds). Since the May 2025 revision, a plain A needs both TLS 1.3 and a valid HSTS header, and `TLS_FALLBACK_SCSV` no longer counts.
+
+An ECDSA P-256 or RSA 2048 certificate is enough. It scores 90 on key exchange and still gets an A+.
+
+## Check your Apache and OpenSSL versions first
+
+```bash
+apache2 -v
+apache2ctl -M | grep -e ssl -e headers -e rewrite -e http2
+```
+
+The OpenSSL version that Apache uses is printed in the error log at startup, in a line like `Apache/2.4.69 (Debian) OpenSSL/3.5.7 configured -- resuming normal operations`.
+
+| Feature | Needs |
+|---|---|
+| TLS 1.3 (and the `SSLCipherSuite TLSv1.3 ...` line) | Apache 2.4.37+ with OpenSSL 1.1.1+ (TLS 1.3 support is listed under 2.4.36, which was never released) |
+| Intermediates in `SSLCertificateFile` (no `SSLCertificateChainFile`) | Apache 2.4.8+ |
+| `SSLOpenSSLConfCmd` (used here to set curves) | Apache 2.4.8+ with OpenSSL 1.0.2+ |
+| `Protocols h2 http/1.1` (HTTP/2) | Apache 2.4.17+ |
+| Per-virtual-host `SSLProtocol` honored with SNI | Apache 2.4.42+ with OpenSSL 1.1.1+ |
+| Hybrid post-quantum group `X25519MLKEM768` | OpenSSL 3.5+ |
+
+## Step 1: Get a certificate and serve the full chain
+
+With Let's Encrypt and Certbot, get the certificate only and write the TLS config yourself:
+
+```bash
+sudo certbot certonly --apache -d example.com -d www.example.com
+```
+
+Certbot 2.0 and newer create an ECDSA key by default.
+
+Point Apache at `fullchain.pem`:
+
+```apache
+SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+```
+
+Since Apache 2.4.8, `SSLCertificateFile` can hold the intermediate certificates after your own certificate, and the [mod_ssl documentation](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslcertificatefile) marks `SSLCertificateChainFile` as obsolete. If you point `SSLCertificateFile` at `cert.pem` and drop the chain file, the intermediate is missing. Browsers often fill it in themselves, but SSL Labs reports "Chain issues: Incomplete" and caps the grade at B.
+
+If you use Certbot's installer (`certbot --apache` without `certonly`), it adds `Include /etc/letsencrypt/options-ssl-apache.conf` inside your virtual host. That file sets its own protocols and ciphers, and because it sits in the virtual host, it wins over the global file from Step 2. Remove the include, but first look at what else the file sets: the current [`options-ssl-apache.conf`](https://github.com/certbot/certbot/blob/main/certbot/src/certbot/_internal/plugins/apache/tls_configs/current-options-ssl-apache.conf) starts with `SSLEngine on`. Check with `grep -n SSLEngine /etc/letsencrypt/options-ssl-apache.conf` and, if it is there, add `SSLEngine on` to the virtual host itself. Without it, Apache serves plain HTTP on port 443 and reports no error.
+
+## Step 2: Enable the modules and set TLS globally
+
+Enable the modules this guide uses:
+
+```bash
+sudo a2enmod ssl headers rewrite http2
+```
+
+`a2enmod ssl` also enables `socache_shmcb` for the session cache. On Debian 13, the stock `/etc/apache2/mods-available/ssl.conf` then contains these lines (comments removed):
+
+```apache
+SSLSessionCache shmcb:${APACHE_RUN_DIR}/ssl_scache(512000)
+SSLSessionCacheTimeout 300
+SSLCipherSuite HIGH:!aNULL
+SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
+SSLSessionTickets off
+```
+
+The protocols are already fine. The cipher list is wider than it needs to be. With only these defaults, the test server accepted CBC suites, plain RSA key exchange without forward secrecy, and DHE:
+
+```text
+-cipher 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' -> Cipher is ECDHE-ECDSA-AES128-SHA
+-cipher 'AES256-GCM-SHA384' -> Cipher is AES256-GCM-SHA384
+-cipher 'DHE-RSA-AES256-GCM-SHA384' -> Cipher is DHE-RSA-AES256-GCM-SHA384
+```
+
+SSL Labs marks CBC and plain RSA suites as WEAK in its cipher list, and TLSRef leaves DHE out entirely. There is no reason to keep offering any of them. Other releases may ship different defaults, so check yours with `grep -v '^\s*#' /etc/apache2/mods-available/ssl.conf`.
+
+Leave `ssl.conf` as it is and put your settings in `/etc/apache2/conf-available/tls.conf`:
+
+```apache
+# TLS settings for every HTTPS virtual host (server config context)
+SSLProtocol -all +TLSv1.2 +TLSv1.3
+
+# OpenSSL 3.5+: hybrid post-quantum group first.
+# On OpenSSL 3.0-3.4, remove X25519MLKEM768 from this list.
+SSLOpenSSLConfCmd Curves X25519MLKEM768:X25519:prime256v1:secp384r1
+
+# TLS 1.2: ECDHE key exchange + AEAD only. TLS 1.3: the three standard suites.
+SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
+SSLCipherSuite TLSv1.3 TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
+SSLHonorCipherOrder off
+SSLSessionTickets off
+```
+
+Enable it:
+
+```bash
+sudo a2enconf tls
+```
+
+Debian's `apache2.conf` reads `mods-enabled/*.conf` first and `conf-enabled/*.conf` later. For these directives, the later global value replaces the earlier one, so `tls.conf` wins over `ssl.conf` without any edits there. On the test server, the three weak suites above were refused once `tls.conf` was enabled.
+
+Line by line:
+
+- **`SSLProtocol -all +TLSv1.2 +TLSv1.3`** starts from nothing and allows only TLS 1.2 and 1.3. TLS 1.0 or 1.1 would cap you at B; missing TLS 1.3 caps you at A-.
+- **`SSLOpenSSLConfCmd Curves`** passes the key exchange groups to OpenSSL. The list comes from the [TLSRef guideline 6.0](https://data.tlsref.org/guidelines/6.0.json), the successor of Mozilla's Server Side TLS guidelines, and matches what the [TLSRef Configurator](https://configurator.tlsref.org/) generates for Apache. `X25519MLKEM768` combines X25519 with the ML-KEM post-quantum algorithm; clients without it fall back to X25519.
+- **The first `SSLCipherSuite`** lists only ECDHE suites with GCM or ChaCha20-Poly1305: forward secrecy and AEAD, nothing else. TLSRef lists the oldest clients this still serves as Firefox 31, Chrome 49, Safari 9, Android 4.4.2, IE 11 on Windows 10 and Java 8u161.
+- **`SSLCipherSuite TLSv1.3 ...`** sets the TLS 1.3 suites. These three are OpenSSL's defaults anyway; the line makes them explicit.
+- **`SSLHonorCipherOrder off`** lets the client choose from the list. Every option is strong, and phones without AES hardware pick ChaCha20.
+- **`SSLSessionTickets off`**. The mod_ssl documentation warns that tickets used "without restarting the web server with an appropriate frequency (e.g. daily)" compromise forward secrecy. Apache does not rotate the ticket key on its own, so TLSRef turns tickets off. Session resumption still works through the `shmcb` session cache from `ssl.conf`.
+- **No DH parameters.** The list has no DHE suites, so you do not need to append DH parameters to the certificate file.
+
+## Step 3: The virtual hosts, HSTS and the HTTP redirect
+
+Create `/etc/apache2/sites-available/example.com.conf`:
+
+```apache
+
+ ServerName example.com
+ ServerAlias www.example.com
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^.*$ https://%{HTTP_HOST}%{REQUEST_URI} [R=308,QSA,L]
+
+
+
+ ServerName example.com
+ ServerAlias www.example.com
+ DocumentRoot /var/www/example.com
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+
+ Protocols h2 http/1.1
+
+ # HSTS: 2 years. "always" also sends it on redirects and error pages.
+ Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
+
+```
+
+Then enable the site and reload:
+
+```bash
+sudo a2ensite example.com
+sudo apache2ctl configtest && sudo systemctl reload apache2
+```
+
+The `RewriteCond` line keeps `/.well-known/acme-challenge/` on plain HTTP, so Let's Encrypt HTTP-01 validation is answered directly on port 80 and does not depend on the redirect or the HTTPS setup. The redirect is a 308, as in the TLSRef config.
+
+### Why `Header always set` matters
+
+`mod_headers` keeps two lists of response headers. The default one (`onsuccess`) is used for normal responses; the [mod_headers documentation](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) says that for a locally generated non-2xx response, "such as a redirect", only the `always` list is used.
+
+On the test server, with `Header set` instead of `Header always set` and a `Redirect 302 /old /` line in the `*:443` virtual host:
+
+```text
+GET / -> HTTP/2 200 strict-transport-security: max-age=63072000; includeSubDomains
+GET /old -> HTTP/2 302 (no HSTS header)
+GET /nope -> HTTP/2 404 (no HSTS header)
+```
+
+With `Header always set`, all three responses carried the header. This matters most when your home page redirects, for example `/` to `/en/` or to a login page: the first HTTPS response a visitor gets is that redirect, and without `always` it has no HSTS header.
+
+Other HSTS notes:
+
+- **`max-age=63072000`** is two years, the TLSRef value. SSL Labs needs at least 15768000 (six months) for the A+.
+- **`includeSubDomains`** makes every subdomain HTTPS-only in browsers that have seen the header. Leave it out until that is true for all of them.
+- The header belongs only in the `*:443` virtual host. Browsers ignore HSTS on plain HTTP, and [RFC 6797, section 7.2](https://www.rfc-editor.org/rfc/rfc6797#section-7.2) says servers must not send it there.
+
+Apache does not have nginx's `add_header` inheritance problem. A `Header set Cache-Control ...` inside a `` block is added to the headers from the virtual host; on the test server the HSTS header stayed on those responses.
+
+## Step 4: Watch for virtual hosts that override the global settings
+
+`tls.conf` sets the server-wide default. Any virtual host that has its own `SSLProtocol`, `SSLCipherSuite` or an `Include` of a file that sets them uses those values instead. Since Apache 2.4.42 with OpenSSL 1.1.1 or newer, the [SSLProtocol documentation](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslprotocol) says each name-based virtual host's own `SSLProtocol` is honored when the client sends SNI. Before that, the protocol of the first virtual host on the IP and port applied to all of them.
+
+Find every place that sets TLS options:
+
+```bash
+grep -rn -e SSLProtocol -e SSLCipherSuite -e SSLHonorCipherOrder -e options-ssl-apache /etc/apache2/
+apache2ctl -S
+```
+
+The first command should show only `mods-available/ssl.conf` and your `tls.conf`. The second lists which virtual host answers on which address, which helps when SSL Labs grades one IP differently from another.
+
+## Step 5: OCSP stapling and Let's Encrypt
+
+`SSLUseStapling` is off by default in Apache, and for Let's Encrypt certificates it should stay off. Let's Encrypt [ended OCSP support](https://letsencrypt.org/2024/12/05/ending-ocsp/): from 7 May 2025 its certificates no longer contain an OCSP URL, and the responders were switched off on 6 August 2025. There is nothing to staple. (My 2015 post on [OCSP stapling in nginx]({% post_url 2015-04-12-ocsp-stapling-di-nginx %}) predates this change.) OCSP stapling has no effect on the SSL Labs grade.
+
+For a certificate from a CA that still runs OCSP, enable it in the server config (outside any ``), for example at the end of `tls.conf`:
+
+```apache
+SSLUseStapling on
+SSLStaplingCache "shmcb:${APACHE_RUN_DIR}/ssl_stapling(32768)"
+```
+
+## Step 6: The configtest trap
+
+`apache2ctl configtest` checks the syntax, not whether OpenSSL accepts your values. On the test server, a curve name that OpenSSL does not know passed the syntax check and then stopped Apache:
+
+```text
+$ apache2ctl configtest
+Syntax OK
+$ apache2ctl graceful
+# error log:
+[ssl:emerg] AH02407: "SSLOpenSSLConfCmd Curves FakeGroup999:X25519:prime256v1:secp384r1" failed for example.test:443
+[ssl:emerg] AH02312: Fatal error initialising mod_ssl, exiting.
+[:emerg] AH00020: Configuration Failed, exiting
+```
+
+A graceful reload does not protect you here. After that reload, no Apache process was left running, so plain HTTP was down too. OpenSSL older than 3.5 does not know `X25519MLKEM768`, so that name can cause the same failure there.
+
+So check the group names against OpenSSL before you reload. On the test server (OpenSSL 3.5.7):
+
+```bash
+openssl list -tls-groups | tr ':' '\n' | grep -x X25519MLKEM768
+```
+
+If that prints nothing, or your `openssl` does not know the `-tls-groups` option, remove `X25519MLKEM768` from `tls.conf`. Debian and Ubuntu build Apache against the system OpenSSL, so the `openssl` command checks the same library; on a custom build, compare with the version in Apache's startup log line. After any TLS change, reload and then confirm the site still answers (`curl -sI https://example.com/`) before you log out.
+
+## Step 7: Test locally, then run SSL Labs
+
+Replace `example.com` with your host:
+
+```bash
+# TLS 1.1 must be refused. SECLEVEL=0 lets a modern client even try it.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' &1 | grep -E 'alert|Cipher is'
+
+# TLS 1.3 must work. With OpenSSL 3.5+ on both sides the group is X25519MLKEM768.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_3 &1 | grep -E 'Negotiated TLS1.3 group|Cipher is'
+
+# CBC, plain RSA and DHE suites must be refused.
+for c in 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' AES256-GCM-SHA384 DHE-RSA-AES256-GCM-SHA384; do
+ openssl s_client -connect example.com:443 -servername example.com -tls1_2 \
+ -cipher "$c" &1 | grep -E 'alert|Cipher is' | head -1
+done
+
+# Full chain: the server must send at least 2 certificates (yours + intermediate).
+openssl s_client -connect example.com:443 -servername example.com -showcerts /dev/null | grep -c 'BEGIN CERTIFICATE'
+
+# HSTS on a normal page and on an error page, plus the HTTP redirect.
+curl -sI https://example.com/ | grep -i strict-transport-security
+curl -sI https://example.com/does-not-exist | grep -i -e '^HTTP' -e strict-transport-security
+curl -sI http://example.com/ | grep -i -e '^HTTP' -e '^location'
+```
+
+On the test setup, the output looked like this (condensed):
+
+```text
+tls1_1 : tlsv1 alert protocol version ... Cipher is (NONE)
+tls1_3 : Negotiated TLS1.3 group: X25519MLKEM768
+ New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
+CBC : ssl/tls alert handshake failure
+RSA : ssl/tls alert handshake failure
+DHE : ssl/tls alert handshake failure
+404 : HTTP/2 404 + strict-transport-security: max-age=63072000; includeSubDomains
+HTTP : HTTP/1.1 308 Permanent Redirect
+```
+
+Then open [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/), enter your hostname, and tick "Do not show the results on the boards" if you do not want the result listed. After a config change, use "Clear cache" on the results page to force a new scan.
+
+## Troubleshooting: what the report says and how to fix it
+
+| What you see | Cause | Fix |
+|---|---|---|
+| Grade B, "This server supports TLS 1.0 and TLS 1.1" | A virtual host or include sets its own `SSLProtocol` | Run the `grep` from Step 4; remove the override |
+| WEAK suites (CBC, `TLS_RSA_*`) in the cipher list | Distribution default `SSLCipherSuite`, or an override in a virtual host | Enable `tls.conf`; check with the `grep` from Step 4 |
+| Grade B, "Chain issues: Incomplete" | `SSLCertificateFile` points to `cert.pem` | Use `fullchain.pem` |
+| Grade A-, "This server does not support TLS 1.3" | Apache older than 2.4.37, OpenSSL older than 1.1.1, or `SSLProtocol` without `+TLSv1.3` | Upgrade; check both versions in the startup line of the error log |
+| Grade A-, HSTS missing although it is configured | `mod_headers` not enabled, the header set in the wrong virtual host, or `Header set` without `always` on a redirecting page | `a2enmod headers`; put `Header always set` in the `*:443` virtual host |
+| `Invalid command 'Header'` or `'RewriteEngine'` | Module not enabled | `a2enmod headers rewrite` |
+| Apache stops after a reload although `configtest` said Syntax OK (`AH02407`, `AH02312`) | OpenSSL rejects a value, often `X25519MLKEM768` on OpenSSL older than 3.5 | Remove that group, start Apache again; see Step 6 |
+| A+ on one IP, A- on another | IPv6 served by another virtual host or machine | Check `apache2ctl -S` and the AAAA record |
+| Grade does not change after you edit Apache | You are testing a CDN, not your server | See below |
+
+**Behind Cloudflare or another CDN?** With the proxy on, SSL Labs tests the CDN's edge, not your Apache. The grade then depends on the CDN settings (minimum TLS version, TLS 1.3, HSTS). Keep the setup above on the origin and use Cloudflare's "Full (strict)" mode so the connection to your server is checked too.
+
+## Optional: HSTS preload
+
+HSTS preload puts your domain on a list built into browsers. SSL Labs does not need it for an A+. [hstspreload.org](https://hstspreload.org/) requires a `max-age` of at least one year, `includeSubDomains`, `preload`, HTTPS on every subdomain, and an HTTP to HTTPS redirect on the same host. Removal takes months to reach users, so only submit a domain that will stay HTTPS-only everywhere.
+
+```apache
+Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
+```
+
+## The complete config
+
+```apache
+# /etc/apache2/conf-available/tls.conf (a2enconf tls)
+SSLProtocol -all +TLSv1.2 +TLSv1.3
+SSLOpenSSLConfCmd Curves X25519MLKEM768:X25519:prime256v1:secp384r1
+SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
+SSLCipherSuite TLSv1.3 TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
+SSLHonorCipherOrder off
+SSLSessionTickets off
+```
+
+```apache
+# /etc/apache2/sites-available/example.com.conf (a2ensite example.com)
+
+ ServerName example.com
+ ServerAlias www.example.com
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^.*$ https://%{HTTP_HOST}%{REQUEST_URI} [R=308,QSA,L]
+
+
+
+ ServerName example.com
+ ServerAlias www.example.com
+ DocumentRoot /var/www/example.com
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+ Protocols h2 http/1.1
+ Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
+
+```
+
+Modules: `a2enmod ssl headers rewrite http2`.
+
+To serve both ECDSA and RSA certificates, repeat the certificate pair in the same virtual host; mod_ssl picks the right one per client:
+
+```apache
+# ECDSA pair, from Step 1
+SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
+# RSA pair
+SSLCertificateFile /etc/letsencrypt/live/example.com-rsa/fullchain.pem
+SSLCertificateKeyFile /etc/letsencrypt/live/example.com-rsa/privkey.pem
+```
+
+On the test server, a TLS 1.2 client that only offered `ECDHE-RSA-AES128-GCM-SHA256` received the 2048-bit RSA certificate, and other clients got ECDSA. With Certbot, run it again with `--key-type rsa --cert-name example.com-rsa` for the RSA pair.
+
+*Mahyuddin Susanto is a Linux and networking sysadmin who runs his own servers and has written about Apache on this blog since 2007, starting with [Apache server dengan module OpenSSL]({% post_url 2007-09-27-apache-server-dengan-module-openssl %}). [About](/about) · [Contact](/contact)*
+
+## Sources
+
+- Qualys SSL Labs, [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), version 2009r, 16 May 2025
+- TLSRef, [Server Side TLS guideline 6.0](https://data.tlsref.org/guidelines/6.0.json) and [TLS Configurator](https://configurator.tlsref.org/)
+- Apache HTTP Server 2.4, [mod_ssl](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html) and [mod_headers](https://httpd.apache.org/docs/2.4/mod/mod_headers.html)
+- Let's Encrypt, [Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
+- [HSTS Preload List Submission](https://hstspreload.org/)
+- IETF, [RFC 6797: HTTP Strict Transport Security](https://www.rfc-editor.org/rfc/rfc6797)
diff --git a/_posts/2026-11-05-ssl-labs-a-plus-nginx-id.md b/_posts/2026-11-05-ssl-labs-a-plus-nginx-id.md
new file mode 100644
index 0000000..fbb7c57
--- /dev/null
+++ b/_posts/2026-11-05-ssl-labs-a-plus-nginx-id.md
@@ -0,0 +1,403 @@
+---
+layout: post
+title: "Cara Mendapat A+ di SSL Labs dengan nginx (Panduan 2026)"
+description: "Konfigurasi TLS nginx langkah demi langkah untuk nilai A+ di SSL Labs: TLS 1.3, cipher ECDHE, full chain, HSTS, perubahan OCSP, dan jebakan add_header."
+image: "/img/ssl-labs-a-plus-nginx-id.png"
+date: 2026-11-05
+categories:
+ - Linux
+tags:
+ - nginx
+ - tls
+ - ssl
+ - security
+ - hsts
+---
+
+Untuk dapat A+ di [SSL Labs Server Test](https://www.ssllabs.com/ssltest/) dari Qualys dengan nginx, ada lima syarat: hanya TLS 1.2 dan TLS 1.3, cipher ECDHE dengan AEAD (GCM atau ChaCha20-Poly1305), sertifikat dikirim bersama chain lengkapnya, header HSTS dengan `max-age` minimal enam bulan, dan konfigurasi yang sama di semua alamat IP hostname tersebut. Tanpa TLS 1.3 atau tanpa HSTS, nilainya mentok di A-. Kalau TLS 1.0 atau 1.1 masih aktif, nilainya mentok di B.
+
+Tulisan ini membahas tiap syarat itu satu per satu, lengkap dengan konfigurasi yang bisa langsung dipakai. Ada juga dua perubahan yang bikin banyak tutorial lama jadi usang: Let's Encrypt mematikan OCSP di 2025, dan SSL Labs mengubah aturan penilaiannya di Mei 2025.
+
+Konfigurasi di bawah sudah diuji di nginx 1.26.3 dengan OpenSSL 3.5.7, memakai `nginx.conf` bawaan Debian 13 (dua baris SSL-nya diberi `#`) ditambah file-file dari tulisan ini, lalu dicek dengan `openssl s_client` dan `curl`.
+
+**Read in English:** [How to Get an A+ on SSL Labs with nginx]({% post_url 2026-11-05-ssl-labs-a-plus-nginx %}). Pakai Apache? Lihat [Cara Mendapat A+ di SSL Labs dengan Apache]({% post_url 2026-11-05-ssl-labs-a-plus-apache-id %}).
+
+
+
+
+Daftar isi
+
+* TOC
+{:toc}
+
+
+## Cara SSL Labs memberi nilai
+
+SSL Labs menilai tiga kategori: dukungan protokol (30%), key exchange (30%), dan kekuatan cipher (40%). Skor 80 ke atas berarti A. Setelah itu berlaku batas atas (cap): masalah tertentu membatasi grade setinggi apa pun skornya. A+ adalah bonus untuk server ber-grade A yang tidak punya warning.
+
+Aturan yang berlaku sekarang adalah versi 2009r dari [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), tertanggal 16 Mei 2025. Ini batas-batas yang relevan untuk server nginx pada umumnya:
+
+| Masalah | Grade maksimal |
+|---|---|
+| SSLv2, SSLv3 sebagai protokol terbaik, cipher export, bug sekelas Heartbleed | F |
+| Tidak mendukung TLS 1.2 | C |
+| TLS 1.0 atau TLS 1.1 aktif | B |
+| Tanpa forward secrecy (tanpa ECDHE/DHE) | B |
+| Tanpa cipher AEAD (GCM atau ChaCha20-Poly1305) | B |
+| RC4 aktif | B |
+| Chain sertifikat tidak lengkap | B |
+| Key atau parameter DH di bawah 2048 bit | B |
+| Tidak mendukung TLS 1.3 | A- |
+| HSTS tidak ada atau tidak valid | A- |
+
+Untuk A+, panduan itu mensyaratkan konfigurasi tanpa warning dan HSTS dengan `max-age` minimal enam bulan (15768000 detik).
+
+Ada dua perubahan di 2025: sekadar dapat A sekarang butuh TLS 1.3 sekaligus header HSTS yang valid, dan `TLS_FALLBACK_SCSV` tidak dihitung lagi. Kalau ada artikel lama yang menyuruh mengurus fallback SCSV, bagian itu bisa dilewati.
+
+Nilai 100 di semua kategori tidak diperlukan. Sertifikat ECDSA P-256 atau RSA 2048 dapat 90 di key exchange, dan selama AES-128 masih ditawarkan, kekuatan cipher juga 90. Keduanya tetap dapat A+. Pindah ke RSA 4096 cuma bikin handshake lebih lambat.
+
+## Cek versi nginx dan OpenSSL dulu
+
+Beberapa directive bergantung pada versi yang terpasang:
+
+```bash
+nginx -V 2>&1 | head -3
+openssl version
+```
+
+| Fitur | Butuh |
+|---|---|
+| TLS 1.3 (`TLSv1.3` di `ssl_protocols`) | OpenSSL 1.1.1+; jadi default sejak nginx 1.23.4 |
+| `http2 on;` sebagai directive terpisah | nginx 1.25.1+ (versi lama: `listen 443 ssl http2;`) |
+| Rotasi otomatis key session ticket | nginx 1.23.2+ dengan `ssl_session_cache` shared |
+| Grup hybrid post-quantum `X25519MLKEM768` | OpenSSL 3.5+ |
+| `add_header_inherit merge;` | nginx 1.29.3+ |
+
+Yang penting di output `nginx -V` adalah baris `built with OpenSSL ...`. Itu library yang benar-benar dipakai nginx, dan bisa berbeda dengan perintah `openssl` di server yang sama.
+
+## Langkah 1: Sertifikat dan full chain
+
+Sertifikat apa pun yang dipercaya publik bisa dipakai. Dengan Let's Encrypt dan Certbot, ambil sertifikatnya saja, lalu tulis konfigurasi TLS sendiri:
+
+```bash
+sudo certbot certonly --nginx -d example.com -d www.example.com
+```
+
+Certbot versi baru sudah memakai key ECDSA secara default, dan itu yang dibutuhkan. Arahkan nginx ke `fullchain.pem`, bukan `cert.pem`:
+
+```nginx
+ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+```
+
+`cert.pem` hanya berisi sertifikat leaf. Browser sering melengkapi intermediate yang hilang sendiri, jadi situs terlihat normal, tapi SSL Labs akan menulis "Chain issues: Incomplete" dan membatasi grade di B. [Dokumentasi nginx](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate) juga bilang begitu: sertifikat server ditaruh paling atas di file, diikuti sertifikat intermediate.
+
+Kalau memakai installer Certbot (`certbot --nginx` tanpa `certonly`), ia menambahkan `include /etc/letsencrypt/options-ssl-nginx.conf;` ke server block. File itu juga mengatur protokol dan cipher. Hapus baris include tersebut supaya hanya pengaturan dari tulisan ini yang berlaku.
+
+## Langkah 2: Taruh pengaturan TLS di satu file bersama
+
+Debian dan Ubuntu memuat semua file di `/etc/nginx/conf.d/` di dalam blok `http`, jadi satu file di sana berlaku untuk semua situs HTTPS.
+
+Pertama, bereskan dulu baris SSL yang sudah dipasang paket di `/etc/nginx/nginx.conf`. File bawaan Debian 13 punya blok ini di dalam `http { }`:
+
+```nginx
+##
+# SSL Settings
+##
+
+ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3 (POODLE), TLS 1.0, 1.1
+ssl_prefer_server_ciphers off; # Don't force server cipher order.
+```
+
+File di `conf.d/` masuk ke blok `http` yang sama, jadi baris-baris ini akan bentrok. nginx menolak `ssl_prefer_server_ciphers` kedua di level yang sama:
+
+```text
+nginx: [emerg] "ssl_prefer_server_ciphers" directive is duplicate in /etc/nginx/conf.d/tls.conf
+```
+
+`ssl_protocols` kedua juga tidak menimpa yang pertama. nginx menggabungkan kedua daftar dan hanya memberi peringatan seperti `[warn] duplicate value "TLSv1.2"`, bukan error. Beri tanda `#` pada kedua baris itu (isinya bisa berbeda di rilis lain), lalu pastikan tidak ada file lain yang mengaturnya:
+
+```bash
+grep -rn -e ssl_protocols -e ssl_prefer_server_ciphers -e ssl_ciphers /etc/nginx/
+```
+
+Sekarang buat `/etc/nginx/conf.d/tls.conf`:
+
+```nginx
+# Pengaturan TLS untuk semua server block (konteks http)
+ssl_protocols TLSv1.2 TLSv1.3;
+
+# OpenSSL 3.5+: grup hybrid post-quantum di depan.
+# Di OpenSSL 3.0-3.4, hapus X25519MLKEM768 dari daftar ini.
+ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
+
+# Cipher TLS 1.2: hanya key exchange ECDHE + AEAD.
+# Suite TLS 1.3 diatur OpenSSL dan semuanya AEAD.
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+ssl_prefer_server_ciphers off;
+
+ssl_session_timeout 1d;
+ssl_session_cache shared:TLS:10m; # sekitar 40000 sesi
+```
+
+Penjelasan per baris:
+
+- **`ssl_protocols TLSv1.2 TLSv1.3`** mematikan TLS 1.0 dan 1.1 (penyebab cap B) dan mengaktifkan TLS 1.3 (tanpanya grade mentok di A-). Ini sudah default sejak nginx 1.23.4, tapi ditulis eksplisit supaya jelas. Baris ini tidak menimpa `ssl_protocols` lain di blok yang sama, karena itu baris lama tadi harus dimatikan.
+- **`ssl_ecdh_curve`** menentukan grup key exchange. Daftarnya diambil dari [TLSRef guideline 6.0](https://data.tlsref.org/guidelines/6.0.json), penerus Server Side TLS guidelines dari Mozilla. `X25519MLKEM768` menggabungkan X25519 klasik dengan algoritma post-quantum ML-KEM. Klien yang belum mendukungnya otomatis turun ke X25519.
+- **`ssl_ciphers`** hanya berisi suite ECDHE, yang memberi forward secrecy, dan hanya GCM atau ChaCha20-Poly1305, yang termasuk AEAD. Tidak ada CBC, tidak ada key exchange RSA, tidak ada 3DES. Menurut TLSRef, klien tertua yang masih bisa tersambung dengan profil ini adalah Firefox 31, Chrome 49, Safari 9, Android 4.4.2, IE 11 di Windows 10, dan Java 8u161. Daftar ini sekaligus menggantikan daftar cipher di tulisan saya tahun 2015, [ssl_cipher di nginx]({% post_url 2015-04-12-ssl_cipher-di-nginx %}), yang sudah usang.
+- **`ssl_prefer_server_ciphers off`** membiarkan klien yang memilih. Karena semua pilihan di daftar sudah kuat, klien paling tahu mana yang cocok: ponsel tanpa akselerasi AES di hardware akan memilih ChaCha20.
+- **Tanpa `ssl_dhparam`.** Daftar cipher tidak berisi suite DHE, jadi file parameter DH tidak diperlukan. Risiko lama "parameter DH di bawah 2048 bit" ikut hilang.
+- **Session ticket** dibiarkan default (`on`). Sejak nginx 1.23.2, key ticket dirotasi otomatis selama `ssl_session_cache` memakai shared memory, seperti di file di atas. Karena itu TLSRef hanya menambahkan `ssl_session_tickets off;` untuk versi yang lebih lama.
+
+Mozilla SSL Configuration Generator sekarang dialihkan ke [TLSRef Configurator](https://configurator.tlsref.org/). File di atas sama dengan profil "Intermediate" di sana, pilihan yang tepat untuk website publik. Profil "Modern" (hanya TLS 1.3) juga dapat A+, tapi memutus klien lama seperti Android 4.4 dan IE 11.
+
+## Langkah 3: Server block, HSTS, dan redirect HTTP
+
+Sekarang konfigurasi situsnya, misalnya `/etc/nginx/sites-available/example.com` yang di-link ke `sites-enabled/`:
+
+```nginx
+# Port 80: semua dialihkan ke HTTPS
+server {
+ listen 80;
+ listen [::]:80;
+ server_name example.com www.example.com;
+
+ return 308 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on; # nginx < 1.25.1: "listen 443 ssl http2;"
+ server_name example.com www.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ # HSTS: 2 tahun. "always" juga mengirimnya di respons 3xx/4xx/5xx.
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+
+ root /var/www/example.com;
+ index index.html;
+}
+```
+
+Catatan soal baris HSTS:
+
+- **`max-age=63072000`** sama dengan dua tahun, nilai dari TLSRef. SSL Labs butuh minimal 15768000 (enam bulan) untuk A+. Nilai yang lebih pendek atau salah ketik membuat grade turun ke A-.
+- **`always`** penting. Tanpa itu, nginx hanya menambahkan header di respons 200, 201, 204, 206, 301, 302, 303, 304, 307, dan 308. Dengan `always`, halaman error juga membawa header ini.
+- **`includeSubDomains`** memberi tahu browser bahwa semua subdomain juga wajib HTTPS. Jangan dipasang sebelum yakin itu benar. Kalau `intranet.example.com` masih HTTP biasa, browser yang pernah menerima header ini akan menolak membukanya.
+- Header hanya dipasang di server HTTPS. Browser mengabaikan HSTS yang dikirim lewat HTTP biasa, dan spesifikasi HSTS ([RFC 6797 bagian 7.2](https://www.rfc-editor.org/rfc/rfc6797#section-7.2)) melarang server mengirimnya di sana.
+
+Dua baris `listen` di tiap blok memang disengaja. Kalau domain punya record AAAA tapi nginx hanya listen di IPv4, SSL Labs akan menguji alamat IPv6 dan mendapat jawaban berbeda. Penjelasannya ada di tabel troubleshooting.
+
+## Langkah 4: OCSP stapling dan Let's Encrypt
+
+Banyak panduan A+ masih menyuruh menambahkan `ssl_stapling on;`, termasuk tulisan saya sendiri tahun 2015, [OCSP Stapling di nginx]({% post_url 2015-04-12-ocsp-stapling-di-nginx %}). Untuk sertifikat Let's Encrypt, baris itu sebaiknya dibuang.
+
+Let's Encrypt [menghentikan dukungan OCSP](https://letsencrypt.org/2024/12/05/ending-ocsp/): sejak 7 Mei 2025 sertifikatnya tidak lagi memuat URL OCSP, dan responder OCSP dimatikan pada 6 Agustus 2025. Pengecekan revokasi sekarang lewat CRL. Dengan stapling aktif dan sertifikat Let's Encrypt yang baru, nginx menulis peringatan seperti ini setiap kali reload:
+
+```text
+[warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate
+```
+
+Situs tetap jalan, tapi log jadi kotor. Hapus `ssl_stapling` dan `ssl_stapling_verify`. OCSP stapling tidak memengaruhi grade SSL Labs.
+
+Kalau sertifikat berasal dari CA yang masih menjalankan OCSP, stapling tetap berguna. Yang dibutuhkan adalah resolver dan chain penerbit:
+
+```nginx
+ssl_stapling on;
+ssl_stapling_verify on;
+ssl_trusted_certificate /etc/ssl/certs/ca-chain.pem; # root + intermediate
+resolver 127.0.0.53 valid=300s; # contoh: systemd-resolved; ganti dengan resolver server Anda
+```
+
+## Langkah 5: Hindari jebakan add_header
+
+Di nginx, `add_header` hanya diwarisi dari blok luar kalau blok saat ini tidak punya `add_header` sendiri. Begitu satu header ditambahkan di sebuah `location`, semua header dari blok `server` hilang di location itu, termasuk HSTS. Kalau location itu `/`, grade turun ke A-. Kalau di path lain, laporan tetap A+ padahal sebagian situs jalan tanpa header itu.
+
+Ini hasil uji di server tes (port 8443) dengan konfigurasi di atas ditambah satu location:
+
+```nginx
+location /assets/ {
+ add_header Cache-Control "public, max-age=3600";
+}
+```
+
+```text
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/ | grep -i strict
+strict-transport-security: max-age=63072000; includeSubDomains
+
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/assets/x | grep -i -e strict -e cache
+cache-control: public, max-age=3600
+```
+
+Respons kedua tidak punya HSTS.
+
+Ada tiga cara memperbaikinya, urut dari yang paling disarankan:
+
+1. **nginx 1.29.3 ke atas:** pasang `add_header_inherit merge;` di blok `http`. Header dari level luar akan ditambahkan ke header yang diset di blok dalam ([dokumentasi](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header_inherit)).
+2. **nginx lama:** pindahkan header keamanan ke snippet, lalu include di setiap tempat yang memakai `add_header` (contoh di bawah).
+3. **Kalau hanya untuk cache:** pakai `expires 1h;`, bukan `add_header Cache-Control`. Directive ini mengirim `Cache-Control` dan `Expires` sendiri, dan berbeda dengan `add_header`, tidak memutus header warisan dari blok `server`.
+
+Cara snippet kira-kira seperti ini:
+
+```nginx
+# /etc/nginx/snippets/security-headers.conf
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+```
+
+```nginx
+location /assets/ {
+ include snippets/security-headers.conf;
+ add_header Cache-Control "public, max-age=3600";
+}
+```
+
+Dan hasil `location /assets/ { expires 1h; }` di server tes yang sama:
+
+```text
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/assets/x.css
+HTTP/2 200
+expires: Thu, 05 Nov 2026 01:41:51 GMT
+cache-control: max-age=3600
+strict-transport-security: max-age=63072000; includeSubDomains
+```
+
+Aturan pewarisan yang sama berlaku untuk blok `if` dan location bertingkat.
+
+## Langkah 6: Uji di lokal, lalu jalankan SSL Labs
+
+Cek sintaks lalu reload:
+
+```bash
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+Sebelum ke SSL Labs, cek poin-poin penting dari terminal sendiri. Ganti `example.com` dengan host Anda:
+
+```bash
+# TLS 1.1 harus ditolak. SECLEVEL=0 supaya klien modern mau mencobanya.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' &1 | grep -E 'alert|Cipher is'
+
+# TLS 1.3 harus jalan. Dengan OpenSSL 3.5+ di kedua sisi, grupnya X25519MLKEM768.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_3 &1 | grep -E 'Negotiated TLS1.3 group|Cipher is'
+
+# Suite CBC harus ditolak (berlaku untuk sertifikat ECDSA maupun RSA).
+openssl s_client -connect example.com:443 -servername example.com -tls1_2 \
+ -cipher 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' &1 | grep -E 'alert|Cipher is'
+
+# Full chain: harus ada baris "depth=1" (intermediate yang dikirim server).
+openssl s_client -connect example.com:443 -servername example.com &1 | grep depth
+
+# Header HSTS dan redirect.
+curl -sI https://example.com/ | grep -i strict-transport-security
+curl -sI http://example.com/ | grep -i -e '^HTTP' -e '^location'
+```
+
+Di server tes, hasilnya seperti ini (diringkas):
+
+```text
+tls1_1 : tlsv1 alert protocol version ... Cipher is (NONE)
+tls1_3 : Negotiated TLS1.3 group: X25519MLKEM768
+ New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
+CBC : ssl/tls alert handshake failure ... Cipher is (NONE)
+HSTS : strict-transport-security: max-age=63072000; includeSubDomains
+HTTP : HTTP/1.1 308 Permanent Redirect
+```
+
+Kalau semuanya sudah sesuai, buka [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/), masukkan hostname, dan centang "Do not show the results on the boards" kalau tidak ingin hasilnya tampil di daftar publik. SSL Labs menyimpan hasil scan untuk beberapa waktu. Setelah mengubah konfigurasi, klik tautan "Clear cache" di halaman hasil supaya scan diulang. Satu scan penuh butuh beberapa menit, lebih lama kalau hostname punya beberapa alamat IP.
+
+## Troubleshooting: arti pesan di laporan dan cara memperbaikinya
+
+| Yang muncul | Penyebab | Perbaikan |
+|---|---|---|
+| Grade B, "This server supports TLS 1.0 and TLS 1.1" | Ada baris `ssl_protocols` lama, sering di `nginx.conf` atau include dari Certbot | Jalankan `grep` dari Langkah 2 dan sisakan hanya `tls.conf` |
+| `nginx -t`: `"ssl_prefer_server_ciphers" directive is duplicate` | `nginx.conf` bawaan masih mengaturnya di blok `http` yang sama | Beri tanda `#` di `nginx.conf` (Langkah 2) |
+| Grade B, "Chain issues: Incomplete" | `ssl_certificate` mengarah ke `cert.pem` | Pakai `fullchain.pem` |
+| Grade B, warning cipher soal forward secrecy atau CBC | Daftar `ssl_ciphers` lama | Pakai daftar dari Langkah 2 |
+| Grade A-, "This server does not support TLS 1.3" | OpenSSL lebih lama dari 1.1.1, atau `ssl_protocols` tanpa `TLSv1.3` | Upgrade OS atau build nginx; cek `nginx -V` |
+| Grade A-, warning HSTS atau "HSTS not set" | Header tidak ada di halaman yang diuji, `max-age` terlalu pendek, atau kena jebakan add_header | Cek dengan `curl -sI`; lihat Langkah 5 |
+| A+ di satu IP, A- di IP lain | IPv6 dilayani server block lain atau mesin lain | Tambahkan `listen [::]:443 ssl;` di semua blok, atau perbaiki record AAAA |
+| Grade tidak berubah walau nginx sudah diubah | Yang diuji CDN, bukan server Anda | Lihat di bawah |
+| `nginx -t` menulis `SSL_CTX_set1_curves_list(...) failed` | OpenSSL di balik nginx tidak mengenal salah satu grup di `ssl_ecdh_curve`, biasanya `X25519MLKEM768` di OpenSSL sebelum 3.5 | Hapus grup itu dari daftar |
+
+**Pakai Cloudflare atau CDN lain?** Kalau proxy Cloudflare (awan oranye) aktif, SSL Labs menguji edge Cloudflare, bukan nginx Anda. Grade-nya bergantung pada pengaturan di Cloudflare: versi TLS minimum, TLS 1.3, dan HSTS di menu SSL/TLS > Edge Certificates. Konfigurasi nginx tetap penting untuk koneksi antara Cloudflare dan server origin, jadi pakai mode "Full (strict)" dan tetap pasang konfigurasi di atas di origin. (Blog ini juga ada di belakang Cloudflare; ceritanya ada di [Migrasi ke Cloudflare]({% post_url 2014-11-10-migrasi-ke-cloudflare %}).)
+
+## Opsional: HSTS preload
+
+Dengan HSTS preload, domain Anda masuk ke daftar HTTPS-only yang ditanam di dalam browser, jadi kunjungan pertama pun tidak pernah lewat HTTP. SSL Labs tidak mensyaratkannya untuk A+. Syarat di [hstspreload.org](https://hstspreload.org/) lebih ketat daripada syarat A+:
+
+- `max-age` minimal 31536000 (satu tahun), `includeSubDomains`, dan `preload` di header
+- semua subdomain dilayani lewat HTTPS, termasuk subdomain internal
+- HTTP dialihkan ke HTTPS di host yang sama terlebih dulu
+
+```nginx
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+```
+
+Keluar dari daftar itu butuh waktu berbulan-bulan sampai ke pengguna lewat update browser, dan browser lain belum tentu ikut menghapus. Daftarkan hanya domain yang pasti tetap HTTPS-only di semua subdomain.
+
+## Konfigurasi lengkap
+
+Sebagai rujukan, ini dua file yang dipakai saat pengujian di atas (sesuaikan path dan nama). Ditambah dua baris yang diberi `#` di `nginx.conf` pada Langkah 2, itulah seluruh perubahannya:
+
+```nginx
+# /etc/nginx/conf.d/tls.conf
+ssl_protocols TLSv1.2 TLSv1.3;
+ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+ssl_prefer_server_ciphers off;
+ssl_session_timeout 1d;
+ssl_session_cache shared:TLS:10m;
+# add_header_inherit merge; # nginx 1.29.3+
+```
+
+```nginx
+# /etc/nginx/sites-available/example.com
+server {
+ listen 80;
+ listen [::]:80;
+ server_name example.com www.example.com;
+ return 308 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name example.com www.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+
+ root /var/www/example.com;
+ index index.html;
+}
+```
+
+Kalau perlu melayani klien ECDSA dan RSA sekaligus, nginx bisa memuat dua sertifikat di server block yang sama dan memilih yang cocok untuk tiap klien:
+
+```nginx
+ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # ECDSA, dari Langkah 1
+ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+ssl_certificate /etc/letsencrypt/live/example.com-rsa/fullchain.pem;
+ssl_certificate_key /etc/letsencrypt/live/example.com-rsa/privkey.pem;
+```
+
+Di server tes, klien TLS 1.2 yang hanya menawarkan `ECDHE-RSA-AES128-GCM-SHA256` menerima sertifikat RSA 2048 bit, sementara klien lain mendapat ECDSA. Dengan Certbot, jalankan sekali lagi dengan `--key-type rsa --cert-name example.com-rsa` untuk mendapat pasangan RSA.
+
+*Mahyuddin Susanto adalah sysadmin Linux dan jaringan yang mengelola server sendiri dan menulis soal nginx di blog ini sejak 2009, dimulai dari [nginx, dari nol]({% post_url 2009-05-09-nginx-dari-nol %}). [Tentang penulis](/about) · [Kontak](/contact)*
+
+## Sumber
+
+- Qualys SSL Labs, [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), versi 2009r, 16 Mei 2025
+- TLSRef, [Server Side TLS guideline 6.0](https://data.tlsref.org/guidelines/6.0.json) dan [TLS Configurator](https://configurator.tlsref.org/)
+- nginx, [ngx_http_ssl_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html), [ngx_http_headers_module](https://nginx.org/en/docs/http/ngx_http_headers_module.html), dan [CHANGES](https://nginx.org/en/CHANGES)
+- Let's Encrypt, [Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
+- [HSTS Preload List Submission](https://hstspreload.org/)
+- IETF, [RFC 6797: HTTP Strict Transport Security](https://www.rfc-editor.org/rfc/rfc6797)
diff --git a/_posts/2026-11-05-ssl-labs-a-plus-nginx.md b/_posts/2026-11-05-ssl-labs-a-plus-nginx.md
new file mode 100644
index 0000000..2688fd6
--- /dev/null
+++ b/_posts/2026-11-05-ssl-labs-a-plus-nginx.md
@@ -0,0 +1,403 @@
+---
+layout: post
+title: "How to Get an A+ on SSL Labs with nginx (2026 Guide)"
+description: "Step-by-step nginx TLS config for an A+ on the Qualys SSL Labs test: TLS 1.3, ECDHE ciphers, full chain, HSTS, the OCSP change, and the add_header trap."
+image: "/img/ssl-labs-a-plus-nginx.png"
+date: 2026-11-05
+categories:
+ - Linux
+tags:
+ - nginx
+ - tls
+ - ssl
+ - security
+ - hsts
+---
+
+To get an A+ on the [Qualys SSL Labs server test](https://www.ssllabs.com/ssltest/) with nginx, you need five things: TLS 1.2 and TLS 1.3 only, ECDHE key exchange with AEAD ciphers (GCM or ChaCha20-Poly1305), a certificate served with its full chain, an HSTS header with a `max-age` of at least six months, and the same setup on every IP address your hostname resolves to. Miss TLS 1.3 or HSTS and you land on A-. Leave TLS 1.0 or 1.1 on and you are stuck at B.
+
+This guide walks through each piece, gives you a config you can paste, and covers the two changes that break older tutorials: Let's Encrypt shutting down OCSP in 2025, and the grading update SSL Labs shipped in May 2025.
+
+The configuration below was tested on nginx 1.26.3 with OpenSSL 3.5.7, using the stock Debian 13 `nginx.conf` with its two SSL lines commented out and the files from this guide added, and checked with `openssl s_client` and `curl`.
+
+**Baca dalam Bahasa Indonesia:** [Cara Mendapat A+ di SSL Labs dengan nginx]({% post_url 2026-11-05-ssl-labs-a-plus-nginx-id %}). Running Apache? See [How to Get an A+ on SSL Labs with Apache httpd]({% post_url 2026-11-05-ssl-labs-a-plus-apache %}).
+
+
+
+
+Contents
+
+* TOC
+{:toc}
+
+
+## How SSL Labs grades your server
+
+SSL Labs scores three categories: protocol support (30%), key exchange (30%) and cipher strength (40%). A score of 80 or more is an A. Then it applies caps: specific problems limit the grade no matter how high the score is. The A+ is a bonus on top of an A with no warnings.
+
+The current rules are version 2009r of the [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), dated 16 May 2025. These are the caps that matter for a typical nginx server:
+
+| Problem | Grade limit |
+|---|---|
+| SSLv2, SSLv3 as the best protocol, export ciphers, Heartbleed-class bugs | F |
+| TLS 1.2 not supported | C |
+| TLS 1.0 or TLS 1.1 enabled | B |
+| No forward secrecy (no ECDHE/DHE) | B |
+| No AEAD cipher (GCM or ChaCha20-Poly1305) | B |
+| RC4 enabled | B |
+| Incomplete certificate chain | B |
+| Key or DH parameters under 2048 bits | B |
+| TLS 1.3 not supported | A- |
+| HSTS missing or invalid | A- |
+
+For the A+, the guide asks for a configuration with no warnings and HSTS with a `max-age` of at least six months (15768000 seconds).
+
+Two things changed in 2025: a plain A now needs both TLS 1.3 and a valid HSTS header, and `TLS_FALLBACK_SCSV` no longer counts. If an older article tells you to chase fallback SCSV, skip that part.
+
+You do not need 100 in every category. An ECDSA P-256 or RSA 2048 certificate scores 90 on key exchange, and offering AES-128 keeps cipher strength at 90. Both still get an A+. Switching to RSA 4096 only makes handshakes slower.
+
+## Check your nginx and OpenSSL versions first
+
+A few directives depend on the version you run:
+
+```bash
+nginx -V 2>&1 | head -3
+openssl version
+```
+
+| Feature | Needs |
+|---|---|
+| TLS 1.3 (`TLSv1.3` in `ssl_protocols`) | OpenSSL 1.1.1+; default since nginx 1.23.4 |
+| `http2 on;` as a separate directive | nginx 1.25.1+ (older: `listen 443 ssl http2;`) |
+| Automatic session ticket key rotation | nginx 1.23.2+ with a shared `ssl_session_cache` |
+| Hybrid post-quantum group `X25519MLKEM768` | OpenSSL 3.5+ |
+| `add_header_inherit merge;` | nginx 1.29.3+ |
+
+The `nginx -V` line that matters is `built with OpenSSL ...`. That is the library nginx actually uses, and it can differ from the `openssl` command on the same box.
+
+## Step 1: Get a certificate and serve the full chain
+
+Any publicly trusted certificate works. With Let's Encrypt and Certbot, get the certificate only and write the TLS config yourself:
+
+```bash
+sudo certbot certonly --nginx -d example.com -d www.example.com
+```
+
+Recent Certbot releases issue ECDSA keys by default, which is what you want. Point nginx at `fullchain.pem`, not `cert.pem`:
+
+```nginx
+ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+```
+
+`cert.pem` holds only your leaf certificate. Browsers often fill in the missing intermediate on their own, so the site looks fine, but SSL Labs reports "Chain issues: Incomplete" and caps the grade at B. The [nginx docs](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate) say the same: the server certificate comes first in the file, followed by the intermediates.
+
+If you use Certbot's own installer (`certbot --nginx` without `certonly`), it adds `include /etc/letsencrypt/options-ssl-nginx.conf;` to your server block. That file sets protocols and ciphers too. Remove the include so your own settings are the only ones in play.
+
+## Step 2: Put the TLS settings in one shared file
+
+Debian and Ubuntu load every file in `/etc/nginx/conf.d/` inside the `http` block, so a single file there applies to all your HTTPS sites.
+
+First, clear the SSL lines that the package already put in `/etc/nginx/nginx.conf`. The stock Debian 13 file has this block inside `http { }`:
+
+```nginx
+##
+# SSL Settings
+##
+
+ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3 (POODLE), TLS 1.0, 1.1
+ssl_prefer_server_ciphers off; # Don't force server cipher order.
+```
+
+Your `conf.d/` file lands in the same `http` block, so these lines would collide with it. nginx refuses a second `ssl_prefer_server_ciphers` at the same level:
+
+```text
+nginx: [emerg] "ssl_prefer_server_ciphers" directive is duplicate in /etc/nginx/conf.d/tls.conf
+```
+
+A second `ssl_protocols` does not replace the first one either. nginx merges the two lists and only prints warnings such as `[warn] duplicate value "TLSv1.2"`, not an error. Comment out both lines with `#` (your release may have different values there), then check that nothing else sets them:
+
+```bash
+grep -rn -e ssl_protocols -e ssl_prefer_server_ciphers -e ssl_ciphers /etc/nginx/
+```
+
+Now create `/etc/nginx/conf.d/tls.conf`:
+
+```nginx
+# TLS settings for every server block (http context)
+ssl_protocols TLSv1.2 TLSv1.3;
+
+# OpenSSL 3.5+: hybrid post-quantum group first.
+# On OpenSSL 3.0-3.4, remove X25519MLKEM768 from this list.
+ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
+
+# TLS 1.2 ciphers: ECDHE key exchange + AEAD only.
+# TLS 1.3 suites are managed by OpenSSL and are all AEAD.
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+ssl_prefer_server_ciphers off;
+
+ssl_session_timeout 1d;
+ssl_session_cache shared:TLS:10m; # about 40000 sessions
+```
+
+Line by line:
+
+- **`ssl_protocols TLSv1.2 TLSv1.3`** turns off TLS 1.0 and 1.1 (the B cap) and turns on TLS 1.3 (without it you stop at A-). It is the default since nginx 1.23.4, but writing it here makes the intent clear. It does not override other `ssl_protocols` lines in the same block, which is why you removed them above.
+- **`ssl_ecdh_curve`** sets the key exchange groups. The list comes from the [TLSRef guideline 6.0](https://data.tlsref.org/guidelines/6.0.json), the successor of Mozilla's Server Side TLS guidelines. `X25519MLKEM768` combines classic X25519 with the ML-KEM post-quantum algorithm. Clients that do not support it fall back to X25519.
+- **`ssl_ciphers`** lists only ECDHE suites, which gives forward secrecy, and only GCM or ChaCha20-Poly1305, which are AEAD. No CBC, no RSA key exchange, no 3DES. TLSRef lists the oldest clients this profile still serves as Firefox 31, Chrome 49, Safari 9, Android 4.4.2, IE 11 on Windows 10 and Java 8u161. It also replaces the cipher list from my [2015 post on ssl_cipher in nginx]({% post_url 2015-04-12-ssl_cipher-di-nginx %}) (in Indonesian), which is out of date now.
+- **`ssl_prefer_server_ciphers off`** lets the client pick. With an all-strong list, the client knows best: phones without AES hardware choose ChaCha20.
+- **No `ssl_dhparam`.** The list has no DHE suites, so you do not need a DH parameters file. That also removes the old "DH parameters under 2048 bits" risk.
+- **Session tickets** stay at the default (`on`). Since nginx 1.23.2 the ticket keys rotate automatically when `ssl_session_cache` uses shared memory, as it does here. That is why TLSRef only adds `ssl_session_tickets off;` for older versions.
+
+The Mozilla SSL Configuration Generator now redirects to the [TLSRef Configurator](https://configurator.tlsref.org/). This file matches its "Intermediate" profile, which is the right choice for public websites. The "Modern" profile (TLS 1.3 only) also gets an A+, but cuts off older clients such as Android 4.4 and IE 11.
+
+## Step 3: The server blocks, HSTS and the HTTP redirect
+
+Now the site itself, for example `/etc/nginx/sites-available/example.com`, linked into `sites-enabled/`:
+
+```nginx
+# Port 80: redirect everything to HTTPS
+server {
+ listen 80;
+ listen [::]:80;
+ server_name example.com www.example.com;
+
+ return 308 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on; # nginx < 1.25.1: "listen 443 ssl http2;"
+ server_name example.com www.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ # HSTS: 2 years. "always" also sends it on 3xx/4xx/5xx responses.
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+
+ root /var/www/example.com;
+ index index.html;
+}
+```
+
+Notes on the HSTS line:
+
+- **`max-age=63072000`** is two years, the TLSRef value. SSL Labs needs at least 15768000 (six months) for the A+. A shorter value or a typo drops you to A-.
+- **`always`** matters. Without it, nginx adds the header only to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses. With `always`, error pages carry it too.
+- **`includeSubDomains`** tells browsers that every subdomain is HTTPS-only as well. Leave it out until you are sure that is true. If `intranet.example.com` still serves plain HTTP, browsers that have seen the header will refuse to load it.
+- The header goes only on the HTTPS server. Browsers ignore HSTS on plain HTTP, and the HSTS spec ([RFC 6797, section 7.2](https://www.rfc-editor.org/rfc/rfc6797#section-7.2)) says servers must not send it there.
+
+Both `listen` lines in each block are deliberate. If your domain has an AAAA record and nginx listens only on IPv4, SSL Labs tests the IPv6 address and gets a different answer. More on that in the troubleshooting table.
+
+## Step 4: OCSP stapling and Let's Encrypt
+
+Many A+ guides still tell you to add `ssl_stapling on;`, including my own [OCSP stapling post from 2015]({% post_url 2015-04-12-ocsp-stapling-di-nginx %}). For Let's Encrypt certificates, drop it.
+
+Let's Encrypt [ended OCSP support](https://letsencrypt.org/2024/12/05/ending-ocsp/): from 7 May 2025 its certificates no longer contain an OCSP URL, and the OCSP responders were switched off on 6 August 2025. Revocation now goes through CRLs. With stapling on and a current Let's Encrypt certificate, nginx logs a warning like this on every reload:
+
+```text
+[warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate
+```
+
+The site keeps working, but the line is noise. Remove `ssl_stapling` and `ssl_stapling_verify`. OCSP stapling has no effect on the SSL Labs grade.
+
+If your certificate comes from a CA that still runs OCSP, stapling is still useful. You need a resolver and the issuer chain:
+
+```nginx
+ssl_stapling on;
+ssl_stapling_verify on;
+ssl_trusted_certificate /etc/ssl/certs/ca-chain.pem; # root + intermediates
+resolver 127.0.0.53 valid=300s; # e.g. systemd-resolved; use your server's resolver
+```
+
+## Step 5: Avoid the add_header trap
+
+In nginx, `add_header` is inherited from the outer block only if the current block has no `add_header` of its own. Add one header in a `location` and every header from the `server` block disappears in that location, HSTS included. If that location is `/`, the grade drops to A-. If it is another path, the report stays A+ while part of the site runs without the header.
+
+Here is the result on the test server (port 8443) with the config above plus one extra location:
+
+```nginx
+location /assets/ {
+ add_header Cache-Control "public, max-age=3600";
+}
+```
+
+```text
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/ | grep -i strict
+strict-transport-security: max-age=63072000; includeSubDomains
+
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/assets/x | grep -i -e strict -e cache
+cache-control: public, max-age=3600
+```
+
+The second response has no HSTS.
+
+Three fixes, in order of preference:
+
+1. **nginx 1.29.3 or newer:** put `add_header_inherit merge;` in the `http` block. Headers from outer levels are then added to the ones you set in the inner block ([docs](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header_inherit)).
+2. **Older nginx:** move the security headers into a snippet and include it wherever you use `add_header` (example below).
+3. **For caching only:** use `expires 1h;` instead of `add_header Cache-Control`. It sets `Cache-Control` and `Expires` itself and, unlike `add_header`, does not cancel the headers inherited from the `server` block.
+
+The snippet approach looks like this:
+
+```nginx
+# /etc/nginx/snippets/security-headers.conf
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+```
+
+```nginx
+location /assets/ {
+ include snippets/security-headers.conf;
+ add_header Cache-Control "public, max-age=3600";
+}
+```
+
+And `location /assets/ { expires 1h; }` on the same test server:
+
+```text
+$ curl -sk -D- -o /dev/null https://127.0.0.1:8443/assets/x.css
+HTTP/2 200
+expires: Thu, 05 Nov 2026 01:41:51 GMT
+cache-control: max-age=3600
+strict-transport-security: max-age=63072000; includeSubDomains
+```
+
+The same inheritance rule applies to `if` blocks and nested locations.
+
+## Step 6: Test locally, then run SSL Labs
+
+Check the syntax and reload:
+
+```bash
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+Before you go to SSL Labs, check the important points from your own terminal. Replace `example.com` with your host:
+
+```bash
+# TLS 1.1 must be refused. SECLEVEL=0 lets a modern client even try it.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' &1 | grep -E 'alert|Cipher is'
+
+# TLS 1.3 must work. With OpenSSL 3.5+ on both sides the group is X25519MLKEM768.
+openssl s_client -connect example.com:443 -servername example.com \
+ -tls1_3 &1 | grep -E 'Negotiated TLS1.3 group|Cipher is'
+
+# CBC suites must be refused (covers both ECDSA and RSA certificates).
+openssl s_client -connect example.com:443 -servername example.com -tls1_2 \
+ -cipher 'ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA' &1 | grep -E 'alert|Cipher is'
+
+# Full chain: there must be a "depth=1" line (the intermediate sent by the server).
+openssl s_client -connect example.com:443 -servername example.com &1 | grep depth
+
+# HSTS header and redirect.
+curl -sI https://example.com/ | grep -i strict-transport-security
+curl -sI http://example.com/ | grep -i -e '^HTTP' -e '^location'
+```
+
+On the test setup, the output looked like this (condensed):
+
+```text
+tls1_1 : tlsv1 alert protocol version ... Cipher is (NONE)
+tls1_3 : Negotiated TLS1.3 group: X25519MLKEM768
+ New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
+CBC : ssl/tls alert handshake failure ... Cipher is (NONE)
+HSTS : strict-transport-security: max-age=63072000; includeSubDomains
+HTTP : HTTP/1.1 308 Permanent Redirect
+```
+
+When all of that looks right, open [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/), enter your hostname, and tick "Do not show the results on the boards" if you do not want the result listed publicly. SSL Labs caches results for a while. After a config change, use the "Clear cache" link on the results page to force a new scan. A full scan takes a few minutes, longer when the hostname has several IP addresses.
+
+## Troubleshooting: what the report says and how to fix it
+
+| What you see | Cause | Fix |
+|---|---|---|
+| Grade B, "This server supports TLS 1.0 and TLS 1.1" | Old `ssl_protocols` line somewhere, often in `nginx.conf` or a Certbot include | Run the `grep` from Step 2 and keep only `tls.conf` |
+| `nginx -t`: `"ssl_prefer_server_ciphers" directive is duplicate` | The stock `nginx.conf` still sets it in the same `http` block | Comment it out in `nginx.conf` (Step 2) |
+| Grade B, "Chain issues: Incomplete" | `ssl_certificate` points to `cert.pem` | Use `fullchain.pem` |
+| Grade B, cipher warnings about forward secrecy or CBC | Old `ssl_ciphers` list | Use the list from Step 2 |
+| Grade A-, "This server does not support TLS 1.3" | OpenSSL older than 1.1.1, or `ssl_protocols` without `TLSv1.3` | Upgrade the OS or nginx build; check `nginx -V` |
+| Grade A-, HSTS warning or "HSTS not set" | Header missing on the tested page, `max-age` too short, or the add_header trap | Check with `curl -sI`; see Step 5 |
+| A+ on one IP, A- on another | IPv6 handled by a different server block or a different machine | Add `listen [::]:443 ssl;` everywhere, or fix the AAAA record |
+| Grade does not change after you edit nginx | You are testing a CDN, not your server | See below |
+| `nginx -t` says `SSL_CTX_set1_curves_list(...) failed` | The OpenSSL behind nginx does not know a group in `ssl_ecdh_curve`, usually `X25519MLKEM768` on OpenSSL older than 3.5 | Remove that group from the list |
+
+**Behind Cloudflare or another CDN?** If the orange cloud is on, SSL Labs tests Cloudflare's edge, not your nginx. The grade then depends on your Cloudflare settings: minimum TLS version, TLS 1.3, and HSTS under SSL/TLS > Edge Certificates. Your nginx config still matters for the connection between Cloudflare and your origin, so use "Full (strict)" mode and keep the setup above on the origin. (This blog sits behind Cloudflare too; the move is described in [Migrasi ke Cloudflare]({% post_url 2014-11-10-migrasi-ke-cloudflare %}), in Indonesian.)
+
+## Optional: HSTS preload
+
+With HSTS preload, browsers ship with your domain on a built-in HTTPS-only list, so even the very first visit never uses HTTP. SSL Labs does not need it for an A+. The requirements on [hstspreload.org](https://hstspreload.org/) are stricter than the A+ ones:
+
+- `max-age` of at least 31536000 (one year), `includeSubDomains`, and `preload` in the header
+- every subdomain served over HTTPS, including internal ones
+- HTTP redirected to HTTPS on the same host first
+
+```nginx
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+```
+
+Getting off the list takes months to reach users through browser updates, and other browsers are not guaranteed to follow. Only submit a domain you know will stay HTTPS-only on every subdomain.
+
+## The complete config
+
+For reference, here are the two files used in the test above (adjust paths and names). Together with the two commented lines in `nginx.conf` from Step 2, that is the whole change:
+
+```nginx
+# /etc/nginx/conf.d/tls.conf
+ssl_protocols TLSv1.2 TLSv1.3;
+ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+ssl_prefer_server_ciphers off;
+ssl_session_timeout 1d;
+ssl_session_cache shared:TLS:10m;
+# add_header_inherit merge; # nginx 1.29.3+
+```
+
+```nginx
+# /etc/nginx/sites-available/example.com
+server {
+ listen 80;
+ listen [::]:80;
+ server_name example.com www.example.com;
+ return 308 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name example.com www.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+
+ root /var/www/example.com;
+ index index.html;
+}
+```
+
+If you need to support both ECDSA and RSA clients, nginx can load two certificates in the same server block, and it picks the right one per client:
+
+```nginx
+ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # ECDSA, from Step 1
+ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+ssl_certificate /etc/letsencrypt/live/example.com-rsa/fullchain.pem;
+ssl_certificate_key /etc/letsencrypt/live/example.com-rsa/privkey.pem;
+```
+
+On the test server, a TLS 1.2 client that only offered `ECDHE-RSA-AES128-GCM-SHA256` received the 2048-bit RSA certificate, and everyone else got ECDSA. With Certbot, run it a second time with `--key-type rsa --cert-name example.com-rsa` to get the RSA pair.
+
+*Mahyuddin Susanto is a Linux and networking sysadmin who runs his own servers and has written about nginx on this blog since 2009, starting with [nginx, dari nol]({% post_url 2009-05-09-nginx-dari-nol %}). [About](/about) · [Contact](/contact)*
+
+## Sources
+
+- Qualys SSL Labs, [SSL Server Rating Guide](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide), version 2009r, 16 May 2025
+- TLSRef, [Server Side TLS guideline 6.0](https://data.tlsref.org/guidelines/6.0.json) and [TLS Configurator](https://configurator.tlsref.org/)
+- nginx, [ngx_http_ssl_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html), [ngx_http_headers_module](https://nginx.org/en/docs/http/ngx_http_headers_module.html) and [CHANGES](https://nginx.org/en/CHANGES)
+- Let's Encrypt, [Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
+- [HSTS Preload List Submission](https://hstspreload.org/)
+- IETF, [RFC 6797: HTTP Strict Transport Security](https://www.rfc-editor.org/rfc/rfc6797)
diff --git a/img/ssl-labs-a-plus-apache-id.png b/img/ssl-labs-a-plus-apache-id.png
new file mode 100644
index 0000000..809aae4
Binary files /dev/null and b/img/ssl-labs-a-plus-apache-id.png differ
diff --git a/img/ssl-labs-a-plus-apache.png b/img/ssl-labs-a-plus-apache.png
new file mode 100644
index 0000000..64a0bdd
Binary files /dev/null and b/img/ssl-labs-a-plus-apache.png differ
diff --git a/img/ssl-labs-a-plus-nginx-id.png b/img/ssl-labs-a-plus-nginx-id.png
new file mode 100644
index 0000000..bec499f
Binary files /dev/null and b/img/ssl-labs-a-plus-nginx-id.png differ
diff --git a/img/ssl-labs-a-plus-nginx.png b/img/ssl-labs-a-plus-nginx.png
new file mode 100644
index 0000000..c31de45
Binary files /dev/null and b/img/ssl-labs-a-plus-nginx.png differ