‹ BLOG rikystudio.id

Infra · Jul 2026

Bad Gateway di Balik Reverse Proxy: Membedah Kasus Stalwart Mail Server yang Menolak Traefik

Studi kasus troubleshooting 502 Bad Gateway pada self-hosted mail server Stalwart di belakang Traefik — dari PROXY protocol yang salah kaprah sampai auto-ban yang mengunci proxy sendiri.

Ringkasan Kasus

Suatu pagi, panel admin mail server berbasis Stalwart di mail.domainpertama.id tiba-tiba menampilkan 502 Bad Gateway. Container-nya sehat, network internalnya normal, tapi Traefik — reverse proxy di depannya — sama sekali tidak bisa "komunikasi" dengan container itu.

Tulisan ini membedah proses investigasi ujung ke ujung: dari asumsi awal yang salah, jalan buntu, sampai akhirnya menemukan dua penyebab yang saling bertumpuk. Kalau kamu sedang self-hosting apapun di belakang Traefik/Coolify dan pernah mengalami error 502 yang membingungkan, semoga catatan ini membantu.

Gejala Awal

Kombinasi ini penting: kalau container-nya sehat dan bisa diakses langsung, tapi gagal lewat reverse proxy, itu petunjuk kuat masalahnya ada di jalur komunikasi antara Traefik dan container, bukan di aplikasi itu sendiri.

Part 1: Menyingkirkan Kemungkinan yang Jelas

Langkah pertama yang selalu paling membosankan tapi wajib dilakukan — eliminasi variabel:

docker ps | grep stalwart          # container hidup?
docker logs stalwart --tail 100    # ada crash / OOM?
docker inspect stalwart | grep -i oomkilled

Semua bersih. Container jalan normal, tidak ada tanda kehabisan memori (walau server ini memang punya riwayat RAM mepet — worth untuk dicatat, tapi ini bukan penyebabnya untuk kali ini).

Cek berikutnya: apakah Traefik dan container satu network yang sama, dan apakah IP internalnya konsisten.

docker network inspect coolify | grep -A 5 stalwart
docker exec coolify-proxy wget -qO- http://<ip-container>:8080 --timeout=5

Hasilnya: Connection reset by peer. Bukan timeout, bukan connection refused — tapi reset. Ini detail kecil yang penting: koneksi TCP-nya diterima, tapi diputus paksa setelah itu. Beda jauh maknanya dari "port tertutup" atau "host tidak terjangkau".

Part 2: Jalan Buntu — Teori Blocked-IP

Log Stalwart menunjukkan baris seperti ini berulang kali:

INFO Blocked IP address (security.ip-blocked) listenerId = "http", ... remoteIp = 10.0.1.6

IP itu ternyata memang IP internal Traefik. Masuk akal sekali menyimpulkan: Stalwart sedang memblokir Traefik lewat mekanisme auto-ban bawaannya (mirip fail2ban). Teori ini masuk akal, konsisten dengan gejala, dan mengarah ke rangkaian percobaan fix yang cukup dalam:

Titik ini penting sebagai pelajaran: satu baris log yang meyakinkan bisa menyeret investigasi ke arah yang salah selama berjam-jam kalau tidak divalidasi ulang dengan bukti yang lebih kuat. Blocked-IP itu nyata, tapi bukan akar masalah utama — dia baru muncul setelah percobaan koneksi berulang, bukan penyebab dari awal.

Part 3: Petunjuk Sebenarnya — "Invalid Proxy Header"

Sambil menelusuri log lebih teliti, muncul baris yang sebelumnya terlewat:

WARN Proxy protocol error (network.proxy-error) listenerId = "http", ... reason = "invalid proxy header"

Ini kunci sebenarnya. Stalwart mengharapkan setiap koneksi masuk membawa PROXY protocol header — mekanisme di mana reverse proxy "menitipkan" informasi IP asli klien di awal koneksi TCP, sebelum data HTTP sesungguhnya dikirim. Fitur ini berguna supaya server di belakang proxy tetap tahu IP asli pengunjung, bukan cuma IP proxy-nya.

Masalahnya: Traefik, sebagai HTTP router (bukan TCP passthrough), tidak mengirim header itu. PROXY protocol di Traefik hanya tersedia untuk service bertipe TCP, bukan HTTP — dan router mail.domainpertama.id di setup ini dikonfigurasi sebagai HTTP router dengan TLS termination di Traefik.

Hasilnya: kontradiksi yang sederhana tapi sulit dilihat — Stalwart menunggu header yang secara arsitektural tidak akan pernah dikirim oleh Traefik.

Kenapa Setting Ini Bisa Aktif?

Penelusuran lebih lanjut menemukan dua sumber yang tumpang tindih:

  1. Environment variable STALWART_HTTP_TRUSTED_IPS=10.0.1.0/24 di docker-compose.yml — ternyata cuma dipakai sekali waktu instalasi awal untuk seed database, bukan dibaca ulang setiap start.
  2. Setting permanen proxyTrustedNetworks yang tersimpan di database Stalwart sendiri (accessible via objek SystemSettings), independen dari env var.

