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
- Admin panel Stalwart bisa diakses lewat
localhost:8099(tunnel SSH langsung ke server) — berfungsi normal. - Domain publik
mail.domainpertama.id(di belakang Traefik) mengembalikan 502 Bad Gateway. - Container
stalwartberstatushealthydidocker ps.
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:
- Mencoba REST API lama Stalwart (
/api/settings) untuk unblock — ternyata API ini sudah dihapus total di Stalwart v0.16, diganti JMAP. - Masuk ke RocksDB console bawaan Stalwart (
stalwart --console) untuk mencoba hapus key blocked-ip secara langsung — gagal, karena key di database di-encode dalam format binary internal yang tidak bisa ditebak dari luar. - Melakukan export penuh database ke format yang bisa dibaca (
stalwart --export) untuk mencari jejak string terkait blocked-ip — tapi nihil.
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:
- Environment variable
STALWART_HTTP_TRUSTED_IPS=10.0.1.0/24didocker-compose.yml— ternyata cuma dipakai sekali waktu instalasi awal untuk seed database, bukan dibaca ulang setiap start. - Setting permanen
proxyTrustedNetworksyang tersimpan di database Stalwart sendiri (accessible via objekSystemSettings), 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:
- Objek administratif Stalwart di JMAP diberi prefix
x:pada nama method dan tipe (x:SystemSettings/get, bukanSystemSettings/get). - Header
Hostpada request HTTP harus sesuai hostname yang dikonfigurasi di server (mail.domainpertama.id), bukanlocalhost— kalau tidak, server mengembalikan redirect loop ke/jmap/session.
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:
- IP Traefik sudah ada di daftar Allowed IPs sejak awal.
- Tapi tetap masuk Blocked IPs berulang kali, bahkan setelah dihapus manual.
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.