Docker VoIP Stack: Boot Sequencing, Health Checks, and Dependency Management
← All ArticlesHow to correctly order Docker container startup for a Kamailio + Asterisk + A2Billing stack using health checks, and bridging VLAN-isolated IP phones via a Python SIP relay.
The Boot Sequencing Problem
The default Docker depends_on ordering is not sufficient for VoIP stacks — service_started ≠ service_ready.
A VoIP stack has strict startup dependencies. If Kamailio starts routing before Asterisk is ready to accept SIP connections, phones that send REGISTER at boot will get rejected and enter an exponential backoff timer — some phones won't retry for 30–60 minutes. Getting the boot order right matters enormously.
The failure mode is subtle: all containers show as "running" in Docker, but phones show as "unregistered" after a reboot. The root cause is that depends_on: service_started (the default) only waits for the container process to start — not for the application inside to become ready to serve requests.
Correct Startup Order
mysql (wts-db) │ service_healthy (~15s — mysqladmin ping) ▼ asterisk-1 + asterisk-2 + a2billing + mysql-exporter │ asterisk service_healthy (~45s — asterisk -rx 'core show version') ▼ kamailio ← KEY FIX: was service_started, now service_healthy │ kamailio service_healthy (~30s — kamailio -c validates config) ▼ sip-relay (host process) ← waits for Kamailio healthy, then binds port 5060 │ (~5s after relay binds) ▼ IP Phones register ← REGISTER → relay → Kamailio → Asterisk → 200 OK
The fix: Change kamailio depends_on asterisk condition: service_started to condition: service_healthy. Without this, Kamailio receives REGISTER requests before Asterisk is ready and returns 503 — the phone enters backoff and stays unregistered.
docker-compose.yml Health Checks
services:
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root",
"-p$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 5
asterisk:
image: asterisk:20
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "asterisk", "-rx", "core show version"]
interval: 30s
timeout: 10s
retries: 5
kamailio:
image: kamailio:5
depends_on:
asterisk:
condition: service_healthy # CRITICAL — not service_started
healthcheck:
test: ["CMD", "kamailio", "-c"] # Validates config without running
interval: 30s
timeout: 10s
retries: 3
a2billing:
image: a2billing:latest
depends_on:
asterisk:
condition: service_healthy
healthcheck:
test: ["CMD", "php", "-r",
"require '/etc/a2billing/a2billing.conf'; echo 'ok';"]
interval: 30s
timeout: 10s
retries: 3Health Check Reference
| Container | Health Check Command | Interval | Retries |
|---|---|---|---|
| mysql | mysqladmin ping | 10s | 5 |
| asterisk | asterisk -rx 'core show version' | 30s | 5 |
| asterisk-2 | asterisk -rx 'core show version' | 30s | 5 |
| kamailio | kamailio -c | 30s | 3 |
| a2billing | php parse a2billing.conf | 30s | 3 |
SIP Relay — Bridging VLAN-Isolated Phones to Docker
IP phones on a voice VLAN cannot directly reach Docker containers. A Python SIP relay bridges the gap.
When Asterisk runs inside Docker, it listens on the Docker bridge network — not on the host's VLAN interface. IP phones on VLAN 10 (voice subnet) can reach the host's VLAN 10 IP, but not the container. A lightweight Python relay process on the host forwards SIP messages bidirectionally:
Phone (voice-VLAN IP:5060) │ TCP/UDP sip-relay.py (voice-gateway-ip:5060) ← host process — visible to phones │ TCP/UDP Asterisk container (127.0.0.1:5065) ← published Docker port │ Asterisk:5060 (inside container)
SIP Relay Implementation
import socket, threading, time
LISTEN_IP = '10.10.0.1' # Host's voice VLAN interface
LISTEN_PORT = 5060
TARGET_IP = '127.0.0.1' # Docker host port
TARGET_PORT = 5065 # Asterisk published port
SESSION_TTL = 300 # Seconds before idle UDP session expires
def handle_udp():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((LISTEN_IP, LISTEN_PORT))
sessions = {} # src_addr -> last_seen
upstream = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
data, addr = sock.recvfrom(4096)
sessions[addr] = time.time()
upstream.sendto(data, (TARGET_IP, TARGET_PORT))
# Return path handled by upstream receive thread
The relay handles both UDP and TCP SIP transport. UDP sessions are keyed by source address (phone IP:port) — one upstream socket per phone. TCP uses bidirectional pipe threads. Session TTL expires idle UDP mappings after 300 seconds.
LaunchDaemon Wait Script (macOS)
On macOS, LaunchDaemons fire at boot — before Docker Desktop is running. A wait script prevents the relay from starting too early.
#!/bin/bash
# sip-relay-wait.sh — waits for VLAN interface, Docker, and Kamailio before binding
# 1. Wait for voice VLAN interface to exist
until ifconfig vlan10 &>/dev/null; do sleep 2; done
# 2. Wait for Docker Desktop to be responding
until docker info &>/dev/null 2>&1; do sleep 5; done
# 3. Wait for Kamailio container to be healthy
until [ "$(docker inspect --format='{{.State.Health.Status}}' kamailio 2>/dev/null)" = "healthy" ]; do
sleep 5
done
# 4. All dependencies ready — exec the relay (replaces this shell)
exec python3 /path/to/sip-relay.py<!-- /Library/LaunchDaemons/com.voip.sip-relay.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.voip.sip-relay</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/path/to/sip-relay-wait.sh</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/var/log/sip-relay.log</string>
<key>StandardErrorPath</key><string>/var/log/sip-relay.log</string>
</dict>
</plist>Expected Boot Timeline
| Milestone | Approximate Time (from system login) |
|---|---|
| IP forwarding set | < 5 seconds |
| Docker Desktop running | 60–90 seconds |
| MySQL healthy | + 15 seconds |
| Asterisk healthy | + 45 seconds |
| Kamailio healthy | + 30 seconds |
| SIP relay binds port 5060 | Immediately after Kamailio healthy |
| Phone registers | + 5 seconds |
| Total | ~2.5–3 minutes |
Monitoring Commands
# Check all container health states
docker ps --format "table {{.Names}} {{.Status}}"
# Check sip-relay is running and what it logged
pgrep -f sip-relay.py && tail -20 /var/log/sip-relay.log
# Verify Kamailio is routing
docker exec kamailio kamcmd dispatcher.list
# Verify Asterisk shows phones as registered
docker exec asterisk asterisk -rx "sip show peers"