Playbook: vps-h1 stale Docker iptables rules
Trigger
After docker compose down && docker compose up -d on vps-h1, the new Docker network
gets new container IPs. Docker creates correct DNAT rules for the new network, but the
old rules from the previous network are NOT cleaned up. The stale rules take priority and
redirect public ports to dead IPs or wrong containers.
Symptoms:
WAHAProbeDown— HTTPS probe towaha2.vps-h1.infra.zintegrowana.onlinetimes outWAHAContainerDown—container_last_seen{name="waha"}absent (cAdvisor unreachable)PromtailDown— port 9080 scrape timing out from vps-i1N8nWorkflowPersistentlyFailing— WAHA Monitor can’t reach WAHA API
Discovered: 2026-06-25 after PR #1362 deployment recreated vps-h1 compose stack.
Confirm
ssh root@72.60.32.61
# List all DNAT rules with IPs
iptables -t nat -L DOCKER -n -v --line-numbers
# List current container IPs
docker network inspect root_default --format '{{json .Containers}}' | \
python3 -c 'import sys,json; d=json.load(sys.stdin); [print(v["Name"], ":", v["IPv4Address"]) for v in d.values()]'Problem indicator: DNAT rules pointing to IPs (e.g. .7, .8) that don’t appear in
root_default container list. Multiple rules for the same port (old + new network DNAT).
Step-by-step fix
Option A — Restart Docker (preferred for full cleanup, causes ~30s downtime)
ssh root@72.60.32.61
# All containers will stop, Docker cleans iptables, then restores correct rules on start
systemctl restart docker
# After restart, compose stack is NOT auto-started (restart:unless-stopped only applies within a run)
cd /root && docker compose up -d
# Restore WAHA session if needed:
systemctl restart waha-session # starts the session if not auto-startedOption B — Surgical iptables fix (no downtime)
ssh root@72.60.32.61
# 1. Find stale DNAT rules (IPs not in current network)
iptables -t nat -L DOCKER -n -v --line-numbers
docker network inspect root_default --format '{{json .Containers}}' | \
python3 -c 'import sys,json; d=json.load(sys.stdin); [print(v["Name"], ":", v["IPv4Address"]) for v in d.values()]'
# 2. Delete stale rules from highest number to lowest (to preserve rule positions)
# Example: if rules 3 and 4 point to dead IPs
iptables -t nat -D DOCKER 4
iptables -t nat -D DOCKER 3
# 3. Verify correct rules remain
iptables -t nat -L DOCKER -n --line-numbers | grep -E 'dpt:80|dpt:443|dpt:8080|dpt:9080'
# 4. Confirm WAHA reachable
curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
https://waha2.vps-h1.infra.zintegrowana.online/api/server/status
# Expected: 401 (WAHA requires API key — 401 = reachable)Root cause
Docker tracks iptables rules per-network using the bridge interface name
(e.g. br-2530276e6882). When compose recreates with a new network name, Docker adds new
DNAT rules but does NOT flush the old rules — it only removes rules it believes it owns for
that specific bridge, and the old bridge is already gone. The stale rules match before the
correct ones (iptables first-match wins) and send traffic to non-existent container IPs.
Prevention
- Before deploy:
docker compose downcleans up the old network if Docker is still tracking it. If compose was killed or the network was manually removed, stale rules persist. - After any compose recreation on vps-h1: verify
iptables -t nat -L DOCKER -n -vshows no duplicate rules for ports 80, 443, 8080, 9080. - Safest deployment procedure for vps-h1:
cd /root docker compose down # removes containers + network + iptables rules git pull # get latest config docker compose up -d # creates fresh network + correct iptables systemctl restart waha-session # ensure WAHA session is started
Escalation
If surgical rule deletion doesn’t fix connectivity:
- Check if the correct DNAT rules were re-added by Docker
- Check
iptables -t nat -L POSTROUTING -nfor MASQUERADE rules - If still broken, restart Docker (Option A) — this guarantees clean iptables state
- If Docker restart fails, check
journalctl -u docker --since -10mfor errors
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="config_change",
resource="vps-h1-docker",
result="success", # "success" | "failed" | "skipped"
detail="Stale Docker iptables rules flushed on vps-h1",
env="vps-h1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'config_change', 'vps-h1-docker', 'success', 'Stale Docker iptables rules flushed on vps-h1', 'vps-h1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''