Docker Network MTU Tuning: Defaults, Diagnosis, and Production Configuration

Published

No hand-waving. Every MTU setting: what it does, the default if you omit it, what breaks when it's wrong, and exactly how to calculate the right value — with real numbers.

MTU problems are maddening because everything looks fine. Ping works. SSH connects. DNS resolves. Then HTTPS hangs on large pages, Docker pulls stall halfway through, and your monitoring shows nothing because small packets still get through.

This guide walks through Docker networking MTU from first principles to production. For every important setting you'll get four things:

  1. What it does — in plain language
  2. Default value — what Docker uses if you write nothing
  3. What goes wrong — if you leave the default or set it wrong
  4. How to calculate — step-by-step with real numbers

We'll use a concrete reference server throughout:

Example server (our reference case):
Ubuntu 22.04 host · Docker 27.x · WireGuard VPN to office (1420 MTU) · 3 bridge networks · Swarm overlay for one cluster · Nginx + API + Postgres stack

Adjust the numbers for your environment. The method stays the same.

1. What MTU is and why Docker makes it worse

MTU (Maximum Transmission Unit) is the largest IP packet size a network interface can send without fragmentation. Standard Ethernet is 1500 bytes. Every hop along the path must support at least that size — or packets get fragmented or silently dropped.

Docker adds encapsulation overhead. A container on a bridge network doesn't talk directly on the host NIC. Traffic goes through a virtual bridge (docker0 or a custom bridge), often through iptables/NAT, and sometimes through an overlay tunnel (VXLAN adds 50 bytes). Each layer eats into your effective MTU budget.

Reference scenarios — know your path MTU before tuning Docker:

Standard Ethernet (office LAN, bare metal): 1500 MTU end-to-end. Docker bridge default 1500 usually works.

Cloud VPC (AWS, GCP, Azure, Hetzner): 1500 MTU on the host NIC. Same as Ethernet — but check if you're behind a VPN or transit gateway that lowers it.

WireGuard VPN (typical config): Interface MTU 1420. Path MTU to remote peers is ≤ 1420. Docker bridge at 1500 causes black-hole behavior for large packets.
Effective path MTU = minimum MTU across every hop (host NIC → VPN → cloud gateway → remote peer)
MSS (Max Segment Size) for IPv4 TCP = MTU − 40   (20 byte IP header + 20 byte TCP header)
Host on WireGuard (1420 MTU), Docker bridge at default 1500:
Container sends 1500-byte packet → exceeds WireGuard tunnel → dropped silently
TCP MSS should be: 1420 − 40 = 1380
Docker bridge MTU should be: 1420 (match the narrowest hop)

2. Common symptoms — when MTU is your problem

These patterns scream MTU mismatch long before you find the root cause:

3. Diagnosis commands — find the real MTU

Don't guess. Run these before changing anything.

3.1 Check host interface MTU

# All interfaces and their MTU
ip link show

# Specific interface (e.g. WireGuard)
ip link show wg0 | grep mtu

# Example output:
# 3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue

