На Хабр 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.

И последнее. Проверять открытые порты изнутри сервера бессмысленно. Единственный честный тест: запрос с другой машины.

Ссылки