<!-- Generated from the canonical HTML page. Do not edit separately. -->
# Kubernetes the Hard Way, Part 4: Pod Networking by Hand, Without a CNI Plugin | Ankra

Canonical: https://ankra.ai/blog/kubernetes-the-hard-way-networking

Build Kubernetes pod networking by hand with network namespaces, veth pairs, a bridge, routes and NAT, then call a CNI plugin yourself and trace a Service. Tested.

[Back to Blog](https://ankra.ai/blog)

[Kubernetes](https://ankra.ai/blog/tag/kubernetes)  [Engineering](https://ankra.ai/blog/tag/engineering)  [Homelab](https://ankra.ai/blog/tag/homelab)

# Kubernetes the Hard Way, Part 4: Pod Networking by Hand, Without a CNI Plugin

[Mattias Åsell](https://ankra.ai/blog/author/mattias-asell)

October 25, 2026

11 min read

### On this page

In  [Part 1](https://ankra.ai/blog/kubernetes-the-hard-way)  you gave pods their network with a small config file for the `bridge` plugin and a few routes. It worked, and you could trust it, but you did not see what it did. This part opens it up.

You will build what that plugin builds with plain Linux commands, which means a network namespace for the pod, a cable into it, a switch on the host, a route to the other worker and a rule for the internet. Then you will hand the same job to the plugin yourself, exactly the way the container runtime does, and read what it answers. After that you will find out why a packet that is too big vanishes on some clouds, and how a Service address that belongs to no machine at all still reaches a pod.

We ran every command in this post, top to bottom, on fresh machines before publishing it, and the outputs on this page are the ones we got. Our machines were on Hetzner Cloud, whose private network is routed, so you will see its gateway in a few places.

## What you need

The cluster from  [Part 1](https://ankra.ai/blog/kubernetes-the-hard-way) , with its jumpbox and both workers. Everything in this part happens on the workers, plus a few `kubectl` commands from the jumpbox. Part 1 already installed `conntrack` on the workers. The only new package is `traceroute`.

| Component | Version |
| --- | --- |
| Kubernetes | v1.37.1 |
| CNI plugins | v1.9.1 |
| Debian | 13 |

The pods you build by hand get addresses from `10.100.0.0/16`, a range the cluster does not use. node-0 gets `10.100.0.0/24`, node-1 gets `10.100.1.0/24`.

## Step 1. Look inside a real pod

Start one pod on each worker, so you know where they are.

jumpbox

```
for N in 0 1; do  kubectl run net-${N} --image=busybox:1.37 --restart=Never \    --overrides="{\"spec\":{\"nodeName\":\"node-${N}\"}}" -- sleep 7200donekubectl wait --for=condition=Ready pod/net-0 pod/net-1 --timeout=120skubectl get pods net-0 net-1 -o wide
```

```
NAME    READY   STATUS    RESTARTS   AGE   IP           NODE     NOMINATED NODE   READINESS GATESnet-0   1/1     Running   0          3s    10.200.0.4   node-0   <none>           <none>net-1   1/1     Running   0          3s    10.200.1.4   node-1   <none>           <none>
```

A pod’s network is a Linux network namespace, a private copy of the network stack with its own interfaces, addresses and routes. containerd creates one per pod and keeps it under `/var/run/netns`. Find the one for `net-0` on node-0 and look inside.

node-0

```
POD=$(crictl pods --name net-0 -q)NETNS=$(crictl inspectp -o go-template \  --template '{{range .info.runtimeSpec.linux.namespaces}}{{if eq .type "network"}}{{.path}}{{end}}{{end}}' ${POD})NS=$(basename ${NETNS})echo ${NS}ip -n ${NS} -br addrip -n ${NS} route
# eth0@ifN in the pod means the other end of its cable is interface N on the hostPEER_INDEX=$(ip -n ${NS} -o link show eth0 | grep -o '@if[0-9]*' | cut -c4-)ip -o link show | grep "^${PEER_INDEX}:" | cut -d' ' -f1-9ip -br link show master cni0ls /var/lib/cni/networks/bridge
```

```
cni-4c6a91cf-d49d-13b2-7bff-68211852cbc0lo               UNKNOWN        127.0.0.1/8 ::1/128eth0@if7         UP             10.200.0.4/24 fe80::3095:d9ff:fec4:a3aa/64default via 10.200.0.1 dev eth010.200.0.0/24 dev eth0 proto kernel scope link src 10.200.0.47: vethcbcd0d19@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni0veth6a96ea1b@if2 UP             6e:f4:e5:8b:a2:ef <BROADCAST,MULTICAST,UP,LOWER_UP>veth50e795cd@if2 UP             92:1a:5e:0f:2d:0d <BROADCAST,MULTICAST,UP,LOWER_UP>vethcbcd0d19@if2 UP             d2:e5:77:26:5a:22 <BROADCAST,MULTICAST,UP,LOWER_UP>10.200.0.210.200.0.310.200.0.4last_reserved_ip.0lock
```

That is the whole network of a pod.

- **Inside the namespace** there is a loopback interface and one `eth0` with the pod’s address. Its routing table has two lines. Addresses in the worker’s own slice are reachable directly, and everything else goes to `10.200.0.1`, which is `cni0` on the host.
- **The cable** is a veth pair. `eth0@if7` means its other end is interface 7 on the host, and interface 7 is `vethcbcd0d19`, plugged into `cni0`. Every pod on the worker has one such host end on the bridge, three in our case.
- **The address book** is a directory. The `host-local` plugin keeps one file per address it has handed out, which is how it never gives the same address to two pods.

## Step 2. Build a pod by hand

Now build the same thing yourself, on both workers. A namespace called `lab` plays the pod. A bridge called `lab0` plays `cni0`. A veth pair is the cable, a pair of virtual interfaces where whatever goes in one end comes out of the other. One end goes into the namespace and becomes its `eth0`, the other end is plugged into the bridge.

node-0 and node-1

```
apt-get install -y -qq traceroute >/dev/nullN=$(hostname | cut -d- -f2)
ip netns add labip link add lab0 type bridgeip addr add 10.100.${N}.1/24 dev lab0ip link set lab0 up
ip link add veth-lab type veth peer name eth0 netns labip link set veth-lab master lab0 up
ip -n lab addr add 10.100.${N}.10/24 dev eth0ip -n lab link set eth0 upip -n lab link set lo upip -n lab route add default via 10.100.${N}.1
ip -n lab -br addrping -c 2 -W 2 10.100.${N}.10 | tail -2
```

```
lo               UNKNOWN        127.0.0.1/8 ::1/128eth0@if9         UP             10.100.0.10/24 fe80::e0aa:29ff:fe0a:2ce0/642 packets transmitted, 2 received, 0% packet loss, time 1005msrtt min/avg/max/mdev = 0.142/0.207/0.272/0.065 ms
```

That is node-0. node-1 printed the same with `10.100.1.10`.

The bridge has the address `10.100.N.1`, and the namespace sends everything it cannot reach directly to that address. That makes the host the namespace’s router, which is exactly what `"isGateway": true` did in Part 1’s bridge config.

## Step 3. Route between the workers

A packet from `10.100.0.10` to `10.100.1.10` leaves the namespace, reaches node-0, and node-0 has to know where to send it. Part 1 solved this for pods, and the same choice applies here.

If your workers share one network segment, each worker routes the other’s range straight to it. On a routed network such as Hetzner Cloud, every packet goes through the network’s gateway, so the network itself has to learn where each range lives. Add those routes with the `hcloud` tool, using your network’s name. Skip this block on a shared segment.

hcloud CLI

```
NETWORK=kthwhcloud network add-route ${NETWORK} --destination 10.100.0.0/24 --gateway 10.240.0.20hcloud network add-route ${NETWORK} --destination 10.100.1.0/24 --gateway 10.240.0.21
```

Then add the route on each worker. As in Part 1, set `POD_GATEWAY` to your network’s gateway on a routed network, or leave it empty on a shared segment.

node-0 and node-1

```
N=$(hostname | cut -d- -f2)PEER=$((1 - N))PEER_IP=$(getent hosts node-${PEER} | awk '{print $1}')
# Empty when the workers share one network segment.# On a routed network such as Hetzner Cloud, the private network's gateway.POD_GATEWAY=""
if [ -n "${POD_GATEWAY}" ]; then  ip route add 10.100.0.0/16 via ${POD_GATEWAY}else  ip route add 10.100.${PEER}.0/24 via ${PEER_IP}fiip route get 10.100.${PEER}.10
```

```
10.100.1.10 via 10.240.0.1 dev enp7s0 src 10.240.0.20 uid 0    cache
```

`ip route get` asks the kernel which route it would use for one address. On node-0 the answer is the network’s gateway, out of the private interface. node-1 gives the same answer for `10.100.0.10`.

Now test across the workers from node-0, and see the path the packet takes.

node-0

```
ip netns exec lab ping -c 3 -W 2 10.100.1.10 | tail -2ip netns exec lab traceroute -n -w 2 -q 1 10.100.1.10
```

```
3 packets transmitted, 3 received, 0% packet loss, time 2001msrtt min/avg/max/mdev = 0.676/2.959/7.261/3.043 mstraceroute to 10.100.1.10 (10.100.1.10), 30 hops max, 60 byte packets 1  10.100.0.1  0.131 ms 2  10.240.0.1  2.675 ms 3  10.240.0.21  2.610 ms 4  10.100.1.10  1.667 ms
```

Read the path one hop at a time. The packet goes from the namespace to its gateway on the bridge (`10.100.0.1`), from node-0 to the private network’s gateway (`10.240.0.1`), from there to node-1 (`10.240.0.21`), because of the route you gave the network, and from node-1 over its own bridge into the other namespace. On a shared segment the second hop is missing, because node-0 sends straight to node-1. No address was translated anywhere on the way.

## Step 4. Let the pod reach the internet

Try to reach the internet from the namespace first.

node-0

```
ip netns exec lab ping -c 2 -W 2 1.1.1.1 | tail -2 || true
```

```
2 packets transmitted, 0 received, 100% packet loss, time 1026ms
```

The ping leaves, but no answer comes back. The packet carries the source address `10.100.0.10`, which only means something inside your network, and the reply has nowhere to go. Traffic leaving the cluster needs its source swapped for the worker’s own address. That is masquerading, the same rule Part 1 added for pods. Note the exception. Traffic to other pods, yours or Kubernetes’, must keep its real source.

node-0 and node-1

```
nft -f - <<'EOF'table ip lab-nat {  chain postrouting {    type nat hook postrouting priority srcnat; policy accept;    ip saddr 10.100.0.0/16 ip daddr != { 10.100.0.0/16, 10.200.0.0/16 } masquerade  }}EOF
```

node-0

```
ip netns exec lab ping -c 2 -W 2 1.1.1.1 | tail -2conntrack -L -p icmp -d 1.1.1.1 2>/dev/null | grep -v UNREPLIED | tail -1
```

```
2 packets transmitted, 2 received, 0% packet loss, time 1001msrtt min/avg/max/mdev = 1.386/3.117/4.848/1.731 msicmp     1 29 src=10.100.0.10 dst=1.1.1.1 type=8 code=0 id=2519 src=1.1.1.1 dst=37.27.184.211 type=0 code=0 id=2519 mark=0 use=1
```

The conntrack line shows the translation in both directions. The ping went out from `10.100.0.10`, and the reply came back to `37.27.184.211`, node-0’s public address. The kernel remembers the pairing and hands the reply back to the namespace. The `grep -v UNREPLIED` skips the entries left over from the pings that failed a moment ago.

## Step 5. Be the container runtime

Everything you just typed is what a CNI plugin does when containerd asks it to. The CNI contract is small. The runtime runs the plugin binary, passes the details in environment variables and the network config on standard input, and reads a JSON answer from standard output. You can be the runtime.

node-0

```
ip netns add lab2
cat > /root/lab-cni.conf <<'EOF'{  "cniVersion": "1.0.0",  "name": "lab",  "type": "bridge",  "bridge": "lab0",  "isGateway": true,  "ipam": {    "type": "host-local",    "ranges": [[{ "subnet": "10.100.0.0/24", "rangeStart": "10.100.0.100" }]],    "routes": [{ "dst": "0.0.0.0/0" }],    "dataDir": "/run/lab-ipam"  }}EOF
CNI_COMMAND=ADD CNI_CONTAINERID=lab2 CNI_NETNS=/var/run/netns/lab2 CNI_IFNAME=eth0 \  CNI_PATH=/opt/cni/bin /opt/cni/bin/bridge < /root/lab-cni.conf
```

```
{    "cniVersion": "1.0.0",    "interfaces": [        {            "mac": "1e:96:dd:b6:6d:9f",            "name": "lab0"        },        {            "mac": "7e:56:4a:45:3f:6e",            "name": "veth8a694dd9"        },        {            "mac": "d6:b8:f5:f0:6c:77",            "name": "eth0",            "sandbox": "/var/run/netns/lab2"        }    ],    "ips": [        {            "address": "10.100.0.100/24",            "gateway": "10.100.0.1",            "interface": 2        }    ],    "routes": [        {            "dst": "0.0.0.0/0"        }    ]}
```

That JSON is the plugin’s answer, and it is what containerd reads for every pod you start. The plugin found the existing `lab0` bridge and reused it, created a new veth pair, put one end into `lab2` as `eth0`, and asked its IPAM plugin, `host-local`, for an address. It started at `rangeStart`, so it could not collide with the `.10` you assigned by hand. The `interface: 2` under `ips` points at the third entry in `interfaces`, the `eth0` inside the namespace.

node-0

```
ip -n lab2 -br addrip netns exec lab2 ping -c 2 -W 2 10.100.1.10 | tail -1
CNI_COMMAND=DEL CNI_CONTAINERID=lab2 CNI_NETNS=/var/run/netns/lab2 CNI_IFNAME=eth0 \  CNI_PATH=/opt/cni/bin /opt/cni/bin/bridge < /root/lab-cni.confip -n lab2 -br addrls /run/lab-ipam/labip netns delete lab2
```

```
lo               DOWNeth0@if10        UP             10.100.0.100/24 fe80::d4b8:f5ff:fef0:6c77/64rtt min/avg/max/mdev = 0.907/0.940/0.973/0.033 mslo               DOWNlast_reserved_ip.0lock
```

The plugin-made pod reaches the namespace on node-1 at once, because it sits on the same bridge and uses the same route. Then `DEL`, which containerd sends when a pod stops. The plugin removed `eth0` and its veth pair, and gave the address back, so the IPAM directory no longer holds a file for `10.100.0.100`. Notice the loopback is down. Bringing it up is the runtime’s job, not the plugin’s, so in a real cluster containerd does it.

## Step 6. The packet that is too big

Every network link has a maximum transmission unit, the MTU, the biggest packet it carries in one piece. Ethernet’s is usually 1500 bytes. Look at the private network interface on a worker and at the pod bridge.

node-0

```
ip -br link show | awk '{print $1}' | grep -v -E '^(lo|veth)' | while read IF; do  echo "${IF%@*} $(cat /sys/class/net/${IF%@*}/mtu)"done
```

```
eth0 1500enp7s0 1450cni0 1500lab0 1500
```

Here is a mismatch. Everything on the worker says 1500, except the interface to the private network, `enp7s0`, which carries at most 1450 bytes. That is what Hetzner Cloud sets for its private networks. Other clouds and overlay networks have their own limits, many of them below 1500 too. Now send packets of different sizes across and forbid the network to split them.

node-0

```
# 1472 bytes of data + 28 bytes of headers = 1500, with "do not fragment" setip netns exec lab ping -c 1 -W 2 -M do -s 1472 10.100.1.10 || true# 1422 + 28 = 1450, the most the private link carriesip netns exec lab ping -c 1 -W 2 -M do -s 1422 10.100.1.10 | tail -2# One byte more fails at once, because the namespace now remembers the limitip netns exec lab ping -c 1 -W 2 -M do -s 1423 10.100.1.10 || true
```

```
PING 10.100.1.10 (10.100.1.10) 1472(1500) bytes of data.From 10.100.0.1 icmp_seq=1 Frag needed and DF set (mtu = 1450)
--- 10.100.1.10 ping statistics ---1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms
1 packets transmitted, 1 received, 0% packet loss, time 0msrtt min/avg/max/mdev = 0.602/0.602/0.602/0.000 msPING 10.100.1.10 (10.100.1.10) 1423(1451) bytes of data.ping: sendmsg: Message too long
--- 10.100.1.10 ping statistics ---1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms
```

The 1500-byte packet left the namespace, reached node-0, and node-0 could not send it on. It answered with an ICMP message, `Frag needed and DF set (mtu = 1450)`. The namespace learned the limit from that answer, which is called path MTU discovery. The 1450-byte packet went through, and the 1451-byte one never even left, because the namespace already knew it would not fit.

That answer is the weak point. Some networks and firewalls drop ICMP. Then the big packet disappears without a word, small requests work, and large responses hang. It is one of the classic ways a cluster that passes every quick test still breaks in production.

## Step 7. Fix the MTU for real pods

The pods from Part 1 have the same mismatch. Their side of the cable says 1500.

jumpbox

```
kubectl exec net-0 -- cat /sys/class/net/eth0/mtu
```

```
1500
```

The bridge plugin has an `mtu` setting. Add it on both workers. containerd reads the config for every new pod, so nothing has to restart.

node-0 and node-1

```
# The MTU of the interface that carries traffic to the other machinesDEV=$(ip -o route get $(getent hosts server | awk '{print $1}') | grep -o 'dev [^ ]*' | cut -d' ' -f2)MTU=$(cat /sys/class/net/${DEV}/mtu)sed -i "s/\"isGateway\": true,/\"isGateway\": true,\n      \"mtu\": ${MTU},/" /etc/cni/net.d/10-bridge.conflistgrep -n '"mtu"' /etc/cni/net.d/10-bridge.conflist
```

Existing pods keep their old cable until they are replaced. Replace one and look again.

jumpbox

```
kubectl delete pod net-0 --wait=truekubectl run net-0 --image=busybox:1.37 --restart=Never \  --overrides='{"spec":{"nodeName":"node-0"}}' -- sleep 7200kubectl wait --for=condition=Ready pod/net-0 --timeout=120skubectl exec net-0 -- cat /sys/class/net/eth0/mtukubectl exec net-0 -- ping -c 2 -W 2 $(kubectl get pod net-1 -o jsonpath='{.status.podIP}') | tail -2kubectl rollout restart deployment/nginxkubectl rollout status deployment/nginx --timeout=180s
```

```
pod "net-0" deleted from default namespacepod/net-0 createdpod/net-0 condition met14502 packets transmitted, 2 packets received, 0% packet lossround-trip min/avg/max = 1.027/3.778/6.530 msdeployment.apps/nginx restarteddeployment "nginx" successfully rolled out
```

The new `net-0` has an MTU of 1450 and still reaches `net-1` on the other worker. Its TCP connections now start with a segment size that fits the private link, so they never depend on that ICMP answer. Restarting the `nginx` deployment gives its pods the new setting too.

## Step 8. How a Service reaches a pod

A Service address belongs to no machine. Nothing answers ARP for it, and no interface carries it. Look at the `nginx` Service from Part 1 and the pods behind it.

jumpbox

```
kubectl get service nginxkubectl get endpointslices -l kubernetes.io/service-name=nginx
```

```
NAME    TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGEnginx   ClusterIP   10.32.0.18   <none>        80/TCP    85sNAME          ADDRESSTYPE   PORTS   ENDPOINTS               AGEnginx-g6htr   IPv4          80      10.200.0.6,10.200.1.5   85s
```

kube-proxy on every worker turns that address into rules. In nftables mode it keeps a map from each Service address and port to a chain, and each chain picks a pod.

node-0

```
nft list map ip kube-proxy service-ips | grep 'default/nginx/tcp/'CHAIN=$(nft list map ip kube-proxy service-ips | grep -o 'service-[A-Z0-9]*-default/nginx/tcp/')nft list chain ip kube-proxy "${CHAIN}"
```

```
10.32.0.18 . tcp . 80 : goto service-ECO34HEO-default/nginx/tcp/,table ip kube-proxy {  chain service-ECO34HEO-default/nginx/tcp/ {    meta l4proto tcp dnat ip to numgen random mod 2 map { 0 : 10.200.0.6 . 80, 1 : 10.200.1.5 . 80 }  }}
```

Two rules are the whole Service. The map sends any packet for `10.32.0.18`, TCP, port 80, to the Service’s own chain. That chain picks a random number, 0 or 1, and rewrites the destination to the matching pod. When a pod is added or removed, kube-proxy rewrites that one line.

Call the Service from `net-0`, then ask the kernel on node-0 what it did with the connection. `conntrack` lists the connections the kernel tracks, with the address each packet was sent to and the address the reply really came from.

jumpbox

```
kubectl exec net-0 -- wget -qO- -T 5 http://nginx | grep -m1 -o 'Welcome to nginx!'
```

node-0

```
CLUSTER_IP=$(nft list map ip kube-proxy service-ips | grep 'default/nginx/tcp/' | grep -o '10\.32\.[0-9.]*')conntrack -L -p tcp -d ${CLUSTER_IP} --dport 80 2>/dev/null | tail -1
```

```
tcp      6 119 TIME_WAIT src=10.200.0.5 dst=10.32.0.18 sport=41900 dport=80 src=10.200.0.6 dst=10.200.0.5 sport=80 dport=41900 [ASSURED] mark=0 use=1
```

The first half is what `net-0` (`10.200.0.5` now) sent, a connection to the Service address. The second half is where the reply really came from, the nginx pod at `10.200.0.6`. The pod never saw the Service address, and `net-0` never saw the pod’s. The kernel translated both ways, and that is all a ClusterIP is.

## Step 9. Clean up

The lab served its purpose. Remove it from both workers, and remove the network routes if you added them. The MTU change from step 7 stays, because it is a real fix.

node-0 and node-1

```
nft delete table ip lab-natip route del 10.100.0.0/16 2>/dev/null || ip route del 10.100.$((1 - $(hostname | cut -d- -f2))).0/24ip netns delete labip link delete lab0rm -rf /run/lab-ipam /root/lab-cni.confip -br link | grep -c lab || true
```

hcloud CLI

```
NETWORK=kthwhcloud network remove-route ${NETWORK} --destination 10.100.0.0/24 --gateway 10.240.0.20hcloud network remove-route ${NETWORK} --destination 10.100.1.0/24 --gateway 10.240.0.21
```

jumpbox

```
kubectl delete pod net-0 net-1
```

## When something does not work

- **`Frag needed and DF set (mtu = 1450)` or `Message too long`.** A packet is bigger than a link on its path. Set the bridge plugin’s `mtu` to the smallest link between your machines, as in step 7.
- **Pings to the internet get no answer.** The masquerade rule is missing or does not match the source range. `conntrack -L` shows the reply addressed to the private address instead of the worker’s public one.
- **`conntrack -L` shows `[UNREPLIED]` entries.** Those are attempts that got no answer, often from before you fixed something. They expire on their own after half a minute for ICMP.
- **`nft list` prints `table ip filter is managed by iptables-nft, do not touch!`.** That table belongs to iptables, which some programs still use through its nftables backend. Read it, but change it with `iptables`, not `nft`.

## What real network plugins add

The bridge plugin and a few routes are a complete pod network for a small cluster, and production plugins do the same jobs. What they add is mostly scale and safety.

- **No hand-made routes.** An overlay such as VXLAN wraps each pod packet inside a packet between nodes, so the network in between never has to learn pod addresses. The wrapping costs bytes, 50 for VXLAN over IPv4, which is why overlay pods use a smaller MTU than their nodes.
- **Network policies.** Rules that say which pods may talk to which. The bridge plugin cannot enforce them. Calico and Cilium can.
- **Faster Services.** Cilium can replace kube-proxy with eBPF programs in the kernel that do the same address rewriting you read in step 8.
- **Encryption between nodes**, with WireGuard or IPsec.

## The easy way, once you know the hard way

Clusters Ankra builds with its default kubeadm distribution run Cilium, so the pod network, the routes between nodes and the policies come with the cluster. You choose the private network’s range when you create it.

Terminal window

```
ankra cluster hetzner create --name my-cluster --location hel1 \  --credential-id <your-hetzner-credential> \  --network-ip-range 10.0.0.0/16 \  --control-plane-count 3 --worker-count 2
```

What you learned here is what you need when something in that network misbehaves anyway. A pod that cannot reach another is a namespace, a cable, a route and a rule, and now you know where to look for each one.

This is Part 4 of  [Kubernetes the Hard Way](https://ankra.ai/kubernetes-the-hard-way) , a series of guides we test end to end before publishing.  [Part 5](https://ankra.ai/blog/kubernetes-the-hard-way-certificates)  covers certificates, expiry and renewal.

---

Get started:  [Create a free account on Ankra](https://platform.ankra.app/auth/signup) .

Join our community:  [**Slack**](https://join.slack.com/t/ankra-community/shared_invite/zt-3a5rem8f8-cUho4epX2MoLT83bFf~VSA)

Follow us on:  [LinkedIn](https://www.linkedin.com/company/ankra)  |  [GitHub](https://github.com/ankraio)

Contact us:  [hello@ankra.ai](mailto:hello@ankra.ai)

Share  [X](https://x.com/intent/post?url=https%3A%2F%2Fankra.ai%2Fblog%2Fkubernetes-the-hard-way-networking&text=Kubernetes%20the%20Hard%20Way%2C%20Part%204%3A%20Pod%20Networking%20by%20Hand%2C%20Without%20a%20CNI%20Plugin)  [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fankra.ai%2Fblog%2Fkubernetes-the-hard-way-networking)  [HN](https://news.ycombinator.com/submitlink?u=https%3A%2F%2Fankra.ai%2Fblog%2Fkubernetes-the-hard-way-networking&t=Kubernetes%20the%20Hard%20Way%2C%20Part%204%3A%20Pod%20Networking%20by%20Hand%2C%20Without%20a%20CNI%20Plugin)

### Get the next post in your inbox

Written by

 [Mattias Åsell](https://ankra.ai/blog/author/mattias-asell)

Mattias writes about GitOps, security, and platform architecture - from draft-first AI workflows to the engineering behind Ankra's native deployment engine.

[All posts](https://ankra.ai/blog/author/mattias-asell)  [LinkedIn](https://www.linkedin.com/in/mattiasasell/)

## Related Posts

[Kubernetes](https://ankra.ai/blog/tag/kubernetes)  [Engineering](https://ankra.ai/blog/tag/engineering)  [Homelab](https://ankra.ai/blog/tag/homelab)

[## Kubernetes the Hard Way, Part 3: Back Up etcd and Restore a Lost Control Plane](https://ankra.ai/blog/kubernetes-the-hard-way-backup-restore)

Back up a Kubernetes 1.37 cluster by hand with etcd snapshots, then delete the control plane and restore it. Includes the encryption key trap, all tested.

[Mattias Åsell](https://ankra.ai/blog/author/mattias-asell)

October 25, 2026

12 min read

[Kubernetes](https://ankra.ai/blog/tag/kubernetes)  [Engineering](https://ankra.ai/blog/tag/engineering)  [Homelab](https://ankra.ai/blog/tag/homelab)

[## Kubernetes the Hard Way, Part 5: Certificates, Expiry and Renewal Without Downtime](https://ankra.ai/blog/kubernetes-the-hard-way-certificates)

Read every Kubernetes certificate, watch one expire, and renew them all by hand. Most components reload a renewed certificate with no restart at all. Tested.

[Mattias Åsell](https://ankra.ai/blog/author/mattias-asell)

October 25, 2026

9 min read

[Back to Blog](https://ankra.ai/blog)
