Docker and UFW: Firewall Configuration Guide

Table of Contents

Version 1.6.9 | Updated: August 08, 2026

Introduction

Problem Description

When using Docker on Linux systems with UFW (Uncomplicated Firewall), a critical security issue arises: UFW rules do not apply to ports published by Docker. This means that even if you configure UFW to block all incoming traffic, Docker container ports remain open to the entire internet.

Why This Matters

Many administrators rely on UFW as the primary server protection mechanism. Without understanding Docker’s interaction with iptables, they may inadvertently leave critical services (databases, admin panels, internal APIs) accessible from outside.

Typical Vulnerability Scenario

  1. Administrator configures UFW:

ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
  1. Starts a container with a database:

docker run -d -p 5432:5432 postgres
  1. Expectation: PostgreSQL is only accessible locally

  2. Reality: PostgreSQL is accessible from the internet on port 5432

Technical Explanation

iptables Architecture

Linux uses Netfilter/iptables for network traffic filtering. Packets pass through chains in a specific order:

              INCOMING PACKET
                    |
                    v
              PREROUTING
          (NAT, destination change)
                    |
         +----------+----------+
         |                     |
    For host?            For forwarding?
         v                     v
       INPUT                FORWARD
  (UFW works               (Docker rules
    here)                    here)
         |                     |
         v                     v
    Local                 POSTROUTING
   processes           (NAT for containers)

How Docker Manipulates iptables

When starting a container with the -p option, Docker automatically creates iptables rules.

1. DOCKER Chain in nat Table

# View NAT rules
iptables -t nat -L DOCKER -n -v

Example output:

Chain DOCKER (2 references)
 pkts bytes target   prot opt in      out   source      destination
    0     0 RETURN   all  --  docker0 *     0.0.0.0/0   0.0.0.0/0
   15   780 DNAT     tcp  --  !docker0 *    0.0.0.0/0   0.0.0.0/0
                                            tcp dpt:5432 to:172.17.0.2:5432

2. DOCKER Chain in filter Table

# View filter rules
iptables -L DOCKER -n -v

Example output:

Chain DOCKER (1 references)
 pkts bytes target   prot opt in      out     source      destination
   12   624 ACCEPT   tcp  --  !docker0 docker0 0.0.0.0/0  172.17.0.2
                                                          tcp dpt:5432

3. Rule Processing Order

Docker inserts a jump rule into the FORWARD chain:

iptables -L FORWARD -n -v
Chain FORWARD (policy DROP)
 pkts bytes target                   prot opt in  out     source      dest
  245 15680 DOCKER-USER              all  --  *   *       0.0.0.0/0   0.0.0.0/0
  245 15680 DOCKER-ISOLATION-STAGE-1 all  --  *   *       0.0.0.0/0   0.0.0.0/0
  125  7800 ACCEPT                   all  --  *   docker0 0.0.0.0/0   0.0.0.0/0
                                                          ctstate RELATED,ESTABLISHED
   12   624 DOCKER                   all  --  *   docker0 0.0.0.0/0   0.0.0.0/0

Why UFW Doesn’t Work

UFW manages ufw-before-input, ufw-after-input and other chains in the INPUT chain. But traffic to Docker containers goes through the FORWARD chain, which UFW does not control.

External traffic -> PREROUTING (DNAT) -> FORWARD (Docker ACCEPT) -> Container
                                            ^
                                    UFW doesn't see this traffic!

Solution Methods

Method 1: Disabling iptables in Docker

Description

Completely prevent Docker from managing iptables. Docker will not create any firewall rules.

Configuration

Create or edit the file /etc/docker/daemon.json:

{
  "iptables": false
}

Restart Docker:

sudo systemctl restart docker

Verification

# Before change - many Docker rules
sudo iptables -L -n | grep -i docker

# After change - no Docker rules
sudo iptables -L -n | grep -i docker

Consequences

What will stop working:

  1. Automatic NAT for containers - containers won’t be able to access the internet

  2. Port publishing (-p flag) - won’t work

  3. Inter-container communication via bridge networks

What needs manual configuration:

# Enable IP forwarding
echo 1 > /proc/sys/net/ipv4/ip_forward

# Or permanently in /etc/sysctl.conf
net.ipv4.ip_forward = 1

# NAT for container internet access
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 -o eth0 -j MASQUERADE

# Allow forwarding for Docker
iptables -A FORWARD -i docker0 -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o docker0 -m state \
    --state RELATED,ESTABLISHED -j ACCEPT