Menghapus env var saja tidak cukup — settingan yang sudah tersimpan di database tetap berlaku.

Part 4: Membaca dan Menulis Konfigurasi Tanpa Admin Panel

Ironisnya, untuk mengubah setting ini biasanya cukup lewat WebUI — tapi WebUI-nya sendiri tidak bisa diakses karena masalah yang sama (listener HTTP yang sama juga melayani port admin). Solusinya: berbicara langsung ke Stalwart lewat JMAP API, dengan trik mengirim PROXY protocol header palsu secara manual menggunakan nc:

{ printf 'PROXY TCP4 127.0.0.1 127.0.0.1 12345 8081\r\n'; \
  printf 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'; \
  sleep 1; } | nc 127.0.0.1 8081

Trik ini "menipu" listener supaya percaya koneksi tersebut sudah membawa header yang diminta, sehingga request bisa diteruskan. Dari sana, dibangun request JMAP penuh (session discovery ke /jmap/session, lalu method call x:SystemSettings/get dan x:SystemSettings/set) lewat script Python yang mengulangi trik yang sama di level socket mentah.

Detail teknis yang perlu dicatat buat siapapun yang mengalami hal serupa:

Setelah proxyTrustedNetworks berhasil dikosongkan lewat API, error "invalid proxy header" hilang dari log. Satu masalah selesai — tapi 502 masih muncul.

Part 5: Auto-Ban yang Menembak Mati Sekutu Sendiri

Setelah proxy protocol tidak lagi diwajibkan, log berganti pesan — kembali ke "Blocked IP address" untuk IP Traefik. Kali ini teorinya benar: percobaan koneksi berulang selama proses debug sebelumnya (semasa listener masih menolak koneksi) rupanya cukup untuk memicu auto-ban Stalwart terhadap IP Traefik sendiri.

Setelah admin panel akhirnya bisa diakses (lewat perbaikan tunnel SSH — soal port forwarding yang salah arah, sebuah cerita tersendiri), ditemukan bahwa:

Ini ternyata bug yang sudah dikenal di beberapa versi Stalwart — beberapa kategori auto-ban (khususnya loitering ban, yang menghukum koneksi yang dibuka lalu ditutup cepat tanpa request lengkap — persis pola health-check reverse proxy) tidak selalu menghormati daftar Allowed IPs secara konsisten.

Workaround yang berhasil: menaikkan threshold loitering ban dan abuse attempt ban jauh lebih longgar (bukan mematikannya total), lalu memastikan entry Allowed IP menyasar IP tunggal (/32) selain CIDR range, sebagai lapisan cadangan. Setelah restart container, Traefik akhirnya bisa terhubung normal — dan mail.domainpertama.id kembali online.

Pelajaran yang Bisa Diambil

1. Pesan error yang paling mencolok belum tentu akar masalahnya. Baris "Blocked IP address" muncul lebih dulu dan paling sering di log — tapi itu gejala susulan, bukan penyebab awal. Penyebab sesungguhnya ("invalid proxy header") justru lebih jarang muncul dan mudah terlewat di antara noise log lainnya.

2. Kenali batasan arsitektural sebelum menambah konfigurasi. PROXY protocol terdengar seperti solusi masuk akal untuk "server tahu IP asli klien" — tapi kalau reverse proxy di depannya adalah HTTP router (bukan TCP passthrough), fitur itu justru jadi jebakan. Baca dulu kapabilitas reverse proxy yang dipakai sebelum mengaktifkan fitur di sisi backend yang mensyaratkannya.

3. Environment variable saat container start ≠ konfigurasi yang berlaku selamanya. Banyak aplikasi modern (termasuk Stalwart) memakai database internal untuk menyimpan konfigurasi setelah instalasi awal. Mengubah env var dan restart container tidak menjamin perubahan itu benar-benar diterapkan — perlu dicek langsung di lapisan yang benar-benar dibaca aplikasi saat runtime.

4. Auto-protection kadang butuh diverifikasi ulang, bukan cuma dipercaya. Fitur seperti allowlist seharusnya jadi jaminan mutlak. Kalau ternyata tidak — seperti kasus loitering ban di atas — jangan asumsikan konfigurasi kita yang salah sebelum mengecek apakah ini memang perilaku (atau bug) yang diketahui di komunitas project tersebut.

5. Alat darurat itu ada, meski tidak didokumentasikan dengan baik. Ketika WebUI dan REST API sama-sama tidak bisa diandalkan, kombinasi nc + PROXY protocol header manual + JMAP raw request lewat socket Python jadi jalan keluar, meskipun dengna jalan yang kurang cantik tapi tetap bisa diandalkan untuk membaca dan menulis konfigurasi langsung ke server.


Catatan: nama domain dan IP internal pada tulisan ini adalah milik infrastruktur pribadi penulis, ditampilkan apa adanya untuk konteks teknis.