Network Namespace Plumbing by Hand: Bridge-Based Isolation Without Docker or LXC

Every time you spin up a Docker container, the kernel quietly creates a network namespace for it, wires up a veth pair, plugs one end into docker0, and NATes it to the outside world. Most people treat this as magic. It isn’t. It’s about 15 lines of shell that any sysadmin can reproduce by hand — and understanding those 15 lines will completely change how you debug container networking, design custom topologies, or build lightweight isolation without pulling in a container runtime.

This guide builds a multi-namespace topology from scratch: two isolated "hosts" connected through a software bridge, with routed internet access through the host. We’ll go slow enough to understand every step, then fast enough to wire the whole thing in one script.

Why Bother Doing This Manually?

Fair question. Docker handles this. LXC handles this. Even systemd-nspawn handles this.

But there are real situations where you want raw namespace plumbing:

  • You’re debugging why two containers can’t reach each other and you need to understand what Docker actually did.
  • You’re building a test environment that mimics a multi-host network topology on a single machine without the overhead of a container daemon.
  • You’re writing a lightweight sandbox for an untrusted process and don’t want a full container runtime as a dependency.
  • You’re just tired of treating the network stack as a black box.

The tooling is all in iproute2, which is already on every modern Linux system.

The Building Blocks

Before wiring anything up, you need to know the three kernel primitives at play here.

Network namespaces give a process its own isolated view of network interfaces, routing tables, firewall rules, and sockets. Two processes in different namespaces can bind to port 80 simultaneously without conflict, and they can’t see each other’s traffic unless you explicitly connect them.

veth pairs are virtual ethernet cables. Create one, and you get two interface endpoints. Anything sent into one end comes out the other. You can move each endpoint into a different namespace, effectively running a wire between two isolated network stacks.

Linux bridge (bridge or brX) is a software Layer 2 switch. Plug multiple interfaces into it and they can forward frames to each other based on MAC address — the same way a physical switch works, but entirely in kernel space.

The topology we’re building looks like this:

[ Namespace: ns1 ]        [ Namespace: ns2 ]
  eth0-ns1 (veth)           eth0-ns2 (veth)
  10.10.0.2/24              10.10.0.3/24
       |                         |
       +----------+--------------+
                  |
              [ br0 bridge ]
              10.10.0.1/24
                  |
              [ Host eth0 ]
                  |
              [ Internet ]

Step 0: Prerequisites

You need a Linux machine (bare metal or VM) with:

  • iproute2 (ip command — already present on any modern distro)
  • iptables or nftables
  • Root access (or sudo)
  • IPv4 forwarding either enabled or you’re ready to enable it

Check your kernel’s namespace support:

ls /proc/sys/net/ipv4/conf/
# You should see "all", "default", "lo", and your real interfaces

If you’re on a cloud VM, make sure the instance allows traffic between its own interfaces — some hypervisors drop this. For AWS, disable source/destination check on the NIC.

Step 1: Create the Namespaces

# Create two named network namespaces
sudo ip netns add ns1
sudo ip netns add ns2

# Verify
ip netns list
# ns1
# ns2

Named namespaces live as bind-mounted files under /var/run/netns/. The name is just a handle — what matters is the namespace ID the kernel assigned.

Each new namespace starts with only a loopback interface, and even that is down:

sudo ip netns exec ns1 ip link show
# 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN

Step 2: Create the Bridge

On the host (not inside any namespace), create the bridge:

sudo ip link add br0 type bridge

# Bring it up and assign the gateway IP
sudo ip addr add 10.10.0.1/24 dev br0
sudo ip link set br0 up

The bridge’s IP (10.10.0.1) becomes the default gateway for both namespaces. The bridge itself lives in the host’s root network namespace.

Step 3: Create and Wire the veth Pairs

Each namespace needs one veth pair. One end stays on the host (to plug into the bridge), the other gets moved into the namespace.

# Pair for ns1
sudo ip link add veth-ns1 type veth peer name eth0-ns1

# Pair for ns2
sudo ip link add veth-ns2 type veth peer name eth0-ns2

Now move the namespace-facing ends:

sudo ip link set eth0-ns1 netns ns1
sudo ip link set eth0-ns2 netns ns2

After this, eth0-ns1 and eth0-ns2 vanish from the host’s interface list and appear inside their respective namespaces. The veth-ns1 and veth-ns2 ends remain on the host.

Plug the host-side ends into the bridge:

sudo ip link set veth-ns1 master br0
sudo ip link set veth-ns2 master br0

# Bring up the host-side veth ends
sudo ip link set veth-ns1 up
sudo ip link set veth-ns2 up

Step 4: Configure Networking Inside the Namespaces