# Manual port publishing
iptables -t nat -A PREROUTING -p tcp --dport 80 \
    -j DNAT --to-destination 172.17.0.2:80
iptables -A FORWARD -p tcp -d 172.17.0.2 --dport 80 -j ACCEPT

When to Use

  • When full network control is needed

  • In specific network configurations

  • When using external network management tools

  • For most production environments

  • Without deep iptables knowledge

  • When using many containers with different networks

Method 2: Binding Ports to localhost

Description

Instead of publishing ports on all interfaces (0.0.0.0), bind them only to localhost (127.0.0.1). Organize external access through a reverse proxy.

Docker CLI

# WRONG - accessible externally
docker run -d -p 5432:5432 postgres

# CORRECT - local only
docker run -d -p 127.0.0.1:5432:5432 postgres

Docker Compose

version: '3.8'

services:
  # Database - local only
  postgres:
    image: postgres:15
    ports:
      - "127.0.0.1:5432:5432"
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data

  # Redis - local only
  redis:
    image: redis:7
    ports:
      - "127.0.0.1:6379:6379"

  # Application - local only
  app:
    image: myapp:latest
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      - postgres
      - redis

  # Nginx - the only public port
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"      # Public HTTP
      - "443:443"    # Public HTTPS
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app

volumes:
  postgres_data:

Nginx Configuration Example

events {
    worker_connections 1024;
}

http {
    # Upstream for application
    upstream app_backend {
        server app:3000;
    }

    # HTTP -> HTTPS redirect
    server {
        listen 80;
        server_name example.com;
        return 301 https://$server_name$request_uri;
    }

    # HTTPS server
    server {
        listen 443 ssl http2;
        server_name example.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;

        # Main application
        location / {
            proxy_pass http://app_backend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_cache_bypass $http_upgrade;
        }
    }
}

Verifying Port Bindings

# Check which addresses ports are listening on
sudo netstat -tlnp | grep docker
# or
sudo ss -tlnp | grep docker

# Expected output for localhost binding:
# tcp LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy"...))

# Check external accessibility (should be refused)
# From another machine:
nc -zv your-server-ip 5432
# Connection refused - correct!

# Check local accessibility (should work)
nc -zv 127.0.0.1 5432
# Connection succeeded - correct!

Advantages

  • Simple implementation

  • No Docker modifications required

  • Single entry point (reverse proxy)

  • Centralized SSL/TLS capability

  • All request logging in one place

Disadvantages

  • Additional layer (proxy)

  • Small proxying delay

  • WebSocket configuration complexity

Method 3: DOCKER-USER Chain

Description

Docker specifically preserves the DOCKER-USER chain for user rules. Rules in this chain are processed before Docker’s automatic rules, but after connection establishment.

Basic Setup

# View current DOCKER-USER rules
sudo iptables -L DOCKER-USER -n -v --line-numbers

# By default only:
# 1  RETURN  all  --  *  *  0.0.0.0/0  0.0.0.0/0

“Deny All, Allow Selectively” Strategy

# IMPORTANT: Identify external interface
# Usually eth0, ens3, enp0s3, etc.
EXTERNAL_IF="eth0"

# 1. Allow established/related connections (important for responses)
sudo iptables -I DOCKER-USER -i $EXTERNAL_IF -m conntrack \
    --ctstate ESTABLISHED,RELATED -j ACCEPT

# 2. Allow specific ports
# HTTP
sudo iptables -I DOCKER-USER -i $EXTERNAL_IF -p tcp --dport 80 -j ACCEPT

# HTTPS
sudo iptables -I DOCKER-USER -i $EXTERNAL_IF -p tcp --dport 443 -j ACCEPT

# 3. Allow from specific IPs (e.g., office)
sudo iptables -I DOCKER-USER -i $EXTERNAL_IF -s 203.0.113.0/24 -j ACCEPT

# 4. Block everything else externally to Docker
sudo iptables -I DOCKER-USER -i $EXTERNAL_IF -j DROP

Correct Rule Order

# Clear and reconfigure with correct order
sudo iptables -F DOCKER-USER

# Add rules in processing order (top to bottom):

# 1. Allow established (always first)
sudo iptables -A DOCKER-USER -m conntrack \
    --ctstate ESTABLISHED,RELATED -j RETURN

# 2. Allow local traffic
sudo iptables -A DOCKER-USER -i lo -j RETURN

