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

25 min read

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.

How to read this guide (start here)

Every code block has a colored label. Do only what the label says.

Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.

Suggested path: symptoms → diagnosis → apply daemon/bridge MTU → verify with ping/MSS checks.

1. What MTU is and why Docker makes it worse

What this part is for: cover: 1. What MTU is and why Docker makes it worse.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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

What this part is for: understand: 2. Common symptoms — when MTU is your problem.
What you should do: read this section unless a labeled block says otherwise.

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

3. Diagnosis commands — find the real MTU

What this part is for: cover: 3. Diagnosis commands — find the real MTU.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Don't guess. Run these before changing anything.

3.1 Check host interface MTU

RUN — paste into a shell on the host this section describes.
# 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

Sample output — ip link show eth0 (excerpt)

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff

Meaning: host Ethernet MTU is 1500. If this host reaches the internet only through WireGuard/VPN with MTU 1420, Docker’s default bridge at 1500 will cause black-hole TCP (SYN works, large transfers stall). Lower Docker network MTU to match the path (often 1420 or 1400), then recreate networks/containers.

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

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

RUN — paste into a shell on the host this section describes.
# 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

Sample output — ping -M do PMTU boundary test

From 10.0.0.1 icmp_seq=1 Frag needed and DF set (mtu = 1420)
3 packets transmitted, 0 received, +1 errors, 100% packet loss

64 bytes from 8.8.8.8: icmp_seq=1 ttl=116 time=12.4 ms
3 packets transmitted, 3 received, 0% packet loss

Meaning: the 1472-byte payload (1500 total) exceeded a reported 1420 MTU, while 1372 (1400 total) passed. Increase from the working size to find the exact boundary; total PMTU is largest successful payload + 28. Total loss without “Frag needed” may instead be ICMP filtering.

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

RUN — paste into a shell on the host this section describes.
# 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"

Sample output — container tracepath and DF ping (excerpt)

1?: [LOCALHOST]                      pmtu 1420
1:  172.17.0.1                       0.091ms
3 packets transmitted, 3 received, 0% packet loss

Meaning: container PMTU is 1420 and the 1400-byte packet passes. A first-hop PMTU of 1500 when the host path is 1420 exposes the mismatch; package/DNS errors mean the diagnostic container never reached the network test.

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

RUN — paste into a shell on the host this section describes.
# 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

Sample output — tracepath 8.8.8.8 (excerpt)

1?: [LOCALHOST]                      pmtu 1500
1:  10.0.0.1                         0.412ms
2:  10.200.0.1                       7.803ms pmtu 1420
3:  8.8.8.8                         12.194ms reached

Meaning: the local interface starts at 1500, hop 2 lowers PMTU to 1420, and the destination is reachable. The lowest reported PMTU—not the first line—is the budget. Missing PMTU changes can occur when routers suppress ICMP; confirm with DF ping.

3.4 Inspect Docker network MTU

RUN — paste into a shell on the host this section describes.
# 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

Sample output — docker network inspect myapp_web (MTU line)

"Options": {
    "com.docker.network.driver.mtu": "1420"
}

Meaning: this user-defined network was created with MTU 1420 (matches a typical WireGuard path). Empty Options or missing the mtu key usually means Docker used the daemon default (often 1500). After changing daemon.json or Compose driver_opts, recreate the network — existing networks keep their old MTU.

3.5 Compare host vs container

RUN — paste into a shell on the host this section describes.
# 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

Sample output — host versus container tracepath

host:      1?: [LOCALHOST] pmtu 1420
container: 1?: [LOCALHOST] pmtu 1500

Meaning: the host knows the 1420 path but the container network advertises 1500, so TCP can negotiate oversized segments. If both show 1420, investigate routing, firewall ICMP, or application timeouts instead of lowering MTU again.

4. Docker bridge network MTU (driver_opts)

What this part is for: cover: 4. Docker bridge network MTU (driver_opts).
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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

COPY FILE — add to your docker-compose.yml (edit MTU/IPs for your environment).
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)

COPY FILE — add to your docker-compose.yml (edit MTU/IPs for your environment).
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

Swarm prerequisite: driver: overlay in Compose requires Swarm mode (docker swarm init). Plain docker compose up on a single host cannot create overlay networks — use bridge + driver_opts instead.
COPY FILE — add to your docker-compose.yml (edit MTU/IPs for your environment).
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:

RUN — paste into a shell on the host this section describes.
docker compose down
docker network rm myapp_web myapp_backend   # if not removed by down
docker compose up -d

These lifecycle mutations are normally quiet apart from Compose progress; success means exit code 0 and no stderr. “Network has active endpoints” means a container still uses it, while “not found” is harmless only when it was already removed.

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

