Konstas.Engineering Notes
Networking · Cybersecurity · Infrastructure
Telecom

Docker VoIP Stack: Boot Sequencing, Health Checks, and Dependency Management

Articles · 2026 · Ioannis (John) Konstas

← All Articles

How 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: 3

Health Check Reference

ContainerHealth Check CommandIntervalRetries
mysqlmysqladmin ping10s5
asteriskasterisk -rx 'core show version'30s5
asterisk-2asterisk -rx 'core show version'30s5
kamailiokamailio -c30s3
a2billingphp parse a2billing.conf30s3

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

MilestoneApproximate Time (from system login)
IP forwarding set< 5 seconds
Docker Desktop running60–90 seconds
MySQL healthy+ 15 seconds
Asterisk healthy+ 45 seconds
Kamailio healthy+ 30 seconds
SIP relay binds port 5060Immediately 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"

← All articles

Research and direction by Ioannis (John) Konstas; compiled and finalized with AI assistance.

👁· views