На Хабр Q&A этот вопрос всплывает регулярно, формулировки почти одинаковые: “установил Ubuntu, поднял Docker, в контейнере веб-приложение, а брандмауэр не работает”. Человек делает ufw deny, видит Status: active, а сервис снаружи всё равно отвечает.
Я администрирую чужие серверы, и это одна из тех вещей, которые нахожу у клиентов чаще всего: файрвол настроен аккуратно, а база или админка контейнера торчат в интернет. Владелец сервера при этом искренне уверен, что закрыто, потому что ufw status показывает ровно то, что он ожидает увидеть.
Обычно в ответах на такой вопрос пишут: добавь правило в цепочку DOCKER-USER. Я так и сделал на стенде. Порт остался открытым, и следующие минут двадцать я потратил на проверку собственных рук: не опечатался ли в номере порта, туда ли вставил правило, не кэширует ли что-то curl. Правило было верным. Неверным был совет.
Дальше разбор: почему UFW тут вообще ни при чём, почему популярный совет не работает, и что написано об этом в документации Docker, которую мало кто открывает.
Все команды ниже выполнены на живой машине, вывод настоящий. Проверки снаружи идут с другого хоста, иначе смысла нет: с самого сервера порт будет отвечать всегда.
Стенд
Debian 13 (trixie), ядро 6.12, Docker 29.6.2, ufw 0.36.2.
$ docker --version Docker version 29.6.2, build dfc4efb $ sudo ufw status verbose Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), deny (routed) New profiles: skip To Action From -- ------ ---- 22/tcp (OpenSSH) ALLOW IN Anywhere 80/tcp ALLOW IN Anywhere 443/tcp ALLOW IN Anywhere
Политика по умолчанию: входящие запрещены, разрешены только SSH, HTTP и HTTPS. Это боевая машина, правил там больше сорока, в выводе я оставил только те, что имеют отношение к делу.
Ещё одна деталь, которая пригодится дальше:
$ sudo iptables --version iptables v1.8.11 (nf_tables) $ update-alternatives --query iptables | grep '^Value' Value: /usr/sbin/iptables-nft
Команда называется iptables, но под ней работает nftables: в Debian 13 и свежих Ubuntu это давно дефолт. На поведение, которое разбираем ниже, бэкенд не влияет, а вот вывод команд на системе с iptables-legacy будет выглядеть немного иначе.
Запрещаем порт явно
Возьмём свободный порт 18080 и запретим его не политикой по умолчанию, а отдельным правилом, чтобы к концу статьи не осталось сомнений.
$ sudo ufw deny 18080 Rule added Rule added (v6) $ sudo ufw status | grep 18080 18080 DENY Anywhere 18080 (v6) DENY Anywhere (v6)
Проверяем снаружи, с другой машины:
$ curl -m 6 -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' http://SERVER:18080/ code=000 time=6.007749 curl: (28) Connection timed out after 6007 milliseconds
Таймаут. Порт закрыт, файрвол работает.
Поднимаем контейнер на этом же порту
$ docker run -d --name ufwtest -p 18080:80 nginx:alpine 66406adbcd2ea57d30d8d652e83f49eaf661b01fc30c2943bf444685b72c49ba $ docker ps --filter name=ufwtest NAMES STATUS PORTS ufwtest Up 1 second 0.0.0.0:18080->80/tcp, [::]:18080->80/tcp
Спрашиваем UFW, что он думает о порте 18080:
$ sudo ufw status | grep 18080 18080 DENY Anywhere 18080 (v6) DENY Anywhere (v6)
По-прежнему DENY. А теперь снаружи:
$ curl -m 8 -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' http://SERVER:18080/ code=200 time=0.137114 $ curl -m 8 -sS -I http://SERVER:18080/ HTTP/1.1 200 OK Server: nginx/1.29.6 Date: Sun, 23 Aug 2026 13:52:09 GMT Content-Type: text/html Content-Length: 896
HTTP 200 за 0.13 секунды. Файрвол уверен, что порт закрыт, nginx отвечает всему интернету.
Теперь представьте, что вместо nginx:alpine там -p 5432:5432 с базой, у которой пароль остался из примера в туториале.
Куда делся пакет
Смотрим правила, которые Docker создал сам.
Таблица nat:
$ sudo iptables -t nat -L DOCKER -n | grep 18080 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:18080 to:172.17.0.17:80
Вот первый ключ. Пакет, пришедший на порт 18080, ещё в PREROUTING получает новый адрес назначения: 172.17.0.17, порт 80. То есть адрес контейнера.
Дальше маршрутизация видит, что адрес назначения не локальный, и отправляет пакет не в INPUT, а в FORWARD. UFW же свои правила для входящих пишет именно в INPUT. Он честно фильтрует то, что приходит хосту, но трафик к контейнеру идёт мимо него.
Смотрим порядок в FORWARD:
$ sudo iptables -L FORWARD -n --line-numbers Chain FORWARD (policy DROP) num target prot opt source destination 1 DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0 2 DOCKER-FORWARD all -- 0.0.0.0/0 0.0.0.0/0 3 ufw-before-logging-forward all -- 0.0.0.0/0 0.0.0.0/0 4 ufw-before-forward all -- 0.0.0.0/0 0.0.0.0/0 5 ufw-after-forward all -- 0.0.0.0/0 0.0.0.0/0
Второй ключ. Цепочки Docker стоят под номерами 1 и 2, цепочки UFW начинаются с третьего. Пакет принимается в DOCKER-FORWARD и до правил UFW просто не доходит.
Это не баг и не злой умысел. Документация Docker говорит об этом прямо: Docker и ufw “incompatible with each other”, потому что трафик к контейнеру отводится раньше, чем дойдёт до настроек ufw.
Есть и третий участник:
$ sudo ss -ltnp | grep 18080 LISTEN 0 4096 0.0.0.0:18080 0.0.0.0:* users:(("docker-proxy",pid=3300758,fd=8)) $ docker info | grep -i userland EnableUserlandProxy: true
На хосте висит процесс docker-proxy, слушающий 0.0.0.0. Это userland-прокси, включённый по умолчанию, второй маршрут для того же трафика.
Популярный совет, который не работает
Самый частый ответ в интернете: добавьте правило в DOCKER-USER, эта цепочка обрабатывается раньше остальных правил Docker. Цепочка действительно предназначена ровно для пользовательских правил. Пробуем.
$ sudo iptables -I DOCKER-USER 1 -p tcp --dport 18080 -j DROP $ sudo iptables -L DOCKER-USER -n --line-numbers Chain DOCKER-USER (1 references) num target prot opt source destination 1 DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:18080
Правило на месте, стоит первым. Проверяем снаружи:
$ curl -m 8 -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' http://SERVER:18080/ code=200 time=0.128561
Открыто. Правило не сработало.
Я потратил на это время, пока не открыл документацию Docker и не нашёл объяснение прямым текстом:
When packets arrive to the DOCKER-USER chain, they have already passed through a Destination Network Address Translation (DNAT) filter. That means that the iptables flags you use can only match internal IP addresses and ports of containers.
Пакеты приходят в DOCKER-USER уже после DNAT. К этому моменту порт назначения не 18080, а 80, потому что трансляция произошла раньше. Правило с --dport 18080 не совпадает ни с одним пакетом.
Получается, совет, который повторяют десятки статей и ответов, противоречит документации самого Docker.
Что работает
В той же документации есть и решение: сопоставлять исходный адрес и порт, до трансляции, через модуль conntrack.
$ sudo iptables -D DOCKER-USER -p tcp --dport 18080 -j DROP $ sudo iptables -I DOCKER-USER 1 -p tcp -m conntrack --ctorigdstport 18080 -j DROP $ sudo iptables -L DOCKER-USER -n --line-numbers Chain DOCKER-USER (1 references) num target prot opt source destination 1 DROP tcp -- 0.0.0.0/0 0.0.0.0/0 ctorigdstport 18080
Проверяем:
$ curl -m 8 -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' http://SERVER:18080/ code=000 time=8.012896 curl: (28) Connection timed out after 8012 milliseconds
Закрыто. Разница между двумя правилами ровно в том, по какому полю идёт сопоставление: по порту после трансляции или по исходному.
Способ, который проще и надёжнее
Правила в DOCKER-USER живут отдельно от UFW. Через полгода вы откроете ufw status, увидите знакомую картину и забудете, что часть логики лежит в другом месте.
Поэтому в большинстве случаев лучше вообще не публиковать порт наружу. Docker умеет привязывать публикацию к конкретному адресу:
$ docker run -d --name ufwtest -p 127.0.0.1:18080:80 nginx:alpine $ docker ps --filter name=ufwtest NAMES PORTS ufwtest 127.0.0.1:18080->80/tcp $ sudo ss -ltnp | grep 18080 LISTEN 0 4096 127.0.0.1:18080 0.0.0.0:* users:(("docker-proxy",pid=3301355,fd=8))
Обратите внимание на вывод ss: теперь docker-proxy слушает не 0.0.0.0, а только localhost.
Изнутри сервера сервис доступен:
$ curl -s -o /dev/null -w 'code=%{http_code}\n' http://127.0.0.1:18080/ code=200
Снаружи нет:
$ curl -m 8 -sS -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' http://SERVER:18080/ code=000 time=8.006704
Причём правил в файрволе для этого не понадобилось вообще. Порт просто не открыт на внешнем интерфейсе.
Если наружу сервис всё-таки нужен, перед ним ставится reverse proxy на 80 и 443, а эти порты уже нормально управляются через UFW, потому что nginx на хосте принимает трафик в INPUT.
Другие варианты и их цена
Отключить управление iptables у демона, добавив в /etc/docker/daemon.json:
{ "iptables": false }
Docker перестанет трогать правила совсем. Звучит заманчиво, но тогда сеть контейнеров придётся настраивать вручную, включая NAT для исходящего трафика. Для одиночного сервера это лишняя работа с хорошим шансом сломать связность.
Использовать готовое решение ufw-docker. Проект правит /etc/ufw/after.rules и вставляет в DOCKER-USER делегирование в цепочку UFW:
-A DOCKER-USER -j ufw-user-forward -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN -A DOCKER-USER -m conntrack --ctstate INVALID -j DROP -A DOCKER-USER -j RETURN -s 10.0.0.0/8
Логика та же: DOCKER-USER плюс conntrack. Удобно, когда контейнеров много и хочется управлять доступом привычными командами ufw. Минус в том, что это ещё одна зависимость, а сами авторы предупреждают, что после перезапуска ufw правила иногда не применяются, и советуют перезагружать сервер.
Docker 28 это не починил
Частое возражение: в свежих версиях всё исправили. Не совсем.
В Docker Engine 28 действительно усилили сетевые настройки по умолчанию: теперь демон дропает входящий трафик к портам контейнеров, которые не были опубликованы. Это закрывает доступ к внутренним адресам вроде 172.17.0.x из локальной сети.
Но опубликованные порты работают как раньше, и в анонсе это сказано прямо: “published ports will continue to function as usual”.
Всё, что описано выше, воспроизведено на Docker 29.6.2. Проблема не в старой версии.
Это не только про UFW
Механика тут общая, поэтому и последствия шире одного файрвола.
С firewalld та же история, только устроена иначе: Docker создаёт для себя зону docker с целью ACCEPT и заносит туда интерфейсы своих мостовых сетей. Правила, которые вы написали для публичной зоны, к трафику контейнеров отношения иметь не будут.
С docker compose ловушка ровно та же, просто менее заметная. Секция ports: делает то же самое, что флаг -p:
services: db: image: postgres:16 ports: - "5432:5432"
Такая запись открывает базу на всех интерфейсах. Достаточно дописать адрес, и порт останется внутри машины:
ports: - "127.0.0.1:5432:5432"
Разница в 10 символов, а с точки зрения доступности снаружи это два разных сервера.
Отдельно стоит держать в голове, что reverse proxy тоже часто живёт в контейнере. Если nginx запущен как контейнер с -p 80:80, его собственный порт публикуется по тем же правилам и через UFW не управляется. Разница только в том, что 80 и 443 вы открыть и хотели.
Проверьте у себя
Список портов, опубликованных наружу:
docker ps --format '{{.Names}}|{{.Ports}}'
Смотреть надо на левую часть отображения. 0.0.0.0:5432->5432/tcp означает, что порт доступен из интернета независимо от того, что показывает ufw status. Запись 127.0.0.1:5432->5432/tcp безопасна. Фильтровать вывод по 0.0.0.0 я не советую: контейнер может быть привязан к конкретному внешнему адресу сервера, и тогда он в такой фильтр не попадёт, оставаясь при этом полностью доступным.
Что слушает хост на внешнем интерфейсе:
sudo ss -ltnp | grep -v '127.0.0.1'
И главная проверка, которую нельзя заменить ничем: сканирование снаружи, с другой машины.
nmap -Pn -p- ВАШ_СЕРВЕР
Локальные команды показывают конфигурацию, а внешняя проверка показывает реальность.
Выводы
UFW не ломается и не игнорируется. Он фильтрует INPUT, а трафик к контейнерам после DNAT идёт через FORWARD, где правила Docker стоят раньше.
Правило в DOCKER-USER с портом публикации бесполезно: к этому моменту DNAT уже переписал порт. Нужен -m conntrack --ctorigdstport, и это написано в документации Docker.
Самый простой способ не иметь проблемы вообще: публиковать на 127.0.0.1 и выпускать наружу только reverse proxy.
И последнее. Проверять открытые порты изнутри сервера бессмысленно. Единственный честный тест: запрос с другой машины.
Ссылки
Packet filtering and firewalls — документация Docker, где сказано про несовместимость с ufw
Docker with iptables — там же про DNAT перед цепочкой DOCKER-USER и про conntrack
Docker Engine v28: Hardening Container Networking by Default — что именно изменилось в 28 и почему это не про опубликованные порты
chaifeng/ufw-docker — готовое решение, если контейнеров много