# 3. Allow internal network
sudo iptables -A DOCKER-USER -s 10.0.0.0/8 -j RETURN
sudo iptables -A DOCKER-USER -s 172.16.0.0/12 -j RETURN
sudo iptables -A DOCKER-USER -s 192.168.0.0/16 -j RETURN

# 4. Allow specific public ports
sudo iptables -A DOCKER-USER -p tcp --dport 80 -j RETURN
sudo iptables -A DOCKER-USER -p tcp --dport 443 -j RETURN

# 5. Allow access from trusted IPs to any port
sudo iptables -A DOCKER-USER -s 203.0.113.50 -j RETURN

# 6. Allow SSH to specific container only from specific IP
sudo iptables -A DOCKER-USER -s 203.0.113.100 -p tcp --dport 2222 -j RETURN

# 7. Log blocked attempts (optional)
sudo iptables -A DOCKER-USER -j LOG \
    --log-prefix "DOCKER-USER-DROPPED: " --log-level 4

# 8. Block everything else
sudo iptables -A DOCKER-USER -j DROP

Scenario Examples

Scenario 1: Web Server with Database
# Only 80 and 443 public, DB local only
sudo iptables -F DOCKER-USER
sudo iptables -A DOCKER-USER -m conntrack \
    --ctstate ESTABLISHED,RELATED -j RETURN
sudo iptables -A DOCKER-USER -i lo -j RETURN
sudo iptables -A DOCKER-USER -s 172.16.0.0/12 -j RETURN
sudo iptables -A DOCKER-USER -p tcp --dport 80 -j RETURN
sudo iptables -A DOCKER-USER -p tcp --dport 443 -j RETURN
sudo iptables -A DOCKER-USER -i eth0 -j DROP
Scenario 2: Allow Only from VPN
# Docker access only from VPN network 10.8.0.0/24
sudo iptables -F DOCKER-USER
sudo iptables -A DOCKER-USER -m conntrack \
    --ctstate ESTABLISHED,RELATED -j RETURN
sudo iptables -A DOCKER-USER -s 10.8.0.0/24 -j RETURN
sudo iptables -A DOCKER-USER -i eth0 -j DROP
Scenario 3: Rate Limiting
# Limit new connections
sudo iptables -I DOCKER-USER -p tcp --dport 80 -m conntrack --ctstate NEW \
    -m recent --set --name HTTP
sudo iptables -I DOCKER-USER -p tcp --dport 80 -m conntrack --ctstate NEW \
    -m recent --update --seconds 60 --hitcount 100 --name HTTP -j DROP

Saving Rules

Debian/Ubuntu
# Install iptables-persistent
sudo apt install iptables-persistent

# Save current rules
sudo netfilter-persistent save

# Rules are saved in:
# /etc/iptables/rules.v4
# /etc/iptables/rules.v6
CentOS/RHEL
# Save rules
sudo iptables-save > /etc/sysconfig/iptables

# Enable restoration on boot
sudo systemctl enable iptables

Verifying Rules

# View rules with line numbers
sudo iptables -L DOCKER-USER -n -v --line-numbers

# Example output:
# Chain DOCKER-USER (1 references)
# num
pkts bytes target  prot opt in   out  source      destination
# 1
156 12480 RETURN  all  --  *    *    0.0.0.0/0   0.0.0.0/0
#
ctstate RELATED,ESTABLISHED
# 2
0     0 RETURN  all  --  lo   *    0.0.0.0/0   0.0.0.0/0
# 3
23  1380 RETURN  tcp  --  *    *    0.0.0.0/0   0.0.0.0/0   tcp dpt:80
# 4
45  2700 RETURN  tcp  --  *    *    0.0.0.0/0   0.0.0.0/0   tcp dpt:443
# 5
12   624 DROP    all  --  eth0 *    0.0.0.0/0   0.0.0.0/0

# Testing (from another server)
nmap -p 80,443,5432,6379 your-server-ip
# 80/tcp
open
# 443/tcp  open
# 5432/tcp filtered  <-- Blocked!
# 6379/tcp filtered  <-- Blocked!

Method 4: ufw-docker Utility

Description

The ufw-docker utility is a script that modifies UFW to work correctly with Docker. It adds rules to the DOCKER-USER chain through UFW-like syntax.

Installation

# Clone repository
git clone https://github.com/chaifeng/ufw-docker.git
cd ufw-docker

# Install script
sudo cp ufw-docker /usr/local/bin/
sudo chmod +x /usr/local/bin/ufw-docker