3.2 Path MTU discovery with ping (don't fragment)

On the host, Linux ping supports the DF (Don't Fragment) bit:

# Linux host: ping with DF bit set (won't fragment)
ping -M do -s 1472 8.8.8.8        # 1472 + 28 headers = 1500 total
ping -M do -s 1372 8.8.8.8        # 1372 + 28 = 1400

# Increase -s until you get "Frag needed" or 100% loss
# Last working size + 28 = your path MTU

From inside a container: Alpine's default busybox ping does not support -M do on all versions. Install iputils or use tracepath instead:

# Recommended: tracepath (works on Alpine without extra flags)
docker run --rm alpine sh -c "apk add -q iputils && tracepath -n 8.8.8.8 | head -3"

# Alternative: iputils ping with DF bit
docker run --rm alpine sh -c "apk add -q iputils && ping -M do -s 1372 -c 3 8.8.8.8"
Don't trust container ping alone. Always compare host tracepath vs container tracepath to the same destination. If they differ, Docker network MTU is wrong.

3.3 tracepath — automatic PMTU discovery

# Shows hop-by-hop and estimated path MTU
tracepath 8.8.8.8

# From container (install iputils if needed)
docker run --rm alpine sh -c "apk add iputils && tracepath 8.8.8.8"

# Example output:
#  1:  172.18.0.1                                         0.1ms pmtu 1500
#  1:  172.18.0.1                                         0.2ms pmtu 1420  ← VPN lowered it

3.4 Inspect Docker network MTU

# List networks
docker network ls

# Check MTU on a specific network
docker network inspect bridge --format '{{json .Options}}'
docker network inspect myapp_web --format '{{range $k,$v := .Options}}{{$k}}={{$v}} {{end}}'

# Full inspect (look for "com.docker.network.driver.mtu" in Options)
docker network inspect myapp_web | grep -i mtu

# Check docker0 bridge MTU on host
ip link show docker0 | grep mtu

3.5 Compare host vs container

# Host path MTU
tracepath -n 1.1.1.1 | head -1

# Container path MTU (same destination)
docker run --rm --network myapp_web alpine sh -c \
  "apk add -q iputils && tracepath -n 1.1.1.1 | head -1"

# If host shows pmtu 1420 but container shows 1500 → fix Docker network MTU

4. Docker bridge network MTU (driver_opts)

Bridge networks are the default for docker compose stacks. Each user-defined bridge can have its own MTU via driver_opts.

networks.*.driver_opts.mtu (docker-compose)

What it does: Sets the MTU on the Linux bridge interface Docker creates for this network (e.g. br-abc123). All containers attached to this network inherit this MTU on their eth0.

Default if omitted: Docker copies the host default-route interface MTU (usually 1500). On some hosts this matches reality; on VPN/PPPoE hosts it does not.

If you leave default: On a host behind WireGuard (1420) or PPPoE (1492), containers send 1500-byte packets that exceed the real path. Large TCP transfers hang; small requests work fine.

How to calculate — step by step:

  1. Find path MTU from host: tracepath 8.8.8.8 → e.g. 1420
  2. Subtract overlay overhead if using Swarm overlay on same path: VXLAN = 50 bytes
  3. For plain bridge (no overlay): Docker MTU = path MTU → 1420
  4. For overlay network: overlay MTU = path MTU − 50 → 1420 − 50 = 1370
bridge MTU = path MTU (match the bottleneck)
overlay MTU = path MTU − 50 (VXLAN encapsulation)
WireGuard host, path MTU 1420, bridge network → mtu: 1420
Same host, Swarm overlay → mtu: 1370
Standard cloud VPS, path MTU 1500 → omit driver_opts (default is fine)

4.1 Complete docker-compose.yml — WireGuard host example

services:
  nginx:
    image: nginx:1.27-alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    networks:
      - web
      - backend
    depends_on:
      - api

  api:
    image: myapp/api:latest
    container_name: api
    restart: unless-stopped
    networks:
      - backend
      - db

  postgres:
    image: postgres:16-alpine
    container_name: postgres
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - db

networks:
  web:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1420"

  backend:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1420"

  db:
    driver: bridge
    internal: true
    driver_opts:
      com.docker.network.driver.mtu: "1420"

volumes:
  pgdata:

4.2 docker-compose.yml — standard cloud (1500, explicit for documentation)

networks:
  web:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1500"

  backend:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1500"

4.3 docker-compose.yml — Swarm overlay

networks:
  web:
    driver: overlay
    driver_opts:
      com.docker.network.driver.mtu: "1370"
    attachable: true

  backend:
    driver: overlay
    driver_opts:
      com.docker.network.driver.mtu: "1370"
    internal: true

Important: Changing MTU in compose only applies when the network is created. Existing networks keep their old MTU. You must recreate:

docker compose down
docker network rm myapp_web myapp_backend   # if not removed by down
docker compose up -d

5. Global default MTU — /etc/docker/daemon.json

mtu (daemon.json)

What it does: Sets MTU on docker0 and on the built-in bridge network. For new user-defined networks created without an explicit MTU option, Docker may use this value as the default — but do not rely on it for Compose stacks.

Default if omitted: 1500

What it does not do: It does not retroactively change existing networks. It does not replace per-network driver_opts in Compose. If your stack defines networks: web: driver: bridge without driver_opts.mtu, behavior depends on Engine version — safest practice is to set MTU explicitly on every Compose network.

When daemon.json mtu is enough: Ad-hoc docker run on default bridge, quick tests, and fixing docker0 itself.

When you still need driver_opts: Every production Compose network — especially multi-network stacks, internal DB networks, and overlay/Swarm.

daemon.json mtu → docker0 + default bridge fallback
Compose driver_opts mtu → the network your containers actually use
# /etc/docker/daemon.json
{
  "mtu": 1420,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
# Apply changes
sudo systemctl restart docker

# Verify docker0 MTU after restart
ip link show docker0 | grep mtu
# Expected: mtu 1420

# Verify default bridge network
docker network inspect bridge --format '{{index .Options "com.docker.network.driver.mtu"}}'
# Expected: 1420

Caution: Restarting Docker stops all running containers unless you use live-restore. Plan a maintenance window.

6. TCP MSS — the formula that actually fixes TLS slowness

MSS (Maximum Segment Size)

What it does: Limits the largest TCP payload in a single segment. When MSS × segments would exceed path MTU, packets fragment or drop. Correct MSS prevents black holes.

Default if omitted: Derived from interface MTU at connection time — usually 1460 for 1500 MTU (1500 − 40)

If you leave default: Container thinks MSS is 1460 (1500 − 40) but path only supports 1420. First large segment gets dropped. TCP backs off, retries with smaller windows — looks like slow TLS or random timeouts.

How to calculate:

MSS = MTU − 40   (IPv4 TCP: 20 byte IP + 20 byte TCP header)
MSS = MTU − 60   (IPv6 TCP: 40 byte IP + 20 byte TCP header)
Docker bridge MTU 1420, IPv4:
MSS = 1420 − 40 = 1380

Docker overlay MTU 1370, IPv4:
MSS = 1370 − 40 = 1330

Standard 1500 Ethernet, IPv4:
MSS = 1500 − 40 = 1460 (default, no tuning needed)

Linux usually clamps MSS automatically via route MTU (tcp_mtu_probing). But when Docker bridge MTU is wrong, the kernel's MSS clamping is also wrong. Fix the interface MTU first — MSS follows.

# Enable TCP MTU probing as safety net (host)
sudo sysctl -w net.ipv4.tcp_mtu_probing=1

# Persist in /etc/sysctl.d/99-docker-mtu.conf
net.ipv4.tcp_mtu_probing = 1

7. Overlay / Swarm MTU considerations

Swarm overlay networks encapsulate container traffic in VXLAN (UDP port 4789). VXLAN adds 50 bytes of overhead per packet.

overlay network MTU

What it does: Sets MTU on the overlay's virtual interface inside each container. Must account for VXLAN wrapper when the packet hits the physical NIC.

Default if omitted: 1500 (same problem — ignores VXLAN overhead)

If you leave default: Effective packet size on wire = 1500 + 50 = 1550 bytes. Exceeds 1500 path → dropped. Multi-node Swarm clusters over VPN are especially vulnerable.

How to calculate:

  1. Path MTU on host NIC: 1420 (WireGuard example)
  2. VXLAN overhead: 50
  3. Overlay MTU = 1420 − 50 = 1370
  4. Overlay MSS (IPv4) = 1370 − 40 = 1330
overlay MTU = path MTU − 50
Swarm checklist: Set the same overlay MTU on every node in the cluster. Mixed MTU across nodes causes asymmetric routing failures that are hard to debug. After changing, recreate overlay networks: docker network rm then redeploy stack.
# Create overlay manually with correct MTU
docker network create \
  --driver overlay \
  --opt com.docker.network.driver.mtu=1370 \
  --attachable \
  myoverlay

8. macvlan and host network modes

network_mode: host

What it does: Container shares the host's network stack directly — no bridge, no NAT. Container uses host NIC MTU as-is.

Default if omitted: N/A — you explicitly choose host mode

If you use host mode: No Docker bridge MTU to tune. Fix MTU on the host interface (wg0, eth0) instead. Container inherits it automatically.

How to calculate: Set host interface MTU = path MTU. No docker-compose network block needed.

WireGuard host: ip link set wg0 mtu 1420
Container with network_mode: host automatically gets 1420 MTU

macvlan / ipvlan

What it does: Gives containers a MAC address on the physical LAN — they appear as separate devices on your network.

Default if omitted: Inherits parent interface MTU (usually 1500)

If you leave default: Same black-hole issues if parent is on VPN with lower MTU. macvlan sub-interface doesn't auto-adjust.

How to calculate: macvlan MTU = parent interface MTU = path MTU

networks:
  macvlan_net:
    driver: macvlan
    driver_opts:
      parent: eth0
      com.docker.network.driver.mtu: "1420"
    ipam:
      config:
        - subnet: 192.168.1.0/24
          gateway: 192.168.1.1

9. Worked examples — full calculations

Example A: WireGuard VPN host, bridge compose stack

Input: Host on WireGuard, tracepath shows pmtu 1420 · Bridge networks only · No Swarm

Step 1 — Path MTU: 1420 (WireGuard tunnel)
Step 2 — Bridge MTU: 1420 (match path, no overlay)
Step 3 — MSS (IPv4): 1420 − 40 = 1380
Step 4 — daemon.json: "mtu": 1420
Step 5 — compose driver_opts: com.docker.network.driver.mtu: "1420" on every network

Verify:
docker run --rm --network myapp_web alpine ip link show eth0 | grep mtu → mtu 1420
ping -M do -s 1392 8.8.8.8 from container → 1392 + 28 = 1420, should succeed

Example B: Cloud VPS (1500), no VPN

Input: Hetzner/AWS VPS · tracepath shows pmtu 1500 · Standard bridge stack

Bridge MTU: 1500 (default — no change needed)
MSS: 1500 − 40 = 1460
Action: Nothing for plain bridge networks.

If you run Swarm overlay on this host: path MTU 1500 − VXLAN 50 = overlay MTU 1450 (not 1500). AWS ECS and many cloud Swarm/K8s CNI setups use 1450 for this reason.

Only tune bridge MTU if you later add WireGuard or site-to-site VPN — then recalculate.

Example C: Swarm cluster over WireGuard

Input: 3-node Swarm · All nodes on WireGuard · Path MTU 1420 · Overlay networks

Step 1 — Path MTU: 1420
Step 2 — VXLAN overhead: 50 bytes
Step 3 — Overlay MTU: 1420 − 50 = 1370
Step 4 — Overlay MSS: 1370 − 40 = 1330
Step 5 — daemon.json on every node: "mtu": 1420 (for bridge/default)
Step 6 — Stack overlay networks: com.docker.network.driver.mtu: "1370"

Verify from container on overlay:
tracepath 8.8.8.8 → first hop pmtu should show 1370

Example D: PPPoE home connection (1492)

Input: Home server · ISP PPPoE · Host NIC MTU 1492

Path MTU: 1492
Bridge MTU: 1492
MSS: 1492 − 40 = 1452
daemon.json: "mtu": 1492

10. Master defaults table — quick lookup

Setting                              │ Default (if omitted) │ WireGuard example │ Cloud example
─────────────────────────────────────┼──────────────────────┼───────────────────┼────────────────
daemon.json mtu                      │ 1500                 │ 1420              │ 1500 (omit)
bridge driver_opts mtu               │ 1500                 │ 1420              │ 1500 (omit)
overlay driver_opts mtu              │ 1500 (wrong for VXLAN)│ 1370              │ 1450 (1500−50)
docker0 bridge MTU                   │ 1500                 │ 1420              │ 1500
container eth0 MTU (bridge)          │ inherits network     │ 1420              │ 1500
IPv4 TCP MSS                         │ MTU − 40             │ 1380              │ 1460
VXLAN overhead (overlay)             │ not accounted for    │ subtract 50       │ subtract 50
host network_mode                    │ uses host NIC MTU    │ 1420              │ 1500

11. Troubleshooting — symptom → cause → fix

Symptom                              │ Likely cause                         │ Fix
─────────────────────────────────────┼──────────────────────────────────────┼──────────────────────────────
SSH works, HTTPS hangs               │ MTU black hole on large packets      │ Lower Docker network MTU to path MTU
Slow TLS handshake                   │ TCP retries on oversized segments    │ Fix bridge MTU; enable tcp_mtu_probing
Small API calls OK, uploads fail     │ Payload exceeds path MTU             │ tracepath from container; set driver_opts
ping works, ping -s 1472 fails       │ Path MTU < 1500                      │ Match Docker MTU to tracepath result
Works on host, broken in container   │ Bridge MTU 1500, host path lower     │ driver_opts mtu + recreate network
Swarm service unreachable cross-node │ Overlay MTU ignores VXLAN overhead   │ overlay MTU = path MTU − 50
Fixed MTU but still broken           │ Old network not recreated            │ docker compose down; docker network rm; up
docker pull stalls mid-download      │ Registry traffic hits MTU limit      │ Fix daemon.json mtu; restart docker
Intermittent DB replication lag      │ Large WAL packets dropped            │ Set db network MTU; verify with tracepath
Changes to daemon.json no effect     │ Didn't restart Docker                │ systemctl restart docker; verify docker0

12. Practical walkthrough — fix MTU on a live host

Use this sequence when symptoms match Section 2. Do not change MTU blindly.

Step 1 — Confirm path MTU on the host (before Docker)

tracepath -n 8.8.8.8 | head -3
ip link show | grep -E '^[0-9]+:|mtu'

Write down the lowest pmtu reported. Example: WireGuard → 1420. Plain cloud VPS → 1500.

Step 2 — Check what Docker networks actually use

docker network ls
docker network inspect myapp_web --format '{{index .Options "com.docker.network.driver.mtu"}}'
docker run --rm --network myapp_web alpine ip link show eth0 | grep mtu

If container eth0 shows 1500 but host path is 1420 → you found the bug.

Step 3 — Set daemon.json (docker0 + safety net)

sudo tee /etc/docker/daemon.json <<'EOF'
{
  "mtu": 1420
}
EOF
sudo systemctl restart docker
ip link show docker0 | grep mtu

Step 4 — Set Compose driver_opts (the real fix for stacks)

Add to every network in your compose file:

networks:
  web:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1420"

Step 5 — Recreate networks (required)

docker compose down
docker network rm myapp_web myapp_backend 2>/dev/null || true
docker compose up -d

MTU is applied at network creation. Editing compose without recreating the network does nothing.

Step 6 — Verify from inside the app container

docker exec api ip link show eth0 | grep mtu
docker exec api sh -c "apk add -q iputils && tracepath -n 1.1.1.1 | head -2"

# Payload test: (path_mtu - 28) for ping headers
# 1420 path → ping -s 1392
docker exec api sh -c "apk add -q iputils && ping -M do -s 1392 -c 3 8.8.8.8"

Step 7 — Test real workloads, not just ping

curl -v https://example.com          # TLS handshake uses larger packets
docker pull nginx:alpine             # registry download
# Your actual slow endpoint / file upload

13. Production checklist

  1. Measure path MTU on the hosttracepath 8.8.8.8 or ping -M do -s sweep before touching Docker config.
  2. Document your scenario — Standard Ethernet (1500), cloud (1500), WireGuard (1420), PPPoE (1492). Write it down in your runbook.
  3. Set daemon.json mtu — Fixes docker0 and ad-hoc containers. Not a substitute for Compose driver_opts.
  4. Set driver_opts on every compose network — Explicit MTU per network. For overlay: path MTU − 50 (cloud 1500 → overlay 1450).
  5. Recreate networks after changes — MTU is set at network creation time, not live-updated.
  6. Verify from inside a containerip link show eth0, tracepath, ping -M do -s with calculated payload size.
  7. Enable tcp_mtu_probing — Safety net on hosts with variable paths (mobile VPN, multi-WAN).
  8. Same MTU on all Swarm nodes — Mixed configs cause asymmetric failures.
  9. Test large transfersdocker pull, HTTPS page load, file upload — not just ping.
  10. Monitor after VPN changes — Adding WireGuard or changing tunnel config changes path MTU. Recalculate.

14. Wrapping up

MTU issues hide behind "the network seems fine." The defaults assume 1500-byte Ethernet everywhere — which breaks the moment you add a VPN, PPPoE, or VXLAN overlay.

The fix is mechanical: find your path MTU on the host, subtract overlay overhead if any, set that number in driver_opts on every Compose network (and in daemon.json for docker0), recreate networks, verify from inside a container. MSS = MTU − 40 for IPv4 TCP. That's the whole formula.

Start with tracepath on the host. If it shows anything below 1500, your Docker networks need to match — or you'll keep chasing ghosts in application logs.