What this part is for: cover: 5. Global default MTU — /etc/docker/daemon.json.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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
COPY FILE — save as /etc/docker/daemon.json (edit passwords/IPs first).
# /etc/docker/daemon.json
{
  "mtu": 1420,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
RUN — paste into a shell on the host this section describes.
# 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

Sample output — Docker restart MTU verification

3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1420 qdisc noqueue state DOWN
1420

Meaning: Linux docker0 and Docker’s bridge option both report 1420; state DOWN is normal with no attached container. If either remains 1500, daemon JSON was not loaded or an existing network retained its old option.

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

What this part is for: cover: 6. TCP MSS — the formula that actually fixes TLS slowness.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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.

RUN — paste into a shell on the host this section describes.
sudo sysctl -w net.ipv4.tcp_mtu_probing=1

Sample output — sysctl -w net.ipv4.tcp_mtu_probing=1

net.ipv4.tcp_mtu_probing = 1

Meaning: TCP black-hole probing is enabled for this boot. Permission denied means sudo/capability failure; “cannot stat” means no kernel key. This is a safety net, not a substitute for correct network MTU.

READ — formula or worked example. Do not execute as a script.
net.ipv4.tcp_mtu_probing = 1

7. Overlay / Swarm MTU considerations

What this part is for: cover: 7. Overlay / Swarm MTU considerations.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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.
RUN — paste into a shell on the host this section describes.
# Create overlay manually with correct MTU
docker network create \
  --driver overlay \
  --opt com.docker.network.driver.mtu=1370 \
  --attachable \
  myoverlay

Successful creation prints a network ID and exits 0. “Not a swarm manager” means overlay creation cannot proceed here; “already exists” means inspect/recreate it because MTU options do not change in place.

8. macvlan and host network modes

What this part is for: cover: 8. macvlan and host network modes.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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

COPY FILE — add to your docker-compose.yml (edit MTU/IPs for your environment).
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

What this part is for: get a ready template for: 9. Worked examples — full calculations.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.

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

What this part is for: cover: 10. Master defaults table — quick lookup.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — diagram or folder layout. Do not paste into a terminal.
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

What this part is for: cover: 11. Troubleshooting — symptom → cause → fix.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — diagram or folder layout. Do not paste into a terminal.
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

What this part is for: complete a working setup for: 12. Practical walkthrough — fix MTU on a live host.
What you should do: follow COPY → RUN → SQL labels in order.

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

Pre-flight: Back up /etc/docker/daemon.json, schedule a maintenance window (systemctl restart docker recreates connections), document current network MTUs with docker network inspect, and plan compose down — connected containers will restart.

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

RUN — paste into a shell on the host this section describes.
tracepath -n 8.8.8.8 | head -3
ip link show | grep -E '^[0-9]+:|mtu'

Sample output — live-host path and interface MTU survey

1?: [LOCALHOST] pmtu 1420
1:  10.200.0.1  7.8ms
2:  8.8.8.8     12.2ms reached
2: eth0: <UP,LOWER_UP> mtu 1500
7: wg0: <POINTOPOINT,UP,LOWER_UP> mtu 1420

Meaning: the route is 1420 even though physical Ethernet is 1500; WireGuard is the bottleneck. No route/reached line indicates routing trouble. Choose the lowest MTU on the actual path, not every interface shown.

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

Step 2 — Check what Docker networks actually use

RUN — paste into a shell on the host this section describes.
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

Sample output — current Docker network and container MTU

NETWORK ID     NAME          DRIVER    SCOPE
7d81c58a1d31   myapp_web     bridge    local
1500
2: eth0@if44: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500

Meaning: network and container are 1500, mismatching the 1420 path. Blank format output can mean inherited options; trust container eth0. Missing-network errors mean use the deployed Compose project name.

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

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

Do not blind tee: overwriting /etc/docker/daemon.json wipes existing keys (log-driver, insecure-registries, etc.). Back up and merge.
RUN — merge MTU into existing daemon.json (maintenance window).
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak.$(date +%F) 2>/dev/null || true
# If file missing, start with {}
echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null
sudo jq '. + {"mtu": 1420}' /etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.new
sudo python3 -m json.tool /etc/docker/daemon.json.new >/dev/null
sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json
sudo systemctl restart docker
ip link show docker0 | grep mtu

Sample output — daemon JSON validation and docker0 after restart

{
    "mtu": 1420
}
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1420 qdisc noqueue state DOWN

Meaning: validation retained numeric MTU and Docker recreated its bridge at 1420. A parse error must stop the move/restart; service failure means inspect Docker journal and restore the backup.

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

Add to every network in your compose file:

COPY FILE — add to your docker-compose.yml (edit MTU/IPs for your environment).
networks:
  web:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1420"

Step 5 — Recreate networks (required)

RUN — paste into a shell on the host this section describes.
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

RUN — paste into a shell on the host this section describes.
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"

Sample output — in-container MTU, tracepath, and maximum DF ping

2: eth0@if51: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1420
1?: [LOCALHOST] pmtu 1420
3 packets transmitted, 3 received, 0% packet loss

Meaning: container interface and discovered path agree at 1420, and a 1392-byte payload plus 28-byte headers passes unfragmented. A 1500 interface means recreation failed; “Frag needed” at 1392 means the true path is below 1420.

Step 7 — Test real workloads, not just ping

RUN — paste into a shell on the host this section describes.
curl -v https://example.com          # TLS handshake uses larger packets
docker pull nginx:alpine             # registry download
# Your actual slow endpoint / file upload

Sample output — HTTPS and image-pull workload verification (excerpt)

< HTTP/2 200
< content-type: text/html
Status: Downloaded newer image for nginx:alpine

Meaning: TLS completed with a real response and a multi-layer registry transfer finished, exercising packets larger than basic ping. TLS stalls, resets, or pull retries after PMTU checks pass point to proxy/DNS/registry issues or a different route used by that workload.

13. Production checklist

What this part is for: cover: 13. Production checklist.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
  1. Measure path MTU on the host — tracepath 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 container — ip 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 transfers — docker 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

What this part is for: cover: 14. Wrapping up.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

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.