Cara Memperbaiki Docker yang Melewati UFW
Docker memasang aturan iptables yang melewati UFW, sehingga port yang ditolak tetap menjawab dari Internet. Pahami penyebabnya dan dua perbaikan yang berfungsi.
Mengapa Docker melewati UFW
Docker melewati UFW karena port container yang dipublikasikan tidak pernah melewati aturan firewall yang dikelola UFW. Saat Anda menjalankan docker run -p 8080:80, Docker menulis aturan DNAT (destination network address translation) ke dalam chain DOCKER miliknya sendiri pada tabel kernel nat, yang menjadi tujuan lompatan dari chain PREROUTING. Aturan tersebut mengubah tujuan setiap paket menjadi alamat privat container sebelum kernel menentukan tujuan paket. Paket yang telah diubah kemudian diteruskan ke container melalui chain FORWARD, yang dikendalikan Docker. Aturan UFW berada di chain INPUT, sedangkan paket tidak pernah memasukinya. Karena itu, ufw status menampilkan default deny, sudo ufw deny 8080 melaporkan keberhasilan, dan port 8080 tetap merespons dari seluruh Internet.
Ini bukan bug Docker, dan UFW tidak rusak. Kedua alat tersebut memprogram firewall kernel yang sama. Aturan Docker hanya bekerja lebih awal pada jalur paket, sehingga UFW tidak pernah memprosesnya. Panduan ini mendemonstrasikan bypass tersebut, menjelaskan mekanismenya, lalu membahas dua perbaikan yang berfungsi: memublikasikan port pada 127.0.0.1 dan memfilter pada chain DOCKER-USER. Jika UFW masih baru bagi Anda, siapkan terlebih dahulu dengan panduan dasar firewall UFW, karena firewall default-deny tetap merupakan dasar yang tepat untuk bagian lain di server.
Lihat bypass ini di server Anda sendiri
Mulai dari VPS dengan UFW yang aktif dan kebijakan default deny untuk trafik masuk. Jalankan container web dengan port yang dipublikasikan:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose menampilkan Default: deny (incoming), allow (outgoing) dan tidak ada rule untuk port 8080. Berdasarkan laporan firewall sendiri, port tersebut tertutup. Sekarang lakukan pengujian dari mesin lain, bukan dari server itu sendiri:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKContainer merespons. Tambahkan rule deny secara eksplisit, lalu lakukan pengujian lagi:
sudo ufw deny 8080/tcpPort tersebut masih merespons karena rule deny berada di chain yang tidak pernah dilewati paket. UFW tidak gagal. UFW memang tidak pernah dipanggil. Inilah alasan masalah ini sulit terdeteksi: tidak ada error yang ditampilkan di mana pun, deployment berhasil, dan output status firewall terlihat persis seperti server yang dikunci dengan baik.
Mekanismenya: PREROUTING berjalan sebelum INPUT
Kernel memproses paket masuk dalam urutan tetap, dan seluruh masalah ini disebabkan oleh urutan tersebut.
PREROUTINGberjalan terlebih dahulu. Aturan yang dicapai dari sini dapat mengubah tujuan paket, dan aturan Docker untuk port yang dipublikasikan, dalam chainDOCKERyang menjadi tujuan lompatanPREROUTING, memang melakukan hal tersebut.- Berikutnya, kernel mengambil keputusan routing. Paket yang ditujukan ke host itu sendiri masuk ke chain
INPUT. Paket yang ditujukan ke mesin lain masuk ke chainFORWARD. - Aturan UFW berada di
INPUT. Aturan Docker berada diFORWARD.
Periksa aturan Docker untuk container yang baru saja Anda jalankan:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80Baris DNAT menjelaskan seluruh masalah ini. Setiap paket yang masuk untuk port 8080 akan diubah tujuannya menjadi 172.17.0.2:80, yaitu alamat container pada jaringan bridge privat Docker. Setelah perubahan tersebut, paket tidak lagi ditujukan ke host. Karena itu, keputusan routing mengirimnya melalui jalur FORWARD, tempat Docker telah menambahkan aturan yang menerima trafik ke jaringan miliknya. Aturan deny 8080/tcp Anda menunggu di INPUT untuk paket yang tidak pernah tiba.
Di Ubuntu 24.04, perintah iptables merupakan front end untuk nftables, tetapi urutan chain dan hasilnya tetap sama. UFW dan Docker sama-sama menulis aturan ke pipeline paket kernel yang sama, dan titik masuk Docker berada lebih awal. Hal ini tidak khusus terjadi pada UFW: firewalld pada VPS Rocky atau AlmaLinux memfilter pada titik yang sama dalam pipeline tersebut dan dilewati oleh aturan DNAT yang sama. Karena itu, gunakan perbaikan di bawah ini untuk kasus tersebut juga.
Perbaikan sehari-hari: publikasikan port pada 127.0.0.1
Sebagian besar container sejak awal tidak perlu dapat diakses publik. Database, server aplikasi di belakang reverse proxy, panel admin, dan endpoint metrik tidak boleh menjawab koneksi Internet secara langsung. Publikasikan layanan tersebut pada alamat loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineAtau gunakan file Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Ini berfungsi karena aturan DNAT Docker sekarang hanya mencocokkan paket yang ditujukan ke 127.0.0.1. Pada Docker Engine 28.0.0 dan versi yang lebih baru, Docker juga menambahkan aturan pada chain PREROUTING di tabel raw yang menjatuhkan paket untuk port loopback tersebut jika paket tiba melalui interface selain loopback. Pada rilis tersebut, port dapat diakses dari host dan tidak dari sumber lain. Pada rilis yang lebih lama dari 28.0.0, Docker mendokumentasikan bahwa host dalam segmen L2 yang sama, misalnya mesin yang terhubung ke switch jaringan yang sama, masih dapat mengakses port yang dipublikasikan ke localhost. Karena itu, periksa docker version dan lakukan upgrade sebelum mengandalkan konfigurasi ini. Verifikasi binding tersebut:
sudo ss -tlnp | grep 8080Output harus berisi 127.0.0.1:8080, bukan 0.0.0.0:8080 atau [::]:8080. Selanjutnya, pastikan dari mesin lain bahwa curl http://your-vps-ip:8080/ tidak menerima respons. Paket yang dikirim ke alamat publik Anda tidak lagi ditulis ulang oleh Docker. Paket tersebut mencapai INPUT, tempat kebijakan default deny UFW dan aturan deny 8080/tcp dari bagian sebelumnya menjatuhkannya secara diam-diam, bukan menolaknya. Karena itu, curl akan menunggu lalu berakhir dengan connection timeout, bukan "connection refused".
Untuk layanan yang memang harus menghadap Internet, jalankan satu reverse proxy yang menggunakan port 80 dan 443 serta melakukan routing berdasarkan hostname. Jangan publikasikan port lain. Itulah pola yang digunakan dalam panduan reverse proxy Traefik, dan pola ini membuat aplikasi self-hosted seperti Nextcloud pada VPS tetap tidak dapat dijangkau kecuali melalui proxy-nya. Cara mendeklarasikan entri ports: dan alur kerja Compose lainnya dibahas dalam panduan dasar Docker Compose.
Jika setiap container internal menggunakan loopback, UFW kembali menjalankan fungsi normalnya: melindungi port yang disediakan oleh host itu sendiri. Susun rule set tersebut di sini, lalu jalankan perintah berikut secara berurutan:
Penyaringan nyata: chain DOCKER-USER
Terkadang port container harus tetap dipublikasikan ke jaringan, tetapi aksesnya harus dibatasi. Contohnya adalah port replika database yang hanya boleh diakses dari satu alamat kantor. Untuk kebutuhan ini, Docker menyediakan chain DOCKER-USER. Setiap paket yang menuju container mana pun melewati DOCKER-USER sebelum mencapai aturan accept milik Docker. Chain ini disediakan untuk aturan Anda, dan Docker tidak menghapus atau mengubahnya, termasuk saat daemon dimulai ulang.
Chain ini merupakan bagian dari backend firewall iptables default Docker. Docker Engine 29 menambahkan backend nftables eksperimental yang harus diaktifkan dengan opsi daemon firewall-backend. Pada backend tersebut tidak ada chain DOCKER-USER. Penyaringan khusus ditempatkan dalam table nftables terpisah milik Anda, dengan base chain pada hook forward. Jika Anda belum mengubah opsi tersebut, berarti Anda menggunakan iptables dan semua langkah di bawah ini berlaku. Perbedaan antara kedua tool tersebut dibahas dalam panduan iptables vs nftables.
Perhatikan satu hal sebelum menjalankan perintah. Saat paket mencapai DOCKER-USER, penulisan ulang DNAT sudah terjadi. Port tujuan paket adalah port container (80 dalam contoh ini), bukan port yang dipublikasikan (8080). Karena itu, aturan yang mencocokkan --dport 8080 tidak akan mencocokkan paket apa pun. Cara yang andal adalah mencocokkan port yang awalnya dihubungi client, yang disimpan oleh connection tracker kernel:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPArtinya: untuk paket yang masuk melalui eth0 dan termasuk dalam koneksi dengan port tujuan asli 8080, drop semua paket yang tidak dikirim dari 10.0.0.10. Pencocokan --ctdir ORIGINAL membatasi aturan ini pada arah client ke container, sehingga paket balasan tidak ikut terkena aturan secara keliru. Ganti eth0 dengan interface publik Anda; ip route | grep default menampilkan namanya. Uji dengan cara yang sama seperti sebelumnya: curl dari alamat yang diizinkan akan berhasil, sedangkan dari tempat lain koneksi akan mengalami timeout. Jeda tersebut merupakan tanda bahwa aturan DROP bekerja, bukan tanda bahwa tidak ada layanan di balik port tersebut. Perbedaan antara koneksi yang ditolak dan koneksi yang mengalami timeout adalah cara tercepat untuk membedakan port yang difilter dari service yang memang tidak sedang listen.
Aturan yang ditambahkan dengan perintah iptables akan hilang saat reboot. Karena UFW sudah mengelola firewall ini, tempat yang tepat untuk menyimpannya secara permanen adalah /etc/ufw/after.rules. Tambahkan sebuah blok di akhir file:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITKemudian jalankan sudo ufw reload. UFW akan menerapkan kembali file tersebut setiap kali reload dan setiap kali boot. Dengan demikian, penyaringan container berada di tempat yang sama dengan aturan firewall lainnya dan tetap bertahan setelah reboot maupun upgrade Docker.
Mengapa Anda tidak boleh menonaktifkan integrasi iptables Docker
Jawaban lama untuk masalah ini menyarankan pengaturan { "iptables": false } di /etc/docker/daemon.json. Jangan lakukan itu. Aturan firewall Docker memiliki fungsi yang jauh lebih luas daripada hanya memublikasikan port. Aturan masquerade memberikan akses Internet keluar kepada container melalui alamat host. Jika integrasi dinonaktifkan, container tidak dapat mengakses mirror paket atau memanggil API eksternal (application programming interface). Aturan DNAT dan isolasi juga hilang. Dokumentasi Docker memperingatkan bahwa tanpa aturan pengganti, port container dapat diakses oleh host di jaringan lokal. Anda akan mengatasi celah tersebut dengan merusak jaringan container, lalu harus menulis dan memelihara sendiri semua aturan tersebut. Dokumentasi Docker juga menyebut pengaturan ini tidak sesuai bagi sebagian besar pengguna dan menjelaskan bahwa pembuatan aturan setelahnya sangat rumit. Chain DOCKER-USER memang tersedia agar tidak ada yang memerlukan sakelar ini.
Sisi IPv6 dari masalah yang sama
Pertama, periksa tampilan port yang dipublikasikan pada IPv6:
sudo ss -tlnp | grep 8080Sejak Docker Engine 27, Docker mengelola ip6tables secara default. Pada jaringan Docker dengan IPv6 yang diaktifkan, port yang dipublikasikan mendapatkan perlakuan DNAT yang sama pada tabel IPv6. Karena itu, celah bypass yang sama juga ada di sana dan perbaikannya sama: chain DOCKER-USER juga ada di ip6tables, jadi terapkan aturan yang sama dengan sudo ip6tables -I DOCKER-USER ... dan uji dari luar menggunakan curl ke alamat IPv6 publik server Anda, misalnya curl -6 http://[2001:db8:2a::1]:8080/. Hal ini berlaku pada backend iptables default Docker. Pada backend nftables eksperimental, tidak ada chain DOCKER-USER pada kedua stack. Pemfilteran IPv6 dilakukan pada tabel nftables ip6 milik Anda sendiri.
Pada jaringan tanpa IPv6, klien IPv6 ditangani oleh docker-proxy, yaitu proses user-space biasa yang listen pada [::]:8080 dan meneruskan trafik ke container melalui IPv4. Trafik menuju proses pada host memang melewati INPUT, sehingga UFW dapat memfilter jalur tersebut, tetapi hanya jika UFW memang mengelola IPv6. Apakah UFW mengelola IPv6 dan cara lain celah IPv6 dapat terbuka pada VPS dibahas dalam panduan UFW dan IPv6.
Publikasi pada loopback menghindari seluruh persoalan ini: -p 127.0.0.1:8080:80 hanya melakukan bind pada loopback IPv4, sehingga tidak ada listener IPv6. Pada Docker Engine 28.0.0 dan versi lebih baru, tidak ada layanan yang dapat dijangkau dari luar pada kedua stack.
Pola yang dapat diandalkan
- Publikasikan setiap port internal pada
127.0.0.1agar port tersebut tidak pernah terekspos sejak awal. Gunakan Docker Engine 28.0.0 atau yang lebih baru. Rilis yang lebih lama memungkinkan host pada segmen L2 yang sama untuk mengaksesnya. - Serahkan sisi publik kepada satu reverse proxy yang menggunakan port 80 dan 443.
- Pertahankan kebijakan default deny UFW untuk host. Izinkan SSH dan port reverse proxy.
- Filter port container yang benar-benar publik di
DOCKER-USERpada backend iptables default Docker. Gunakan pencocokan berdasarkan port tujuan asli. Simpan aturan tersebut di/etc/ufw/after.rules. - Biarkan integrasi iptables Docker tetap aktif.
Setelah dikonfigurasi satu kali, pola ini menghilangkan ketidakjelasan: ufw status menjelaskan host, sedangkan DOCKER-USER menjelaskan container. Tidak ada port yang dipublikasikan secara tidak sengaja, dan docker run -p berikutnya yang Anda jalankan akan mengekspos tepat layanan yang dimaksud.
FAQ
Mengapa saya dapat mengakses container Docker ketika UFW memblokir port tersebut?
Karena Docker memublikasikan port menggunakan aturan DNAT dalam chain DOCKER pada table nat, yang dicapai dari PREROUTING. Aturan tersebut menulis ulang tujuan paket ke alamat container sebelum proses filtering berlangsung. Paket kemudian melewati jalur FORWARD, sedangkan aturan UFW berada di INPUT, yaitu chain yang tidak pernah dimasuki paket. Firewall tidak pernah memeriksa paket tersebut, sehingga aturan deny tidak berdampak pada port container yang dipublikasikan.
Bagaimana cara membuat UFW memblokir port yang dipublikasikan Docker?
UFW sendiri tidak dapat melakukannya karena aturannya berada di chain yang salah. Hentikan pemublikasian port tersebut dengan memublikasikannya sebagai 127.0.0.1:8080:80 agar hanya host yang dapat mengaksesnya pada Docker Engine 28.0.0 dan versi lebih baru. Alternatifnya, lakukan filtering pada chain DOCKER-USER menggunakan aturan iptables yang mencocokkan port tujuan awal melalui conntrack. Simpan aturan tersebut di /etc/ufw/after.rules agar tetap berlaku setelah reboot dan ufw reload.
Apakah saya harus menetapkan "iptables": false dalam Docker's daemon.json?
Tidak. Pengaturan tersebut menghapus sebagian besar aturan firewall dan NAT Docker, sehingga dampaknya lebih luas daripada sekadar melewati UFW. Container kehilangan akses internet keluar karena aturan masquerade ikut terhapus. Docker juga memperingatkan bahwa tanpa aturan pengganti, port container dapat diakses oleh host pada jaringan lokal. Gunakan publishing melalui loopback dan chain DOCKER-USER. Keduanya mengatasi eksposur tanpa merusak jaringan container.
Apakah Docker juga melewati UFW pada IPv6?
Pada Docker Engine 27 dan versi lebih baru, pengelolaan ip6tables aktif secara default. Karena itu, port yang dipublikasikan pada jaringan Docker yang mendukung IPv6 ditulis ulang sehingga melewati UFW, sama seperti pada IPv4. Pada backend iptables default Docker, port tersebut juga memerlukan aturan DOCKER-USER yang dicerminkan dengan ip6tables. Pada backend nftables eksperimental dari Engine 29, tidak ada chain DOCKER-USER. Karena itu, filtering tersebut harus dilakukan dalam table nftables milik Anda sendiri. Pada jaringan tanpa IPv6, proses docker-proxy mendengarkan pada [::]. Trafik tersebut melewati INPUT, sehingga UFW dapat memfilternya jika UFW mengelola IPv6. Publishing pada 127.0.0.1 menghindari kedua kondisi tersebut karena tidak ada proses yang mendengarkan pada IPv6.