# Install UFW rules for Docker
sudo ufw-docker install

# Restart UFW
sudo systemctl restart ufw

What install Does

The ufw-docker install command adds to /etc/ufw/after.rules:

# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN

-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp \
    --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp \
    --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp \
    --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12

-A DOCKER-USER -j RETURN

-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 \
    -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP

COMMIT
# END UFW AND DOCKER

Usage

View Status
sudo ufw-docker status
Allow Access to Container
# Syntax: ufw-docker allow <container_name> [port[/protocol]]

# Allow all traffic to nginx container
sudo ufw-docker allow nginx

# Allow only port 80/tcp to nginx container
sudo ufw-docker allow nginx 80/tcp

# Allow port 443
sudo ufw-docker allow nginx 443/tcp
Deny Access
# Remove permission
sudo ufw-docker delete allow nginx

# Remove permission for specific port
sudo ufw-docker delete allow nginx 80/tcp
Allow from Specific IP
# Allow access to container only from specific IP
sudo ufw-docker allow nginx 80/tcp 203.0.113.50

# Allow from subnet
sudo ufw-docker allow nginx 80/tcp 203.0.113.0/24

Complete Example

# Initial setup
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable

# Install ufw-docker
sudo ufw-docker install
sudo systemctl restart ufw

# Start containers
docker run -d --name web -p 80:80 -p 443:443 nginx
docker run -d --name db -p 5432:5432 postgres
docker run -d --name redis -p 6379:6379 redis

# By default all Docker ports are blocked!
# Check: nmap -p 80,443,5432,6379 server-ip
# All ports filtered

# Open only web ports
sudo ufw-docker allow web 80/tcp
sudo ufw-docker allow web 443/tcp

# Check: nmap -p 80,443,5432,6379 server-ip
# 80/tcp
open
# 443/tcp  open
# 5432/tcp filtered
# 6379/tcp filtered

# Allow Redis access only from office
sudo ufw-docker allow redis 6379/tcp 203.0.113.0/24

# View all Docker rules
sudo ufw-docker status

Advantages

  • Simple syntax similar to UFW

  • Integration with existing UFW configuration

  • Automatic container IP detection

  • Rules persist across reboots

Disadvantages

  • Third-party script (not official)

  • Requires updates when container IP changes

  • May conflict with custom iptables rules

Method 5: Host Network Mode

Description

When using network_mode: host, the container uses the host’s network stack directly, without creating a separate network namespace. Docker doesn’t create NAT rules, and UFW works normally.

Docker CLI

docker run -d --network host nginx

Docker Compose

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    network_mode: host
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

UFW Configuration

# Now UFW works as usual
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Verify
sudo ufw status verbose

Example: Web Application

version: '3.8'

services:
  app:
    image: node:18-alpine
    network_mode: host
    working_dir: /app
    volumes:
      - ./:/app
    command: node server.js
    environment:
      - PORT=3000
      - DB_HOST=localhost
      - DB_PORT=5432

  postgres:
    image: postgres:15
    network_mode: host
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
# UFW rules
sudo ufw allow 3000/tcp  # Application
# PostgreSQL (5432) not opened - localhost only

Important Features

Inter-service Access
# With host network, containers access each other via localhost
environment:
  - REDIS_URL=redis://localhost:6379
  - DATABASE_URL=postgres://localhost:5432/db
Port Conflicts
# Check occupied ports before starting
sudo ss -tlnp | grep :80
sudo ss -tlnp | grep :5432

# If port is occupied - container won't start
# docker logs <container> will show bind error

When to Use

  • Maximum network performance (no NAT overhead)

  • Services needing access to host network interfaces

  • Simple UFW integration

  • Containers needing access to host localhost services

When NOT to Use

  • If container isolation is needed

  • If services use the same ports

  • In multi-tenant environments

  • When configuration portability matters

Method Comparison Table

Criterion iptables=false localhost bind DOCKER-USER ufw-docker host network

Setup complexity

High

Low

Medium

Low

Low

Requires iptables knowledge

Yes

No

Yes

No

No

Container isolation

Yes

Yes

Yes

Yes

No

UFW compatibility

Yes

Yes

Partial

Yes

Yes

Production ready

No

Yes

Yes

Yes

Caution

Needs reverse proxy

No

Yes

No

No

No

Survives reboot

Manual

Yes

Manual

Yes

Yes

Scenario Recommendations

Scenario 1: Simple Web Server

Conditions: Single server, web app + DB, minimal administration