Every command prefixed with ip netns exec <name> runs inside that namespace’s network stack.

# ns1
sudo ip netns exec ns1 ip addr add 10.10.0.2/24 dev eth0-ns1
sudo ip netns exec ns1 ip link set eth0-ns1 up
sudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns1 ip route add default via 10.10.0.1

# ns2
sudo ip netns exec ns2 ip addr add 10.10.0.3/24 dev eth0-ns2
sudo ip netns exec ns2 ip link set eth0-ns2 up
sudo ip netns exec ns2 ip link set lo up
sudo ip netns exec ns2 ip route add default via 10.10.0.1

Verify with a ping between namespaces:

sudo ip netns exec ns1 ping -c2 10.10.0.3
# PING 10.10.0.3: 64 bytes from 10.10.0.3: icmp_seq=0 ttl=64 time=0.12 ms

If that works, Layer 2 is plumbed correctly. The bridge is forwarding frames between the two veth pairs.

Step 5: Internet Access via NAT

The namespaces can reach the host, but traffic to the internet dies at the host’s routing table because return packets don’t know how to get back to 10.10.0.x — that range is local to this machine.

Two things need to happen: the host must forward packets between interfaces, and outbound traffic from the namespaces must be NATted to the host’s public IP.

# Enable IP forwarding on the host
sudo sysctl -w net.ipv4.ip_forward=1

# Masquerade (SNAT) traffic leaving through the host's default interface
# Replace eth0 with your actual outbound interface (enp3s0, ens3, etc.)
sudo iptables -t nat -A POSTROUTING -s 10.10.0.0/24 ! -o br0 -j MASQUERADE

Test from inside ns1:

sudo ip netns exec ns1 ping -c2 8.8.8.8
# PING 8.8.8.8: 64 bytes from 8.8.8.8: icmp_seq=0 ttl=117 time=14.3 ms

If ping works but DNS doesn’t, the namespace has no resolver configured. Quick fix:

# Run bash inside ns1 to avoid repeating the exec prefix
sudo ip netns exec ns1 bash

# Inside ns1:
echo "nameserver 1.1.1.1" > /etc/resolv.conf
curl -s https://example.com | head -5
exit

Keep in mind that /etc/resolv.conf inside a network namespace is the same file as on the host unless you’ve set up a separate mount namespace too. This is where things get nuanced — network namespaces isolate the network stack, not the filesystem.

Gotchas

iptables FORWARD chain may be DROP by default. Docker sets FORWARD DROP as its base policy to prevent bypass of its own rules. If you added Docker to this machine at any point, your FORWARD chain is probably restrictive. Check:

sudo iptables -L FORWARD -n

If the policy is DROP or there are blanket DROP rules, you need an explicit ACCEPT for your bridge subnet:

sudo iptables -I FORWARD -i br0 -j ACCEPT
sudo iptables -I FORWARD -o br0 -j ACCEPT

Or more targeted:

sudo iptables -I FORWARD -s 10.10.0.0/24 -j ACCEPT
sudo iptables -I FORWARD -d 10.10.0.0/24 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Bridge might drop traffic due to bridge-nf-call-iptables. The kernel has a sysctl that controls whether bridged traffic goes through iptables. On some setups this is 0, which means your iptables FORWARD rules are skipped for bridge-internal traffic. On others (especially with br_netfilter loaded), it’s 1, which means iptables sees bridge frames and may block them.

cat /proc/sys/net/bridge/bridge-nf-call-iptables

If it’s 1 and you’re seeing unexpected drops, either adjust your rules or:

sudo sysctl -w net.bridge.bridge-nf-call-iptables=0

If it’s 0 and iptables -t nat -A POSTROUTING isn’t working for traffic passing through the bridge, load br_netfilter and set it to 1. Yes, this is genuinely confusing. It depends heavily on which kernel version and which distro defaults you’re on.

veth MTU mismatch. By default, veth interfaces come up with an MTU of 1500. The bridge typically inherits the lowest MTU of attached interfaces. In most cases this is fine, but if you’re tunneling over this (VXLAN, WireGuard through the namespace), you’ll want to size the MTU down appropriately to avoid fragmentation.

sudo ip link set veth-ns1 mtu 1450
sudo ip netns exec ns1 ip link set eth0-ns1 mtu 1450

None of this survives a reboot. Network namespaces created with ip netns add and all associated links disappear at reboot. They’re not persisted to disk. If you need this to come back up, you need a script or a systemd service.

Making It Persistent with systemd

Here’s a minimal systemd oneshot service that rebuilds the topology on boot:

# /etc/systemd/system/netns-lab.service
[Unit]
Description=Network namespace lab (ns1 + ns2 behind br0)
After=network.target

