A network namespace is an isolated copy of the Linux network stack, with its own interfaces, addresses, routing table and firewall rules. Docker, Podman and Kubernetes give every container or pod its own network namespace and connect it to the host with virtual cables and bridges. In this tutorial you will build that plumbing by hand on Ubuntu 24.04: create namespaces, connect them with veth pairs and a bridge, give them Internet access through NAT, filter traffic inside a namespace, publish a service with port forwarding, and then inspect a real Docker container the same way.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, such as a CubePath VPS.
  • A non-root user with sudo privileges.
  • The name of the server's public interface. Find it with ip route show default; this guide uses eth0.
  • Optionally, Docker installed for the last step.

The ip command (from iproute2) and iptables are installed by default on Ubuntu 24.04.

Step 1 - Creating a network namespace

Create a namespace called red:

sudo ip netns add red
ip netns list
red

ip netns exec runs any command inside a namespace. List the interfaces inside red:

sudo ip netns exec red ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

A new namespace only has a loopback interface, and it is down. None of the host's interfaces, routes or firewall rules are visible from inside. Bring loopback up, since many programs expect 127.0.0.1 to work:

sudo ip -n red link set lo up

The ip -n red form is a shorthand for ip netns exec red ip and is used through the rest of this guide.

Step 2 - Connecting a namespace to the host with a veth pair

A veth pair is a virtual cable: whatever enters one end comes out of the other. Create a pair, keep one end on the host and move the other into red:

sudo ip link add veth-red type veth peer name eth0-red
sudo ip link set eth0-red netns red

Address both ends in 10.0.0.0/24 and bring them up:

sudo ip addr add 10.0.0.1/24 dev veth-red
sudo ip link set veth-red up
sudo ip -n red addr add 10.0.0.2/24 dev eth0-red
sudo ip -n red link set eth0-red up

Test the link in both directions:

ping -c 2 10.0.0.2
sudo ip netns exec red ping -c 2 10.0.0.1
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.071 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.049 ms

The namespace can reach the host, but nothing beyond it: it has no default route. Remove this pair before moving on, because the next step connects namespaces through a bridge instead. Deleting one end of a veth pair deletes both:

sudo ip link del veth-red

Step 3 - Connecting several namespaces through a bridge

With more than two namespaces, point-to-point cables do not scale. Container runtimes use a Linux bridge instead, which works like a virtual switch. Create the bridge br0 and give it the gateway address 10.2.0.1:

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

Create a second namespace, blue:

sudo ip netns add blue
sudo ip -n blue link set lo up

Connect red to the bridge: one end of the veth pair is plugged into br0, the other goes into the namespace with an address and a default route through the bridge:

sudo ip link add veth-red type veth peer name eth0-red
sudo ip link set veth-red master br0
sudo ip link set veth-red up
sudo ip link set eth0-red netns red
sudo ip -n red addr add 10.2.0.2/24 dev eth0-red
sudo ip -n red link set eth0-red up
sudo ip -n red route add default via 10.2.0.1

Do the same for blue with address 10.2.0.3:

sudo ip link add veth-blue type veth peer name eth0-blue
sudo ip link set veth-blue master br0
sudo ip link set veth-blue up
sudo ip link set eth0-blue netns blue
sudo ip -n blue addr add 10.2.0.3/24 dev eth0-blue
sudo ip -n blue link set eth0-blue up
sudo ip -n blue route add default via 10.2.0.1

Verify that both namespaces reach the gateway and each other:

sudo ip netns exec red ping -c 2 10.2.0.1
sudo ip netns exec red ping -c 2 10.2.0.3
64 bytes from 10.2.0.3: icmp_seq=1 ttl=64 time=0.094 ms
64 bytes from 10.2.0.3: icmp_seq=2 ttl=64 time=0.061 ms

Check which ports are attached to the bridge:

bridge link show master br0
7: veth-red@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 2
9: veth-blue@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 2

Step 4 - Giving the namespaces Internet access with NAT

Packets from 10.2.0.0/24 must be routed by the host and rewritten to the host's public address, which is exactly what Docker does for its default bridge. First enable IP forwarding, persistently:

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-netns-lab.conf
sudo sysctl --system

Add a masquerade rule for traffic leaving through eth0 and allow forwarding between the bridge and the public interface. -I inserts the rules at the top of the chain, before any restrictive rules:

sudo iptables -t nat -A POSTROUTING -s 10.2.0.0/24 -o eth0 -j MASQUERADE
sudo iptables -I FORWARD -i br0 -o eth0 -j ACCEPT
sudo iptables -I FORWARD -i eth0 -o br0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

Test with an IP address first, so DNS is not involved:

sudo ip netns exec red ping -c 2 1.1.1.1
64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=1.42 ms

For name resolution, ip netns exec bind-mounts /etc/netns/<name>/resolv.conf over /etc/resolv.conf for the command it runs, so each namespace can have its own resolver without touching the host's file. The host's 127.0.0.53 stub resolver is not reachable from inside the namespace, so point it to a public resolver:

sudo mkdir -p /etc/netns/red
echo 'nameserver 1.1.1.1' | sudo tee /etc/netns/red/resolv.conf
sudo ip netns exec red getent hosts ubuntu.com
185.125.190.20  ubuntu.com

Step 5 - Filtering traffic inside a namespace