Recommendation: Bind to localhost + Nginx

version: '3.8'

services:
  app:
    image: myapp:latest
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:15
    # No ports published - access only from Docker network
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - app

volumes:
  db_data:
# UFW for host
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Scenario 2: Development Server

Conditions: Access to various ports needed, but only from office network

Recommendation: DOCKER-USER with whitelist

#!/bin/bash
# /usr/local/bin/setup-docker-firewall.sh

OFFICE_NET="203.0.113.0/24"
VPN_NET="10.8.0.0/24"

iptables -F DOCKER-USER

# Established connections
iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN

# Localhost
iptables -A DOCKER-USER -i lo -j RETURN

# Docker internal
iptables -A DOCKER-USER -s 172.16.0.0/12 -j RETURN

# Office network - full access
iptables -A DOCKER-USER -s $OFFICE_NET -j RETURN

# VPN network - full access
iptables -A DOCKER-USER -s $VPN_NET -j RETURN

# Block everything else
iptables -A DOCKER-USER -j DROP

echo "Docker firewall configured"

Scenario 3: Microservices

Conditions: Many containers, complex network topology

Recommendation: Docker networks + minimal public ports

version: '3.8'

services:
  # Public layer
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend

  # API Gateway
  api-gateway:
    image: kong:latest
    networks:
      - frontend
      - backend
    # Ports NOT published

  # Microservices
  users-service:
    image: users-service:latest
    networks:
      - backend
      - database

  orders-service:
    image: orders-service:latest
    networks:
      - backend
      - database
      - queue

  # Infrastructure
  postgres:
    image: postgres:15
    networks:
      - database
    # Ports NOT published

  redis:
    image: redis:7
    networks:
      - backend
    # Ports NOT published

  rabbitmq:
    image: rabbitmq:3-management
    networks:
      - queue
    ports:
      - "127.0.0.1:15672:15672"  # Management UI local only

networks:
  frontend:
  backend:
  database:
  queue:

Diagnostics and Debugging

When the hardening bites back

Both of the following are our own rules working as intended. They are recorded here because each presents as something else entirely, and both cost real time before being recognised.

Containers cannot reach the network — pulls and builds fail

Symptom: docker pull times out or fails to resolve, docker build dies with a DNS error, and a container cannot even ping its own gateway (172.17.0.1), while the host itself has perfectly good connectivity.

Cause: Docker routes container traffic through the FORWARD chain. A host hardened with -P FORWARD DROP and no matching ACCEPT — or with a broad deny in DOCKER-USER — cuts every container off from the outside. This is exactly what the hardening in this guide does, and on a monitoring host it is usually correct: nothing there needs to originate outbound traffic from a container.

iptables -S | grep -E '^-P FORWARD'      # DROP means containers are cut off
iptables -S DOCKER-USER                  # a deny here does the same
docker run --rm debian:12 ping -c1 172.17.0.1   # fails on such a host

What to do about it depends on why you are pulling:

  • Installing or upgrading the stack — pull with the host’s own networking: docker build --network=host works, and misc/bootstrap.sh already builds that way for this reason. For pulls, run them where the daemon can reach out, or use the offline bundle, which needs no network at all.

  • You genuinely need container egress — add a targeted ACCEPT rather than turning the policy back to ACCEPT wholesale:

    iptables -I DOCKER-USER -i docker0 -j ACCEPT      # all containers, outbound

    Decide that deliberately: it is the rule the hardening exists to avoid.

SSH says “No route to host” while the host answers ping and HTTPS

Symptom: ssh fails immediately with No route to host, but ICMP and 443 to the same address work.

Cause: a connection-limit rule rejects the seventh concurrent SSH connection from one source address, with icmp-port-unreachable — which is precisely what “No route to host” means to a client. A firewall drop would time out instead, so the fast, definite failure is the clue.

iptables -S INPUT | grep connlimit
ss -tn state established '( sport = :22 )' | grep -c <your-ip>

It is easy to hit without noticing: one SSH connection per command in a script reaches the limit quickly, and half-open connections linger after the commands finish. Two fixes, in order of preference:

# 1. Reuse one connection for the whole session (client side, no server change)
cat >> ~/.ssh/config <<'EOF'
Host <monitoring-host>
    ControlMaster auto
    ControlPath ~/.ssh/cm/%r@%h:%p
    ControlPersist 10m
EOF
mkdir -p ~/.ssh/cm

