Bayangkan sebuah gedung perkantoran dengan satu alamat dan satu meja resepsionis. Setiap kurir datang ke pintu yang sama, lalu resepsionis cukup membaca nama penerima di amplop dan mengantar paket ke lantai yang tepat. Resepsionis tidak perlu membuka amplopnya. Begitulah cara kerja Nginx SNI passthrough: satu IP publik di VPS menerima semua koneksi HTTPS, membaca nama domain tujuan, lalu meneruskan koneksi yang masih terenkripsi ke server yang benar.
Pola ini sangat berguna jika server Anda berada di belakang NAT atau CGNAT, misalnya PC Proxmox di kantor yang memakai internet Starlink. Pada tutorial ini kami membedah konfigurasi yang benar-benar berjalan di VPS jembatan kami. VPS itu melayani 18 domain untuk empat tujuan berbeda. Selain itu, kami menceritakan dua kejadian nyata: nginx sempat mati karena dua blok stream, dan cara kami memastikan IP asli pengunjung tetap tercatat di log aplikasi.

Apa Itu Nginx SNI Passthrough?
Saat browser membuka koneksi HTTPS, pesan pertamanya adalah TLS ClientHello. Pesan ini masih berupa teks biasa dan memuat kolom SNI (Server Name Indication), yaitu nama domain yang ingin browser buka. Modul stream milik nginx bisa mengintip kolom tersebut lewat ssl_preread, lalu memilih tujuan tanpa menyentuh enkripsi.
Akibatnya, VPS tidak perlu menyimpan sertifikat SSL untuk domain yang ia teruskan. Sertifikat dan certbot tetap berada di masing-masing server tujuan. Namun, Anda juga bisa mencampur dua cara dalam satu VPS, seperti yang kami lakukan.
| Cara | Sertifikat di mana | Kelebihan | Kekurangan |
|---|---|---|---|
| Terminate di VPS (reverse proxy biasa) | VPS | Bisa cache, header, dan aturan per URL | VPS memegang semua private key, dan trafik di tunnel menjadi HTTP biasa |
| SNI passthrough | Server tujuan | VPS tidak bisa membaca isi trafik, dan tiap server mengurus SSL sendiri | VPS tidak bisa mengubah header, sehingga IP asli butuh PROXY protocol |
| Campuran (pola kami) | Sebagian di VPS, sebagian di server tujuan | Aplikasi monitoring tetap di VPS, aplikasi lain di LAN | Butuh listener internal tambahan (127.0.0.1:8443) |
Topologi dan IP Contoh
Artikel ini memakai IP contoh agar mudah Anda sesuaikan. Jika Anda belum punya tunnel WireGuard antara VPS dan server lokal, siapkan dulu lewat tutorial akses Proxmox dari internet dengan WireGuard dan VPS. Artikel tersebut sudah menyinggung SNI secara singkat, sedangkan di sini kita membahasnya jauh lebih dalam.
| Peran | IP contoh | Domain |
|---|---|---|
| VPS jembatan | 203.0.113.10 (publik), 10.99.0.1 (wg0) | monitor.contoh.com (TLS di VPS) |
| LXC app | 10.10.10.11 | app.contoh.com dan *.app.contoh.com |
| LXC billing | 10.10.10.12 | billing.contoh.com |
| Pengunjung | 198.51.100.7 | – |
Langkah 1: Pasang Modul Stream di VPS
Di Ubuntu dan Debian, modul stream tersedia sebagai paket terpisah. Pertama, pasang paketnya. Kemudian, tambahkan satu baris include di nginx.conf yang berada di luar blok http { }.
sudo apt install -y libnginx-mod-stream sudo mkdir -p /etc/nginx/stream.d grep -q "stream.d" /etc/nginx/nginx.conf || sudo sed -i '/^include \/etc\/nginx\/modules-enabled/a include /etc/nginx/stream.d/*.conf;' /etc/nginx/nginx.conf grep -n "include" /etc/nginx/nginx.conf | head -3
Perintah sed menyisipkan baris baru tepat setelah include /etc/nginx/modules-enabled/*.conf;. Urutan ini penting, karena modul stream harus termuat sebelum nginx membaca blok stream. Selain itu, grep -q mencegah baris yang sama tertulis dua kali jika Anda menjalankan perintah ini ulang.
Langkah 2: Tulis Peta SNI di VPS
Selanjutnya, buat file /etc/nginx/stream.d/sni-router.conf. Kami membaginya menjadi dua bagian agar mudah Anda ikuti. Simpan kedua bagian berurutan dalam satu file.
Bagian 1: Log dan Peta Domain
stream {
# log khusus router SNI: siapa, minta domain apa, diteruskan ke mana
log_format sni '$remote_addr [$time_local] sni="$ssl_preread_server_name" '
'-> $upstream_addr status=$status ${session_time}s';
access_log /var/log/nginx/sni-router.log sni;
map $ssl_preread_server_name $sni_backend {
hostnames;
monitor.contoh.com vps_local;
app.contoh.com lxc_app;
*.app.contoh.com lxc_app;
billing.contoh.com lxc_billing;
default lxc_app;
}
Variabel $ssl_preread_server_name berisi SNI dari browser. Kemudian map mencocokkan nilai itu ke nama upstream. Parameter hostnames mengaktifkan pola wildcard seperti *.app.contoh.com. Baris default menangkap domain yang tidak tercantum. Di produksi kami, default mengarah ke container hotel karena pelanggan hotel bisa memasang domain sendiri kapan saja.
Log sni adalah tambahan yang tidak ada di artikel sebelumnya. Log ini sangat membantu saat troubleshooting, karena setiap baris menunjukkan domain yang pengunjung minta dan server yang menerimanya.
Bagian 2: Upstream dan Pendengar Port 443
upstream vps_local { server 127.0.0.1:8443; }
upstream lxc_app { server 10.10.10.11:443; }
upstream lxc_billing { server 10.10.10.12:443; }
server {
listen 443;
ssl_preread on;
proxy_pass $sni_backend;
proxy_protocol on;
proxy_connect_timeout 10s;
proxy_timeout 300s;
}
}
Perhatikan bahwa listen 443 tidak memakai kata ssl. Blok ini memang hanya mengalirkan byte TCP. Lalu ssl_preread on mengizinkan nginx mengintip ClientHello, sedangkan proxy_protocol on menempelkan satu baris header berisi IP asli pengunjung di depan koneksi. Terakhir, proxy_timeout 300s menjaga koneksi panjang seperti WebSocket tetap hidup.
Sebagai gambaran nyata, peta di VPS kami berisi 18 domain dengan empat tujuan. Menambah domain baru cukup dengan satu baris di map, lalu reload nginx.

Langkah 3: Port 80 dan Listener Internal VPS
Port 80 tetap perlu terbuka untuk redirect ke HTTPS dan untuk verifikasi certbot di tiap container. Karena HTTP biasa tidak punya SNI, kita memakai map $host di blok http. Simpan sebagai /etc/nginx/sites-available/00-default-catchall, lalu aktifkan dengan symlink ke sites-enabled.
# Port 80: Host yang tidak punya vhost di VPS diteruskan ke backend sesuai peta
map $host $http80_backend {
hostnames;
app.contoh.com 10.10.10.11;
*.app.contoh.com 10.10.10.11;
billing.contoh.com 10.10.10.12;
default 10.10.10.11;
}
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://$http80_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
}
}
# Listener TLS internal VPS: SNI yang tidak dikenal ditolak
server {
listen 127.0.0.1:8443 ssl default_server proxy_protocol;
server_name _;
ssl_reject_handshake on;
}
Peta port 80 ini harus selalu sejalan dengan peta SNI. Server terakhir menjadi default untuk listener internal 127.0.0.1:8443. Berkat ssl_reject_handshake on, nginx menolak handshake untuk domain yang tidak punya vhost di VPS, sehingga sertifikat milik situs lain tidak bocor ke domain asing.
Langkah 4: Situs yang TLS-nya Tetap di VPS
Beberapa layanan lebih aman tinggal di VPS, misalnya aplikasi monitoring. Jika server lokal mati, monitoring tetap bisa mengirim alarm. Untuk situs seperti ini, ganti listen 443 ssl menjadi listener internal yang menerima PROXY protocol.
server {
listen 80;
server_name monitor.contoh.com;
location / { return 301 https://$host$request_uri; }
}
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
server_name monitor.contoh.com;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
ssl_certificate /etc/letsencrypt/live/monitor.contoh.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monitor.contoh.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
Koneksi dari router SNI datang dari 127.0.0.1. Oleh karena itu, set_real_ip_from 127.0.0.1 membuat nginx mempercayai header PROXY dari alamat tersebut. Kemudian $remote_addr berubah menjadi IP pengunjung asli dan aplikasi menerimanya lewat X-Real-IP.
Langkah 5: Backend Membaca IP Asli Pengunjung
Sekarang pindah ke setiap container. Pertama, buat /etc/nginx/conf.d/00-realip.conf agar nginx mempercayai header PROXY yang datang dari VPS.
# Semua koneksi publik datang dari VPS lewat WireGuard (10.99.0.1) set_real_ip_from 10.99.0.1; real_ip_header proxy_protocol;
Kemudian buat server default di container. Bagian ini adalah kunci yang sering terlewat: parameter proxy_protocol pada listen berlaku per socket, bukan per vhost. Jadi cukup pasang di default_server, dan semua vhost di port 443 ikut menerima header PROXY.
server { listen 80 default_server; server_name _; return 444; }
server { listen 443 ssl default_server proxy_protocol; server_name _; ssl_reject_handshake on; }
Vhost Aplikasi di Container
server {
listen 80;
server_name app.contoh.com;
real_ip_header X-Forwarded-For;
location ^~ /.well-known/acme-challenge/ { root /var/www/acme; default_type text/plain; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
server_name app.contoh.com;
ssl_certificate /etc/letsencrypt/live/app.contoh.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.contoh.com/privkey.pem;
root /var/www/app;
index index.html;
location / { try_files $uri $uri/ =404; }
}
Port 80 tidak melewati router SNI, melainkan proxy HTTP biasa di VPS. Karena itu, blok port 80 mengganti sumber IP menjadi X-Forwarded-For. Selain itu, lokasi /.well-known/acme-challenge/ membuat certbot mode webroot tetap jalan di dalam container: certbot certonly --webroot -w /var/www/acme -d app.contoh.com.
Uji Nginx SNI Passthrough dari Luar
Selalu cek sintaks sebelum reload. Setelah itu, uji setiap domain dengan curl --resolve. Opsi ini memaksa curl menuju IP VPS tanpa menunggu DNS berubah, jadi Anda bisa menguji sebelum memindahkan record DNS.
sudo nginx -t && sudo systemctl reload nginx curl --resolve app.contoh.com:443:203.0.113.10 https://app.contoh.com/ curl --resolve billing.contoh.com:443:203.0.113.10 https://billing.contoh.com/ echo | openssl s_client -connect 203.0.113.10:443 -servername billing.contoh.com 2>/dev/null | grep -E "subject=|Verify return code" sudo tail -5 /var/log/nginx/sni-router.log
Kami menguji seluruh konfigurasi di atas di server uji. Kami menjalankan nginx VPS dan dua nginx container di network namespace terpisah, lengkap dengan IP yang sama seperti tabel topologi. Hasilnya tampak di bawah. Perintah openssl s_client membuktikan bahwa sertifikat billing.contoh.com berasal dari container, bukan dari VPS.

Domain asing seperti tidakada.contoh.com jatuh ke tujuan default, lalu container menolaknya dengan unrecognized name. Perilaku ini memang kita inginkan. Sebaliknya, kolom status=200 di log stream hanya berarti sesi TCP selesai normal, bukan kode HTTP.
Contoh Kasus 1: Nginx Mati karena Dua Blok stream
Pada hari migrasi, kami menambahkan relay SMTP agar server lokal bisa mengirim email lewat IP tetap VPS. Kami menaruh relay itu di file baru stream.d/egress-relay.conf yang berisi blok stream { } sendiri. Ternyata nginx hanya mengizinkan satu blok stream di seluruh konfigurasi.
Masalahnya bertambah karena skrip kami memanggil systemctl restart nginx. Restart menghentikan nginx lebih dulu, baru kemudian menguji konfigurasi. Akibatnya, semua situs di VPS mati sampai kami menghapus file tersebut dan menyalakan nginx lagi.

Solusi: Satu Blok stream untuk Semua Layanan
Kami memindahkan relay ke dalam blok stream yang sudah ada. Tambahkan server berikut tepat sebelum kurung kurawal penutup di sni-router.conf.
# relay SMTP keluar, cukup di IP tunnel
server {
listen 10.99.0.1:2525;
proxy_pass 192.0.2.25:587;
proxy_connect_timeout 15s;
proxy_timeout 120s;
}
}
Alternatifnya, pindahkan blok stream ke nginx.conf, lalu biarkan file di folder lain hanya berisi server dan map. Dengan pola ini, siapa pun yang menambah file baru tidak mungkin membuat blok ganda.
stream {
include /etc/nginx/stream-sites/*.conf;
}
Pelajaran: Uji Dulu, Lalu Reload
Biasakan pola sudo nginx -t && sudo systemctl reload nginx. Jika konfigurasi salah, nginx -t menghentikan rantai perintah. Bahkan jika reload tetap berjalan, nginx menolak konfigurasi baru dan worker lama terus melayani pengunjung. Kami membuktikannya di lab: setelah reload gagal dengan pesan yang sama, curl ke app.contoh.com tetap mendapat jawaban normal.
Contoh Kasus 2: IP Asli Pengunjung Tercatat di Log
Tanpa PROXY protocol, container hanya melihat satu IP untuk semua pengunjung, yaitu IP VPS di tunnel. Dampaknya serius. Rate limit login memblokir semua orang sekaligus, CrowdSec tidak bisa membedakan penyerang, dan log audit kehilangan makna.

Setelah dua hari berjalan, kami memeriksa log aplikasi hotel di LXC 101. Dari 2.592 baris, IP publik pengunjung tercatat dengan benar. Uptime Kuma di VPS juga tampil dengan IP publik VPS, bukan IP tunnel.

Hanya ada satu baris ber-IP 10.99.0.1 dengan request kosong dan status 400. Kami mereproduksi pola itu di lab dengan openssl s_client yang menutup koneksi tanpa mengirim request HTTP. Penjelasannya, modul realip baru bekerja setelah nginx membaca header request. Jika klien pergi sebelum mengirim request, nginx mencatat alamat koneksi mentah. Jadi baris seperti ini wajar dan bukan tanda konfigurasi bocor.
Membaca Pesan Error yang Sering Muncul
Kami sengaja merusak konfigurasi lab satu per satu untuk melihat gejalanya. Tabel berikut merangkum hasilnya, sehingga Anda bisa langsung menebak penyebab dari pesan error.
| Gejala | Penyebab | Perbaikan |
|---|---|---|
"stream" directive is duplicate | Ada dua blok stream { } | Gabungkan menjadi satu blok |
Error log container: broken header ... while reading PROXY protocol, curl: unexpected eof | Container menunggu header PROXY, tetapi VPS tidak mengirimnya | Aktifkan proxy_protocol on di VPS |
curl: wrong version number | VPS mengirim header PROXY, tetapi listen container tidak memuat proxy_protocol | Tambahkan proxy_protocol di default_server port 443 |
| Log container berisi 10.99.0.1 untuk semua request | Tidak ada set_real_ip_from atau real_ip_header | Buat 00-realip.conf |
curl: tlsv1 unrecognized name | Domain tidak punya vhost di tujuan | Tambahkan domain ke peta dan buat vhost-nya |
Kesalahan Umum
- Menaruh
include stream.ddi dalam blokhttp. Blokstreamharus sejajar denganhttp, bukan di dalamnya. - Membuat blok
streamkedua. Nginx menolak start. Simpan semua server stream dalam satu blok. - Memakai
restartsetelah mengedit. Restart mematikan nginx sebelum menguji. Pakainginx -t && systemctl reload nginx. - Hanya memperbarui peta SNI. Saat menambah domain, perbarui juga peta port 80. Tanpa itu, certbot di container gagal memverifikasi domain baru.
- Memasang
proxy_protocoldi satu sisi saja. VPS dan container wajib sepakat, karena hasilnya selalu gagal total. - Mempercayai header PROXY dari semua alamat. Batasi
set_real_ip_fromke IP VPS (10.99.0.1) atau 127.0.0.1 saja, agar orang lain tidak bisa memalsukan IP. - Menulis
listen [::]:443di VPS tanpa IPv6. Nginx gagal bind dan paket bisa gagal terpasang. Hapus baris IPv6 jika VPS memang tidak punya IPv6.
Kesimpulan
Nginx SNI passthrough membuat satu VPS murah cukup untuk mempublikasikan banyak server di belakang NAT. VPS hanya membaca nama domain, sedangkan sertifikat dan certbot tetap tinggal di server tujuan. Dengan menambahkan PROXY protocol di VPS dan modul realip di container, log aplikasi tetap mencatat IP pengunjung asli.
Pada akhirnya, kunci keberhasilannya ada pada disiplin kecil. Pertama, pertahankan satu blok stream. Kemudian, jaga peta SNI dan peta port 80 tetap sejalan. Terakhir, selalu jalankan nginx -t sebelum reload. Di produksi kami, pola ini sekarang melayani 18 domain dari tiga container Proxmox dan satu set situs di VPS sendiri.
Baca Juga
- Cara Akses Proxmox dari Internet Tanpa IP Publik (WireGuard + VPS)
- Keamanan Server Linux: Tutorial Hardening SSH, Firewall UFW, CrowdSec, dan Alarm Telegram
- Cara Membuat Dashboard Monitoring Server (NOC) dengan Uptime Kuma dan Beszel
Referensi: nginx: ngx_stream_ssl_preread_module, nginx: proxy_protocol (stream), nginx: ngx_http_realip_module, Spesifikasi PROXY protocol.

