Servidores Linux que rodam Docker tipicamente expõem dezenas de portas no host por padrão — a cada ports: em algum docker-compose.yml aparece um novo binding em 0.0.0.0. Muitas dessas exposições são acidentais: bancos, painéis admin, APIs internas. Cada uma amplia a superfície de ataque sem necessidade.
Esse guia mostra como inventariar, decidir o que fechar, e fechar com segurança — sem se trancar fora.
Inventário: o que está exposto?
# TCP listening em 0.0.0.0 ou IPv6 ::
ss -tlnp | awk '$4 ~ /^(0\.0\.0\.0|\[::\]):/'
# UDP idem
ss -ulnp | awk '$4 ~ /^(0\.0\.0\.0|\[::\]):/'
# Mapear cada porta a um container
docker ps --format 'table {{.Names}}\t{{.Ports}}'Para cada porta encontrada, três perguntas:
- Quem precisa acessar? Internet (público), rede privada/VPN, ou só apps internas?
- Tem caminho alternativo? Painel admin já está atrás de um reverse-proxy HTTPS? Então a exposição direta é redundante.
- Qual o custo de fechar? Quebra alguma app? Você perde acesso de gerenciamento?
Apps dentro de containers que se comunicam entre si (postgres:5432, keycloak:8080, etc.) usam DNS interno do Docker — não tocam a porta pública do host. Fechar a exposição externa não quebra essa comunicação interna.
Duas estratégias
A) Bind em loopback no docker-compose
Substituir "5432:5432" por "127.0.0.1:5432:5432" faz o docker-proxy escutar só no lo. A porta deixa de ser pública de fato, e você consegue acessar de scripts rodando no próprio host ou via SSH tunnel.
services:
postgres:
image: postgres:16
ports:
- "127.0.0.1:5432:5432" # antes: "5432:5432"Vantagens: documenta a intenção no compose; container recreate mantém a config. Custo: precisa docker compose up -d (recreate do container, downtime de segundos).
B) Firewall com iptables
Mantém o docker-proxy escutando em 0.0.0.0 (visível em docker ps), mas regras do iptables descartam o tráfego antes dele chegar ao processo:
# Aceitar de uma subnet confiável (ex: VPN)
iptables -A INPUT -p tcp --dport 5432 -s 10.0.0.0/24 -j ACCEPT
# Descartar o resto
iptables -A INPUT -p tcp --dport 5432 -j DROPVantagens: zero downtime; fácil reverter. Custo: docker ps continua mostrando 0.0.0.0:5432, parece exposto pra quem só olha lá.
Em geral: A para serviços críticos (bancos, brokers, identity providers — onde a config errada no compose é o tipo de bug que volta a doer); B para o resto.
Padrão de regras iptables seguro
Ordem importa. O setup mínimo robusto:
# 1. Loopback sempre permitido (processos no host se comunicam via lo)
iptables -A INPUT -i lo -j ACCEPT
# 2. Conexões já estabelecidas — preserva sessões SSH em curso ao aplicar DROPs
iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# 3. Por porta a fechar: ACCEPT da subnet confiável, DROP do resto
for port in 22 5432 8080; do
iptables -A INPUT -p tcp --dport "$port" -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport "$port" -j DROP
doneA regra de RELATED,ESTABLISHED é fundamental. Sem ela, ao adicionar um DROP para a porta SSH que você está usando agora, a sessão atual sobrevive (estado TCP já estabelecido), mas qualquer nova conexão de IP não permitido cai.
Safety: rollback automático antes de aplicar
Antes de mexer em regras de firewall remotas, arme um timer de auto-rollback. Se a regra estiver errada e te trancar fora, em alguns minutos o estado retorna sozinho.
cat > /tmp/iptables-rollback.sh <<'EOF'
#!/bin/bash
sleep 600 # 10 minutos
iptables -D INPUT -p tcp --dport 22 -j DROP 2>/dev/null
iptables -D INPUT -p tcp --dport 22 -s 10.0.0.0/24 -j ACCEPT 2>/dev/null
echo "$(date): regras rollback automaticamente" >> /var/log/iptables-rollback.log
EOF
chmod +x /tmp/iptables-rollback.sh
# Aplique as regras
iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j DROP
# E arme o rollback
nohup /tmp/iptables-rollback.sh > /dev/null 2>&1 &
echo $! > /tmp/iptables-rollback.pidValidou que tudo funciona (nova sessão SSH conecta, apps respondem)? Mate o processo pra cancelar o rollback:
kill $(cat /tmp/iptables-rollback.pid)
rm /tmp/iptables-rollback.pid /tmp/iptables-rollback.shSe você não conseguir confirmar em 10 minutos (ficou sem acesso), as regras são removidas automaticamente e o servidor volta ao estado anterior.
Persistência entre reboots
Por padrão, regras iptables se perdem ao reiniciar. Em Debian/Ubuntu:
apt-get install -y iptables-persistent
iptables-save > /etc/iptables/rules.v4
ip6tables-save > /etc/iptables/rules.v6O pacote habilita o serviço netfilter-persistent, que recarrega /etc/iptables/rules.v4 no boot.
Hardening do sshd em paralelo
Antes de restringir a porta SSH a uma subnet, vale validar que o sshd está endurecido. Cheque o efetivo (drop-ins podem sobrescrever o sshd_config principal):
sshd -T | grep -iE '^(passwordauthentication|permitrootlogin|pubkeyauthentication)'Se passwordauthentication yes aparecer mesmo você tendo configurado no, provavelmente um arquivo em /etc/ssh/sshd_config.d/ está sobrescrevendo (cloud-init costuma fazer isso). Crie um drop-in com prefixo numérico baixo (carrega primeiro, e no sshd o primeiro valor vence):
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
EOF
sshd -t && systemctl reload sshE instale fail2ban pra brute-force lento:
apt-get install -y fail2ban
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = 22
EOF
systemctl enable --now fail2banVerificação
# Ver regras ativas com contadores
iptables -L INPUT -n -v --line-numbers
# De fora da rede confiável: deve dar timeout/refused
nc -zv -w 5 SEU_SERVIDOR 5432
nmap -p 22,5432,8080 SEU_SERVIDOR
# De dentro da rede confiável (VPN): deve conectar
nc -zv -w 5 SEU_SERVIDOR 5432Sem acesso a outra rede pra testar, espere alguns minutos e olhe o contador da regra DROP. A internet faz scan constante em portas comuns — se o pkts da DROP subiu, o bloqueio está ativo.
Pegadinhas comuns
nginx + container recreate = 502
Se o nginx faz reverse-proxy via proxy_pass http://app:8080, ele resolve o nome do upstream uma vez ao carregar a config e cacheia o IP indefinidamente (sem resolver directive). Quando você recreia o container app, o IP muda — mas o nginx continua tentando o IP antigo e responde 502.
Soluções:
# Mais simples: restart do container nginx (reseta o cache de DNS)
docker restart nginx-proxy
# Ou no nginx.conf: força re-resolução periódica
resolver 127.0.0.11 valid=10s; # DNS interno do Docker
set $upstream "http://app:8080";
proxy_pass $upstream; # usar variável força lookup por requestnginx -s reload não é suficiente nesse caso em todas as versões — o restart completo do container é o caminho confiável.
iptables INPUT não bloqueia tráfego container-a-container
Regras na chain INPUT só afetam pacotes destinados ao host. Tráfego entre containers na mesma docker network passa pela FORWARD (e pelas chains DOCKER-USER / DOCKER-FORWARD). Se você quiser restringir só o acesso público a serviços expostos, regras em INPUT resolvem.
Fechar a porta SSH antes de validar
Se o único acesso de gerenciamento é SSH, valide a allowlist da porta 22 antes de fechar outras portas. Trancar a 22 sem ter conferido o source IP que sua VPN apresenta no host é receita pra precisar do console do provedor pra recuperar.
Dica: rode who ou last no servidor enquanto faz uma nova conexão SSH via VPN. O last_address mostrado é o IP que a regra precisa permitir.
IPs de container são dinâmicos
Se você whitelista 172.20.0.12 pensando que é o IP do container da VPN, lembre que esse IP muda se você recriar o container. Prefira whitelistar a subnet inteira da docker network (ex.: 172.20.0.0/16) — robusto a recreate.
docker-proxy continua escutando após DROP no iptables
Se você só aplicou regras iptables, o ss -tlnp ainda mostra 0.0.0.0:5432 (o docker-proxy segue lá). Isso é esperado — o bloqueio acontece no kernel antes do tráfego chegar ao processo. Pra realmente remover o binding, é a estratégia A (bind em 127.0.0.1).
Fluxo resumido
ss -tlnp+docker ps→ inventário- Pra cada porta: pública / VPN-only / loopback-only / fechar tudo
- Loopback-only → editar
ports:no compose pra127.0.0.1:HOST:CONTAINER - VPN-only → iptables com ACCEPT da subnet + DROP do resto
- Sempre arme rollback antes de aplicar regras remotas
- Validar (nova sessão SSH, apps respondem)
- Cancelar rollback
- Persistir com
iptables-save > /etc/iptables/rules.v4 - Lembrar do
docker restart nginx-proxyse algum upstream foi recreado