Network Isolation #
One of Docker’s main strengths that’s often overlooked is network isolation — the ability to keep containers truly isolated from each other and from the outside network, unless explicitly allowed. Without good isolation, Docker just becomes a “lightweight VM” without meaningful protection.
Containers without network isolation = all containers can talk to each other, every service is exposed to the host network, and one small security hole can be fatal. This is the scenario often seen in production with sloppy setups: one container gets compromised, and the attacker can scan internal services, access databases, and exfiltrate data within minutes.
This article covers network isolation in depth: the concept, OS-level mechanisms, common segmentation patterns, internal networks, defense in depth, and best practices for multi-tenant environments. After reading it, you’ll be able to design secure, scalable container network topologies.
What Is Network Isolation? #
Network isolation is the principle that containers can’t freely communicate with each other unless explicitly allowed. Every container has “network boundaries” that can only be crossed through deliberate configuration.
flowchart TB
subgraph Host["Docker Host"]
subgraph NetA["Network A"]
A1[Container A1]
A2[Container A2]
end
subgraph NetB["Network B"]
B1[Container B1]
B2[Container B2]
end
subgraph NetC["Network C (internal)"]
C1[(Database)]
end
end
A1 -.->|can't| B1
A1 -.->|can't| C1
A2 <-->|can| A1
B1 <-->|can| B2
A2 <-.->|via API gateway| B1Characteristics of good network isolation:
- Default deny — a container can’t talk to another container without being on the same network.
- Explicit allow — connections are only allowed when declared (network, port mapping, or policy).
- Granular segmentation — frontend, backend, and data are separated into different networks.
- Internal-only — services that don’t need the internet have no outbound access.
- Egress control — where a container may talk out to is also controlled.
The main principle: isolation is the default, not an add-on. Containers are isolated from the very first second — you’re the one choosing to open that isolation. When in doubt, keep it isolated.
Network Namespaces: The Isolation Foundation #
At the OS level, the network namespace is the mechanism Docker uses to isolate containers. Each container runs in its own namespace with:
- Its own interfaces (eth0, lo).
- Its own routing table.
- Its own iptables rules.
- Its own sockets.
flowchart LR
subgraph HOST["Host Network Namespace"]
H_ETH[eth0: 192.168.1.10]
H_BR[docker0]
H_RT[Host routing]
end
subgraph NS1["Container A Namespace"]
A_ETH[eth0: 172.18.0.2]
A_LO[lo: 127.0.0.1]
end
subgraph NS2["Container B Namespace"]
B_ETH[eth0: 172.18.0.3]
B_LO[lo: 127.0.0.1]
end
A_ETH <-.->|veth| H_BR
B_ETH <-.->|veth| H_BR
H_ETH --- H_RTContainers A and B are in different namespaces connected to the docker0 bridge via veth pairs. They don’t “see” each other’s interfaces directly. Communication only happens because the bridge forwards packets.
# List all network namespaces on the host
ip netns list
# See a container's namespace
docker run --rm --network bridge alpine sh -c 'cat /proc/self/net/dev'
# The container only sees its own eth0 and lo
# See the host namespace
ip link show
# Many veths and bridges will be visible
Isolation Layers #
Docker network isolation works across several layers:
Layer 1: Network Namespaces #
Containers have their own network namespaces, isolated from the host and other containers. Default for all containers.
Layer 2: Network Drivers #
The choice of bridge, host, none, overlay, macvlan determines the type of isolation. Bridge = isolated within its own bridge. Host = not isolated.
Layer 3: Network Topology #
User-defined networks provide natural boundaries. Containers on network A can’t talk to network B without routing.
Layer 4: Port Mapping #
-p is the exit door from isolation. Without -p, a container is truly hidden from the host. With -p, specific ports are exposed.
Layer 5: iptables Rules #
Docker adds iptables rules for NAT, port mapping, and default-deny. Custom rules can add extra firewall layers.
flowchart TB
L1[Layer 1: Network Namespace] --> L2[Layer 2: Network Driver]
L2 --> L3[Layer 3: Network Topology]
L3 --> L4[Layer 4: Port Mapping]
L4 --> L5[Layer 5: iptables]
style L1 fill:#dfd
style L2 fill:#dfd
style L3 fill:#dfd
style L4 fill:#fdd
style L5 fill:#fddGreen = isolated by default, Red = open to host/internet by default. Layers 4 and 5 need explicit configuration to maintain isolation.
Segmentation: The Foundation of Good Isolation #
Segmentation is separating networks by function or security level. It’s the most effective isolation pattern.
The Classic Three-Layer Segmentation #
flowchart TB
subgraph Edge["Edge (public)"]
LB[Load Balancer]
end
subgraph App["Application (internal)"]
API1[API 1]
API2[API 2]
end
subgraph Data["Data (most restricted)"]
DB[(Database)]
Cache[(Cache)]
end
LB --> API1
LB --> API2
API1 --> DB
API1 --> Cache
API2 --> DB
API2 --> Cache# docker-compose.yml
version: "3.9"
services:
loadbalancer:
image: traefik
ports:
- "443:443"
networks:
- edge
api:
image: my-api
networks:
- edge
- app
deploy:
replicas: 3
worker:
image: my-worker
networks:
- app
postgres:
image: postgres
networks:
- data
# No port mapping!
redis:
image: redis
networks:
- data
networks:
edge:
app:
internal: false # can reach the internet for API calls
data:
internal: true # NO outbound access
Characteristics:
edgenetwork — home of the load balancer, the only publicly exposed surface.appnetwork — home of application logic, can talk out (e.g. to third-party APIs).datanetwork — internal only, no internet access. The database is truly hidden.- Service placement —
apisits on bothedgeandapp(as a bridge).postgresonly ondata(hidden from the edge).
internal: trueis a powerful feature. A network withinternal: truehas no outbound gateway to the internet. Containers on this network are truly off-grid. Perfect for databases, caches, internal services — anything that doesn’t need (and must not have) outbound access.
Multi-Tenant: Tenant A vs Tenant B #
For multi-tenant environments (many users/customers), per-tenant segmentation is a must.
services:
tenant-a-app:
image: tenant-a-app
networks:
- tenant-a
tenant-b-app:
image: tenant-b-app
networks:
- tenant-b
networks:
tenant-a:
name: tenant-a-net
internal: true
tenant-b:
name: tenant-b-net
internal: true
Tenant A and Tenant B have different networks. They can’t see each other at all, even on the same host. A compromised tenant won’t threaten another tenant.
flowchart TB
subgraph TenantA["Tenant A"]
A1[App A]
ADB[(DB A)]
A1 --- ADB
end
subgraph TenantB["Tenant B"]
B1[App B]
BDB[(DB B)]
B1 --- BDB
end
A1 -.->|X no access| B1
style A1 fill:#ddf
style B1 fill:#fddInternal Networks: Internet Access = 0 #
Networks with internal: true behave specially:
- No outbound gateway to the host/internet.
- No outbound NAT — packets can never leave.
- Containers can still talk to each other on the same network.
- Docker internal DNS is still active.
networks:
internal-db:
internal: true
# Verify: a container on an internal network can't ping google
docker run --rm --network internal-db alpine ping -c 1 8.8.8.8
# ping: connect: Network is unreachable
Use cases:
- Databases — don’t need (and must not have) internet access.
- Internal caches — Redis, Memcached.
- Message brokers — Kafka, RabbitMQ.
- Admin tools — pgAdmin, RedisInsight.
- Internal APIs — service-to-service calls that must not be reachable from the internet.
Don’t use internal: true for services that fetch dependencies at runtime. For example, images that fetch configuration from S3 at startup. Images must already contain all dependencies at build time.Default Bridge vs User-Defined Networks for Isolation #
Docker’s default bridge network has serious isolation weaknesses:
- All containers on the default bridge share one subnet.
- No internal DNS.
- No segmentation.
User-defined networks provide far more granular isolation:
- One network = one isolation boundary.
- Containers on network A don’t talk to network B.
- Internal DNS = safe service discovery.
- Custom subnets and gateways possible.
# Anti-pattern: everything on the default bridge
docker run -d --name app my-app
docker run -d --name db postgres
docker run -d --name cache redis
# app, db, cache all on the same bridge
# A compromised cache can scan db
# Best practice: segmentation
docker network create app-net
docker network create data-net
docker run -d --name app --network app-net my-app
docker run -d --name db --network data-net postgres
docker run -d --name cache --network data-net redis
# app has no direct access to db/cache
# (unless manually connected)
Containers on Many Networks: Layered Security #
One container can connect to multiple networks. This is used to create layered security — the container becomes a “gateway” you can control.
docker network create edge --internal=false
docker network create app --internal=false
docker network create data --internal=true
docker run -d --name api --network edge nginx
# api also attached to app
docker network connect app api
# api also attached to data
docker network connect data api
flowchart LR
Edge["edge (public)"] -->|eth0a| API[API]
App["app (work)"] -->|eth0b| API
Data["data (internal)"] -->|eth0c| API
Data --> DB[(db)]
Data --> Cache[(cache)]The api container has three interfaces on three different networks. It becomes the single control point between:
- Internet (via edge) ↔ Application (app)
- Application (app) ↔ Data (data)
If api is compromised, the attacker can go through api to db. But db can’t be reached directly from the internet — it must go through api. This gives you one audit point, one policy enforcement point.
This is the “jump host” pattern long used in traditional security. The container acts as a bastion host. It’s more exposed, but it’s also the most monitored and audited.
Network Isolation and Production Security #
1. Default Deny: Disable Everything Unnecessary #
# Drop all default network policies
iptables -P FORWARD DROP
# Allow only the traffic needed
iptables -A FORWARD -i docker0 -o docker0 -j ACCEPT # container-to-container
iptables -A FORWARD -i docker0 -j ACCEPT # container egress
Docker adds these rules automatically, but some extra rules can strengthen isolation:
# Drop traffic between bridges (already default in Docker, but extra doesn't hurt)
iptables -A FORWARD -i br-a -o br-b -j DROP
2. Custom iptables for an Extra Layer #
Add rules in the DOCKER-USER chain (see the NAT article):
# Only allow traffic from network 10.0.0.0/8 to containers
iptables -A DOCKER-USER -s 10.0.0.0/8 -j ACCEPT
iptables -A DOCKER-USER -j DROP
3. NetworkPolicy (Kubernetes) #
For Kubernetes, NetworkPolicy is the formal way to declare isolation.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-policy
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: web
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: db
ports:
- protocol: TCP
port: 5432
# DNS to kube-system
- to:
- namespaceSelector:
matchLabels:
name: kube-system
ports:
- protocol: UDP
port: 53
api may only receive from web, and may only talk to db and DNS. Default deny for all other traffic.
Without a NetworkPolicy in K8s, every pod can talk to every pod. That’s the K8s default. For production, NetworkPolicy is a must. Tools like Calico, Cilium, or Weave can enforce these policies.Anti-Patterns That Violate Isolation #
1. Host Networking for Convenience #
# ✗ Anti-pattern: host networking to skip configuration
docker run -d --network host my-app
# No isolation. The container is a first-class host citizen.
# ✓ Solution: bridge + port mapping
docker run -d --network app-net -p 8080:8080 my-app
2. Default Bridge for Multi-Container Setups #
# ✗ Anti-pattern: everything on the default bridge, hardcoded IPs
docker run -d --name db postgres
docker run -d -e DB_HOST=172.17.0.2 my-api
# The IP can change on restart
# ✓ Solution: user-defined network + DNS
docker network create app-net
docker run -d --name db --network app-net postgres
docker run -d -e DB_HOST=db --network app-net my-api
3. Exposed Databases #
# ✗ Anti-pattern: postgres with -p
docker run -d -p 5432:5432 postgres
# The database is exposed to every host interface
# ✓ Solution: no -p, postgres only on an internal network
docker run -d --network data-net --name db postgres
# data-net internal: true, no port mapping
4. Docker Socket Mounted #
# ✗ Anti-pattern: an ordinary container mounting docker.sock
docker run -d -v /var/run/docker.sock:/var/run/docker.sock my-app
# my-app can control the Docker daemon
# ✓ Solution: restrict to containers that genuinely need it (Portainer, etc.)
# For ordinary apps, DON'T mount docker.sock
5. All Services on One Network #
# ✗ Anti-pattern
docker network create all-in-one
docker run -d --name app --network all-in-one my-app
docker run -d --name db --network all-in-one postgres
docker run -d --name admin --network all-in-one pgadmin
# admin can access db - even though it shouldn't
# ✓ Solution: separate them
docker network create data-net --internal
docker network create mgmt-net
docker run -d --name db --network data-net postgres
docker run -d --name admin --network mgmt-net,data-net pgadmin
# admin has db access, but db isn't exposed
Monitoring Network Isolation #
To make sure isolation is working, good monitoring:
# List all networks and their containers
docker network ls
for net in $(docker network ls -q); do
echo "=== Network: $(docker network inspect $net --format '{{.Name}}') ==="
docker network inspect $net --format '{{range .Containers}}{{.Name}} {{end}}'
done
# View active iptables rules
sudo iptables -L -n -v | head -50
# View policies in K8s
kubectl get networkpolicy -A
# Prometheus alert: containers mounting docker.sock
docker_container_mounts{type="bind",source="/var/run/docker.sock"} > 0
Network Isolation Best Practices #
MUST:
✓ Segment networks per layer (edge, app, data)
✓ Internal networks for services that don't need the internet
✓ User-defined networks, not the default bridge
✓ No port mapping for internal services
✓ Designated containers as audited gateways
✓ NetworkPolicy in Kubernetes for all pods
✓ Monitoring and alerts for topology changes
MUST NOT:
✗ Use host networking to "simplify"
✗ Put all services on one big network
✗ Mount /var/run/docker.sock for ordinary apps
✗ Use the default bridge for multi-container setups
✗ Hardcode IPs between containers
✗ Expose database ports
✗ Use the same network for multi-tenant setups
Summary #
- Network isolation = containers can’t talk to each other by default, only when allowed. The default-deny, explicit-allow principle.
- Network namespaces are the OS-level foundation. Each container has its own interfaces, routing, and iptables.
- Five isolation layers: namespace (L1), driver (L2), topology (L3), port mapping (L4), iptables (L5). L1-L3 are safe by default. L4-L5 need explicit configuration.
- Segmentation = separation per layer (edge/app/data) or per tenant. The edge is publicly exposed, app works internally, data is truly hidden.
internal: true= a network with no outbound gateway. Databases and caches must use this mode. Services can never reach the internet.- Layered security = containers on many networks at once, becoming auditable gateways/bridges. The jump-host pattern.
- Anti-patterns: host networking for convenience, default bridge for multi-container setups, exposed databases, careless docker.sock mounts, one network for everything.
- NetworkPolicy in Kubernetes is the formal declaration of default-deny + explicit-allow. Without a policy, every pod can talk to every pod.
- Monitoring: audit
docker network ls, iptables rules, and NetworkPolicies in K8s. Alert on topology changes.- The main principle: isolation by default, open by configuration. When in doubt, keep it isolated and open only what’s needed.
- For multi-tenancy: one tenant = one internal network. Tenant A and Tenant B should not even know each other exists.
← Previous: Container-to-Container Communication Next: User-defined Network →