[Service]
Type=oneshot
RemainAfterExit=yes

ExecStart=/usr/local/bin/netns-lab-up.sh
ExecStop=/usr/local/bin/netns-lab-down.sh

[Install]
WantedBy=multi-user.target
#!/usr/bin/env bash
# /usr/local/bin/netns-lab-up.sh

set -euo pipefail

ip netns add ns1
ip netns add ns2

ip link add br0 type bridge
ip addr add 10.10.0.1/24 dev br0
ip link set br0 up

for ns in ns1 ns2; do
    ip link add "veth-${ns}" type veth peer name "eth0-${ns}"
    ip link set "eth0-${ns}" netns "${ns}"
    ip link set "veth-${ns}" master br0
    ip link set "veth-${ns}" up
done

ip netns exec ns1 ip addr add 10.10.0.2/24 dev eth0-ns1
ip netns exec ns1 ip link set eth0-ns1 up
ip netns exec ns1 ip link set lo up
ip netns exec ns1 ip route add default via 10.10.0.1

ip netns exec ns2 ip addr add 10.10.0.3/24 dev eth0-ns2
ip netns exec ns2 ip link set eth0-ns2 up
ip netns exec ns2 ip link set lo up
ip netns exec ns2 ip route add default via 10.10.0.1

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.10.0.0/24 ! -o br0 -j MASQUERADE
#!/usr/bin/env bash
# /usr/local/bin/netns-lab-down.sh

set -euo pipefail

ip netns del ns1 2>/dev/null || true
ip netns del ns2 2>/dev/null || true
ip link del br0 2>/dev/null || true

iptables -t nat -D POSTROUTING -s 10.10.0.0/24 ! -o br0 -j MASQUERADE 2>/dev/null || true
chmod +x /usr/local/bin/netns-lab-up.sh /usr/local/bin/netns-lab-down.sh
systemctl enable --now netns-lab

Note that deleting the namespace with ip netns del automatically removes all interfaces inside it and destroys the associated veth peer on the host. So the teardown script is simple — you just need to delete the namespaces and the bridge.

Production-Ready Additions

If this is for something more than a lab, a few things are worth hardening.

Assign stable MAC addresses to your veth pairs. By default the kernel generates random MACs. This can cause issues with ARP caches and makes debugging harder. Set them explicitly:

sudo ip link set veth-ns1 address 02:00:00:00:00:01
sudo ip netns exec ns1 ip link set eth0-ns1 address 02:00:00:00:01:01

Use the 02: prefix — the second-least-significant bit of the first octet being 1 indicates a locally administered address, which avoids conflict with any real hardware MAC.

Use conntrack for proper stateful firewall rules. The masquerade rule above is the minimal setup. In production you want to track connection state so the FORWARD chain only accepts established return traffic, not arbitrary inbound:

iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i br0 -o eth0 -j ACCEPT
# Implicit DROP for everything else via base policy

Namespace-specific /etc/resolv.conf. If you want proper DNS isolation without touching the host resolver, use a bind mount:

mkdir -p /etc/netns/ns1
echo "nameserver 1.1.1.1" > /etc/netns/ns1/resolv.conf

ip netns exec will automatically bind-mount /etc/netns/<name>/resolv.conf over /etc/resolv.conf when this path exists. This is a documented feature of iproute2’s netns implementation, not a hack.

Monitor with nsenter instead of ip netns exec. For debugging a process already running inside a namespace (say, a service you started with unshare), use nsenter:

# Enter all namespaces of PID 1234
sudo nsenter -t 1234 -n -- ip addr show

This is more flexible than ip netns exec because it works with any namespace, not just named ones under /var/run/netns.

Cleaning Up

sudo ip netns del ns1
sudo ip netns del ns2
sudo ip link del br0
sudo iptables -t nat -D POSTROUTING -s 10.10.0.0/24 ! -o br0 -j MASQUERADE

The ip netns del calls take the veth pairs with them. The bridge deletion removes br0. The iptables rule needs to be deleted manually — it doesn’t get cleaned up automatically.

Where This Goes Next

Once you’re comfortable with this basic topology, the same primitives extend further than you might expect.

You can add a third namespace as a "router" between two separate bridge segments to simulate a routed network with multiple subnets. You can attach a real physical interface as a bridge port, turning the bridge into something that handles real external traffic. You can use ip netns exec to run arbitrary processes — a web server, a VPN daemon, a network scanner — in full network isolation without any container runtime.

Tools like systemd-nspawn, LXC, and Docker build on top of exactly this: namespaces plus veth pairs plus a bridge. The abstraction is useful, but understanding what sits underneath it is what separates someone who can debug container networking from someone who just restarts the daemon and hopes.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646