# 2. Drop the lingering sockets on the server, if you are already locked out
#
(reach it from an allowlisted host)
ss -K -tn state established '( sport = :22 )' dst <your-ip>

A host exporter cannot reach a container that is healthy

Symptom: an exporter running on the host times out talking to a container by its IP, while the same container answers immediately from another container on the same bridge. The container is healthy either way, so it reads as an exporter fault.

Cause: an OUTPUT rule that drops a private range containing the Docker bridge. Docker’s default subnets sit inside 172.16.0.0/12, so a rule written to isolate internal networks catches them without naming Docker at all. Container-to-container traffic is unaffected because it never passes the host’s OUTPUT chain — which is why the fault looks selective.

iptables -S OUTPUT | grep -E '\-d (10\.|172\.|192\.168\.)'
ip -4 -o addr show | awk '$2 ~ /^(docker[0-9]+|br-)/ {print $2, $4}'

The check is the second command against the first: only a dropped range that actually contains a bridge on this host explains it. Two fixes:

# 1. Admit the docker bridges
iptables -I OUTPUT -o br+ -j ACCEPT
iptables -I OUTPUT -o docker0 -j ACCEPT
netfilter-persistent save

# 2. Or take the host out of the path: run the exporter inside the application's
#
own network, so the traffic never leaves the bridge. See
#
exporters/postgres/docker-compose.network.yml for a worked example.

Diagnosed live on angular.friendly-tech.com, where a host-networked exporter could not reach a PostgreSQL container that was serving normally. ## Checking Open Ports

Externally (from another server)

# Port scanning
nmap -p 1-65535 target-ip

# Check specific port
nc -zv target-ip 5432

# With timeout
timeout 3 bash -c \
    'cat < /dev/null > /dev/tcp/target-ip/5432' \
    && echo "Open" || echo "Closed"

Locally

# All listening ports
sudo ss -tlnp

# Docker ports
sudo ss -tlnp | grep docker-proxy

# Detailed by process
sudo lsof -i :5432

iptables Analysis

# All rules with packet counts
sudo iptables -L -n -v

# DOCKER-USER only
sudo iptables -L DOCKER-USER -n -v --line-numbers

# NAT table
sudo iptables -t nat -L -n -v

# Docker rules
sudo iptables -L DOCKER -n -v
sudo iptables -t nat -L DOCKER -n -v

# Trace packet path
sudo iptables -t raw -A PREROUTING -p tcp --dport 5432 -j TRACE
sudo dmesg | grep TRACE

Logging Blocked Connections

# Add logging to DOCKER-USER
sudo iptables -I DOCKER-USER -j LOG \
    --log-prefix "DOCKER-USER: " --log-level 4

# View logs
sudo tail -f /var/log/kern.log | grep "DOCKER-USER"
# or
sudo journalctl -f | grep "DOCKER-USER"

Testing Rules

# Simulate packet (doesn't actually send)
sudo iptables -C DOCKER-USER -s 1.2.3.4 -p tcp --dport 80 -j RETURN
# Returns 0 if rule exists, 1 if not

# Check which rule will match
sudo iptables -L DOCKER-USER -n -v
# Look at pkts/bytes counters

Security Checklist

Before Deployment

  • Check which ports each container publishes

  • Configure one of the Docker port protection methods

  • Verify rules with external port scanner

  • Configure blocked connection logging

Regular Checks

  • Open port scanning: nmap -p- server-ip

  • iptables rules check: iptables -L DOCKER-USER -n -v

  • Audit docker-compose files for public ports

  • Verify new containers before starting

Automation

#!/bin/bash
# /usr/local/bin/docker-security-check.sh

echo "=== Docker Security Check ==="
echo ""

echo "1. Published ports:"
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep -v "127.0.0.1"

echo ""
echo "2. DOCKER-USER rules:"
iptables -L DOCKER-USER -n --line-numbers

echo ""
echo "3. Externally accessible ports:"
ss -tlnp | grep docker-proxy | grep -v "127.0.0.1"

echo ""
echo "4. Port scan from localhost (simulating external):"
for port in 3306 5432 6379 27017 9200 5672; do
    timeout 1 bash -c \
        "cat < /dev/null > /dev/tcp/$(hostname -I | awk '{print $1}')/$port" \
        2>/dev/null && echo "WARNING: Port $port is open!" \
        || echo "OK: Port $port is closed"
done

branch 1.6.9 · commit a862b45d92590285f229f80a6b5d2efabcb3b1b0 · page generated August 08, 2026