Each namespace has its own netfilter tables, so rules applied inside blue do not affect the host or red. Lock blue down so it can only make outbound DNS, HTTP and HTTPS connections. Allow loopback and return traffic first, then the permitted ports, then set the default policy to drop:

sudo ip netns exec blue iptables -A OUTPUT -o lo -j ACCEPT
sudo ip netns exec blue iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo ip netns exec blue iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
sudo ip netns exec blue iptables -A OUTPUT -p tcp -m multiport --dports 53,80,443 -j ACCEPT
sudo ip netns exec blue iptables -P OUTPUT DROP

Give blue a resolver as in Step 4, then test. HTTPS works, ping does not:

sudo mkdir -p /etc/netns/blue
echo 'nameserver 1.1.1.1' | sudo tee /etc/netns/blue/resolv.conf
sudo ip netns exec blue curl -sI https://ubuntu.com | head -n 1
sudo ip netns exec blue ping -c 1 -W 1 1.1.1.1
HTTP/2 200
ping: sendmsg: Operation not permitted

List the rules of the namespace with their counters, and note that the host's own rules are unaffected:

sudo ip netns exec blue iptables -L OUTPUT -n -v
sudo iptables -L OUTPUT -n

Step 6 - Publishing a service with port forwarding

To expose a service running in a namespace, the host rewrites the destination of incoming connections, which is how docker run -p works. Start a simple web server inside red on port 80. It stays in the foreground, so run it in a second SSH session:

sudo ip netns exec red python3 -m http.server 80

In the first session, forward TCP port 8080 on the public interface to 10.2.0.2:80, and allow the forwarded connections:

sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.2.0.2:80
sudo iptables -I FORWARD -i eth0 -o br0 -p tcp -d 10.2.0.2 --dport 80 -m conntrack --ctstate NEW -j ACCEPT

If UFW is enabled, also open the port on the host:

sudo ufw allow 8080/tcp

From your local computer, request the page, replacing your_server_ip with the server's public IP address:

curl -I http://your_server_ip:8080
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.12.3

The request log appears in the session running the web server. Stop it with CTRL+C when you are done.

Step 7 - Inspecting a Docker container's namespace

With these building blocks in mind, you can read Docker's network setup directly. Start a test container:

sudo docker run -d --name web nginx

Find the process ID of the container's main process, which holds its network namespace:

pid=$(sudo docker inspect -f '{{.State.Pid}}' web)
echo "$pid"

nsenter -n runs a command from the host inside that network namespace, so you can use the host's tools even though the container image has none:

sudo nsenter -t "$pid" -n ip -br addr
sudo nsenter -t "$pid" -n ip route
lo               UNKNOWN        127.0.0.1/8
eth0@if12        UP             172.17.0.2/16
default via 172.17.0.1 dev eth0

The @if12 suffix is the interface index of the other end of the veth pair on the host. Find it, and confirm it is attached to Docker's bridge docker0:

ip -br link | grep '@if' | grep veth
bridge link show master docker0
12: veth3a9f1c2@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master docker0 state forwarding priority 32 cost 2

This is the same topology you built by hand: a veth pair, a bridge acting as gateway, and NAT rules that sudo iptables -t nat -L -n shows in the DOCKER chain. Remove the test container when you are done:

sudo docker rm -f web

Step 8 - Cleaning up

Namespaces created with ip netns add do not survive a reboot, and neither do the iptables rules added here. To remove everything now, delete the namespaces (which destroys the veth ends inside them and their peers), the bridge and the rules:

sudo ip netns del red
sudo ip netns del blue
sudo ip link del br0
sudo rm -r /etc/netns/red /etc/netns/blue
sudo iptables -t nat -D POSTROUTING -s 10.2.0.0/24 -o eth0 -j MASQUERADE
sudo iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.2.0.2:80
sudo iptables -D FORWARD -i br0 -o eth0 -j ACCEPT
sudo iptables -D FORWARD -i eth0 -o br0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo iptables -D FORWARD -i eth0 -o br0 -p tcp -d 10.2.0.2 --dport 80 -m conntrack --ctstate NEW -j ACCEPT

If you opened port 8080 in UFW, remove it with sudo ufw delete allow 8080/tcp. Keep /etc/sysctl.d/99-netns-lab.conf only if the server needs IP forwarding for another reason; Docker enables it on its own.

Troubleshooting

  • Cannot open network namespace "red": No such file or directory: the namespace does not exist or was deleted. Check with ip netns list.
  • Namespace cannot reach the bridge: both veth ends must be UP. Check ip -br link show veth-red on the host and sudo ip -n red -br link inside, and confirm the host end is attached with bridge link show master br0.
  • Namespaces reach the gateway but not the Internet: check sysctl net.ipv4.ip_forward returns 1, that the MASQUERADE rule uses the correct outbound interface, and that the FORWARD rules appear before any DROP in sudo iptables -L FORWARD -n --line-numbers.
  • ping works but names do not resolve: create /etc/netns/<name>/resolv.conf as shown in Step 4. It only applies to commands started with ip netns exec.

Conclusion

You created isolated network stacks with ip netns, wired them together with veth pairs and a bridge, gave them Internet access through NAT, applied a firewall inside one namespace, published a service with DNAT and recognized the same pieces inside a Docker container. These primitives are what container runtimes and CNI plugins automate. As next steps, try docker network create and inspect the new bridge it makes, read the reference CNI bridge plugin, or use a namespace to run a single service behind a WireGuard tunnel.