Tutorial 38: Containerized Asterisk — Production Docker Compose Stack
Docker + Docker Compose + Asterisk 21 + PJSIP + MariaDB + Redis + Nginx + Let's Encrypt + ARI + WebRTC
| Difficulty | Advanced |
| Reading Time | ~70 minutes |
| Time to Complete | 4-6 hours |
| Prerequisites | Linux VPS (Ubuntu 22.04+), Docker 24+, Docker Compose v2, public IP, basic Asterisk knowledge |
| Tested On | Ubuntu 24.04 LTS, Docker 27.x, Asterisk 21-certified, 4 CPU / 8 GB RAM |
Running Asterisk on bare metal has been the standard for two decades, but the world has moved to containers. This tutorial builds a complete, production-ready Asterisk PBX stack using Docker Compose — from a multi-stage Dockerfile that compiles Asterisk from source with only the modules you need, through RTP/NAT handling (the hardest part of containerized VoIP), database-backed realtime configuration, ARI application development, WebRTC support with TURN, all the way to monitoring, scaling patterns, and a production hardening checklist. Every file is complete, copy-paste ready, and tested against real SIP trunks.
Table of Contents
- Introduction
- Architecture Overview
- Prerequisites
- Dockerfile Deep Dive
- Docker Compose Stack
- Asterisk Configuration Templates
- RTP & NAT Challenges
- Persistent Storage Strategy
- Database Integration
- ARI (Asterisk REST Interface)
- WebRTC Support
- Monitoring & Logging
- Scaling & High Availability
- Production Checklist & Troubleshooting
1. Introduction
Why Containerize Asterisk?
Asterisk has traditionally been installed directly on bare metal or a VM — compiled from source, configured by hand, upgraded with crossed fingers. It works, but it comes with pain:
- Snowflake servers: Every Asterisk box drifts from the others over time. Different module versions, different patches, different configs. When one breaks, you cannot reproduce the issue anywhere else.
- Upgrades are terrifying: Upgrading Asterisk on a production server means recompiling on the live box, hoping dependencies do not break, and praying the new version does not regress your codec or SRTP support.
- Environment dependencies: Asterisk links against specific versions of libpjproject, libsrtp, libopus, and dozens of other libraries. A system update can silently break things.
- No rollback path: If an upgrade goes wrong, you are restoring from backup and hoping your config files match the binary.
Containers solve all of these:
| Problem | Container Solution |
|---|---|
| Snowflake servers | Identical image on every host — built once, tested, deployed everywhere |
| Risky upgrades | Build the new image, test it, swap containers. Old image is still there for instant rollback |
| Library hell | All dependencies baked into the image. Host OS updates cannot break Asterisk |
| Configuration drift | Config templates + environment variables = reproducible deployments |
| CI/CD integration | Build and test Asterisk images in your pipeline before deploying to production |
| Scaling | Spin up additional Asterisk containers behind a SIP proxy in minutes |
When NOT to Containerize
Containers are not always the right answer for Asterisk. Be honest about these limitations:
- ViciDial: ViciDial is deeply coupled to its host OS. It expects specific paths, cron jobs, screen sessions, Apache with mod_php, and direct filesystem access for recordings. Containerizing ViciDial is a multi-month project that most teams should avoid. Use VMs.
- DAHDI timing: If you need MeetMe conferencing (not ConfBridge), you need DAHDI kernel modules for timing. DAHDI requires kernel module compilation on the host, which defeats much of the container isolation benefit. ConfBridge uses
res_timing_timerfdinstead and works perfectly in containers. - Ultra-low latency requirements: Docker's bridge networking adds ~50-100 microseconds of latency per packet. For most VoIP this is negligible, but if you are doing carrier-grade media processing at scale, test carefully.
- Existing stable deployments: If you have a bare-metal Asterisk that has been running for years and you are the only one who touches it, containerization adds complexity without clear benefit. Do not fix what is not broken.
Ideal Use Cases
| Use Case | Why Containers Work |
|---|---|
| Development & testing | Spin up identical Asterisk environments in seconds. Test config changes without touching production. |
| Standalone PBX for small/medium business | Full PBX stack (Asterisk + DB + web) in a single docker compose up command |
| Microservices architecture | Asterisk handles media, your app controls calls via ARI, everything in containers |
| Multi-tenant hosting | Spin up isolated Asterisk instances per customer |
| WebRTC gateway | Asterisk as a WebRTC-to-SIP bridge alongside your web application |
| CI/CD pipeline | Build, test, and deploy Asterisk changes like any other software |
2. Architecture Overview
Here is what we are building — a complete PBX stack that runs with a single docker compose up -d:
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
External Traffic:
├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP
├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP
├── RTP (UDP 10000-20000) ──────────────► Asterisk Media
├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC
├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect
└── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
Container Responsibilities
| Container | Role | Ports |
|---|---|---|
| asterisk | SIP/media server, ARI, conferencing | 5060/udp, 5060/tcp, 5061/tcp, 8088, 5038, 10000-20000/udp |
| mariadb | CDR storage, realtime PJSIP config, voicemail | 3306 (internal only) |
| redis | ARI application state, session caching | 6379 (internal only) |
| nginx | TLS termination, WebSocket proxy, static files | 80, 443 |
| certbot | Automatic Let's Encrypt certificate renewal | — |
| coturn | TURN/STUN server for WebRTC NAT traversal | 3478/udp+tcp, 49152-65535/udp |
Directory Structure
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes
├── mariadb/
├── redis/
├── recordings/
├── voicemail/
├── logs/
└── certs/
3. Prerequisites
System Requirements
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 2 cores | 4+ cores |
| RAM | 4 GB | 8+ GB |
| Disk | 40 GB SSD | 100+ GB SSD (recordings grow fast) |
| OS | Ubuntu 22.04+ / Debian 12+ | Ubuntu 24.04 LTS |
| Network | 1 public IP, 100 Mbps | Dedicated IP, 1 Gbps |
Install Docker and Docker Compose
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null
# Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
# Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
Firewall Rules
Open these ports on your host firewall before proceeding:
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS
# RTP media
sudo ufw allow 10000:20000/udp
# Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp
# Enable firewall
sudo ufw enable
Create Project Directory
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
4. Dockerfile Deep Dive
This is a multi-stage build that compiles Asterisk 21 from source with exactly the modules you need, then copies only the runtime binaries into a minimal image. The result is roughly 250 MB instead of the 1+ GB you would get installing everything.
Why Compile from Source in Docker?
- Module selection: Include only what you need (PJSIP, ARI, ConfBridge, Opus) and exclude what you do not (chan_sip, chan_dahdi, res_phoneprov)
- Codec support: Compile with Opus, which is not included in most distribution packages
- Reproducible builds: The exact same binary every time, regardless of when you build
- Security: Smaller image = smaller attack surface. No compiler, no headers, no build tools in production
The Dockerfile
Create asterisk/Dockerfile:
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder
ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2
# Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
wget \
git \
pkg-config \
autoconf \
automake \
libtool \
# Asterisk core dependencies
libjansson-dev \
libxml2-dev \
libncurses5-dev \
libsqlite3-dev \
uuid-dev \
libssl-dev \
# PJSIP dependencies
libpjproject-dev \
# Sound/codec dependencies
libspeex-dev \
libspeexdsp-dev \
libopus-dev \
libsndfile1-dev \
libvorbis-dev \
# Database/ODBC dependencies
unixodbc-dev \
libmariadb-dev \
odbc-mariadb \
# Lua for dialplan (optional)
liblua5.4-dev \
# HTTP/WebSocket dependencies (for ARI)
libcurl4-openssl-dev \
# SRTP for encrypted media
libsrtp2-dev \
# Editing/misc
libedit-dev \
libxslt1-dev \
&& rm -rf /var/lib/apt/lists/*
# Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \
-o asterisk.tar.gz \
&& tar xzf asterisk.tar.gz \
&& mv asterisk-${ASTERISK_VERSION} asterisk \
&& rm asterisk.tar.gz
WORKDIR /usr/src/asterisk
# Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true
# Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \
--prefix=/usr \
--sysconfdir=/etc \
--localstatedir=/var \
--with-crypto \
--with-ssl \
--with-srtp \
--with-pjproject-bundled \
--with-jansson-bundled \
--with-opus \
--without-dahdi \
--without-pri \
--without-radius \
--without-postgres \
--without-sdl \
--without-gtk2 \
--without-x11
# Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \
# ---- Enable essential modules ----
&& menuselect/menuselect \
--enable res_pjsip \
--enable res_pjsip_session \
--enable res_pjsip_authenticator_digest \
--enable res_pjsip_caller_id \
--enable res_pjsip_endpoint_identifier_ip \
--enable res_pjsip_endpoint_identifier_user \
--enable res_pjsip_header_funcs \
--enable res_pjsip_nat \
--enable res_pjsip_outbound_authenticator_digest \
--enable res_pjsip_outbound_registration \
--enable res_pjsip_registrar \
--enable res_pjsip_sdp_rtp \
--enable res_pjsip_transport_websocket \
--enable res_pjsip_dtmf_info \
# ARI and HTTP
--enable res_ari \
--enable res_ari_channels \
--enable res_ari_bridges \
--enable res_ari_endpoints \
--enable res_ari_recordings \
--enable res_ari_playbacks \
--enable res_ari_sounds \
--enable res_ari_events \
--enable res_http_websocket \
--enable res_stasis \
--enable res_stasis_answer \
--enable res_stasis_playback \
--enable res_stasis_recording \
--enable res_stasis_snoop \
# Conferencing (ConfBridge, no DAHDI needed)
--enable app_confbridge \
--enable app_audiosocket \
# Codecs
--enable codec_opus \
--enable codec_speex \
--enable codec_resample \
# Database / CDR
--enable cdr_adaptive_odbc \
--enable cdr_csv \
--enable func_odbc \
--enable res_odbc \
--enable res_odbc_transaction \
# Voicemail with ODBC storage
--enable app_voicemail_odbc \
# Timing (timerfd works without DAHDI)
--enable res_timing_timerfd \
# Format support
--enable format_mp3 \
--enable format_wav \
--enable format_wav_gsm \
--enable format_sln \
--enable format_ogg_vorbis \
# ---- Disable what we don't need ----
--disable chan_sip \
--disable chan_dahdi \
--disable chan_skinny \
--disable chan_mgcp \
--disable chan_unistim \
--disable app_meetme \
--disable res_timing_dahdi \
--disable cdr_pgsql \
--disable cel_pgsql \
menuselect.makeopts
# Build Asterisk (use all available cores)
RUN make -j$(nproc)
# Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \
&& make samples DESTDIR=/tmp/asterisk-install
# Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true
# Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \
&& cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \
&& for pack in core extra; do \
for fmt in gsm wav; do \
curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \
| tar xz 2>/dev/null || true; \
done; \
done
# Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \
&& cd /tmp/asterisk-install/var/lib/asterisk/moh \
&& curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \
| tar xz 2>/dev/null || true
# -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime
LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0"
ENV DEBIAN_FRONTEND=noninteractive
# Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
# Core runtime libs
libjansson4 \
libxml2 \
libncurses6 \
libsqlite3-0 \
libuuid1 \
libssl3 \
# Codec runtime libs
libspeex1 \
libspeexdsp1 \
libopus0 \
libsndfile1 \
libvorbis0a \
libvorbisenc2 \
# Database/ODBC runtime
unixodbc \
libmariadb3 \
odbc-mariadb \
# SRTP
libsrtp2-1 \
# HTTP
libcurl4 \
# Editing
libedit2 \
# Lua runtime
liblua5.4-0 \
# envsubst for config templating
gettext-base \
# Useful debugging tools (remove in ultra-minimal builds)
sngrep \
tcpdump \
net-tools \
iputils-ping \
dnsutils \
procps \
&& rm -rf /var/lib/apt/lists/*
# Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk
# Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ /
# Create required directories with proper ownership
RUN mkdir -p \
/var/run/asterisk \
/var/log/asterisk \
/var/log/asterisk/cdr-csv \
/var/spool/asterisk/monitor \
/var/spool/asterisk/voicemail \
/var/spool/asterisk/tmp \
/var/lib/asterisk \
/etc/asterisk \
&& chown -R asterisk:asterisk \
/var/run/asterisk \
/var/log/asterisk \
/var/spool/asterisk \
/var/lib/asterisk \
/etc/asterisk
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD asterisk -rx "core show version" || exit 1
# Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp
# Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
Entrypoint Script
Create asterisk/entrypoint.sh:
#!/bin/bash
set -e
# =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# =============================================================================
echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
# ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..."
TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk"
if [ -d "$TEMPLATE_DIR" ]; then
for template in "$TEMPLATE_DIR"/*.template; do
[ -f "$template" ] || continue
filename=$(basename "$template" .template)
echo " Rendering: $filename"
envsubst < "$template" > "$CONFIG_DIR/$filename"
done
fi
# ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then
echo "Rendering ODBC configuration..."
envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi
# ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then
echo "PJSIP_EXTERNAL_IP not set, auto-detecting..."
PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "")
if [ -n "$PJSIP_EXTERNAL_IP" ]; then
echo " Detected external IP: $PJSIP_EXTERNAL_IP"
export PJSIP_EXTERNAL_IP
else
echo " WARNING: Could not detect external IP. NAT may not work correctly."
fi
fi
# Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then
for template in "$TEMPLATE_DIR"/*.template; do
[ -f "$template" ] || continue
filename=$(basename "$template" .template)
envsubst < "$template" > "$CONFIG_DIR/$filename"
done
fi
# ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk
# ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then
echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..."
for i in $(seq 1 30); do
if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \
bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then
echo " Database is ready!"
break
fi
echo " Attempt $i/30 - waiting..."
sleep 2
done
fi
# ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@"
# If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then
exec "$@" -U asterisk -G asterisk
else
exec "$@"
fi
Build and Test the Image
cd /opt/asterisk-docker
# Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/
# Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB
# Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0
# Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
Build Arguments for Customization
You can customize the build without editing the Dockerfile:
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/
# Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
5. Docker Compose Stack
This is the central file that ties everything together. Every service, network, volume, and dependency is defined here.
Environment File
Create .env in the project root:
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# =============================================================================
# ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC
# ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN
# ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN
# ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000
# ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3!
# ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026!
# ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026!
# ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026!
# ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN
# ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
Docker Compose File
Create docker-compose.yml:
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Asterisk PBX
# ---------------------------------------------------------------------------
asterisk:
build:
context: ./asterisk
dockerfile: Dockerfile
container_name: asterisk
restart: unless-stopped
# For production with real SIP trunks, host networking is often simplest.
# See Section 7 for detailed discussion of networking modes.
# Option A: Bridge networking (more isolated, more complex NAT config)
networks:
- asterisk-net
ports:
# SIP signaling
- "5060:5060/udp"
- "5060:5060/tcp"
- "5061:5061/tcp"
# AMI (restrict to localhost in production)
- "127.0.0.1:5038:5038/tcp"
# ARI HTTP (proxied through Nginx, but expose for internal access)
- "127.0.0.1:8088:8088/tcp"
# RTP media - this is a LARGE range
# See Section 7 for why this is necessary and how to minimize it
- "10000-20000:10000-20000/udp"
# Option B: Host networking (uncomment below, comment out ports above)
# network_mode: host
environment:
- TZ=${TZ:-UTC}
- PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
- DOMAIN=${DOMAIN}
- SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD}
- SIP_REALM=${SIP_REALM:-${DOMAIN}}
- RTP_PORT_START=${RTP_PORT_START:-10000}
- RTP_PORT_END=${RTP_PORT_END:-20000}
- DB_HOST=${DB_HOST:-mariadb}
- DB_PORT=${DB_PORT:-3306}
- DB_NAME=${DB_NAME:-asterisk}
- DB_USER=${DB_USER:-asterisk}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- ARI_USERNAME=${ARI_USERNAME:-ari_user}
- ARI_PASSWORD=${ARI_PASSWORD}
- AMI_USERNAME=${AMI_USERNAME:-ami_admin}
- AMI_PASSWORD=${AMI_PASSWORD}
volumes:
# Configuration templates (processed by entrypoint)
- ./asterisk/configs:/etc/asterisk/templates:ro
# Static configs (not templated)
- ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro
- ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro
- ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro
# ODBC configuration template
- ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro
# Persistent data
- recordings:/var/spool/asterisk/monitor
- voicemail:/var/spool/asterisk/voicemail
- asterisk-logs:/var/log/asterisk
# Custom sounds
- ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro
# TLS certificates (shared with Nginx/Certbot)
- certs:/etc/asterisk/certs:ro
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
ulimits:
nofile:
soft: 65536
hard: 65536
core:
soft: -1
hard: -1
deploy:
resources:
limits:
cpus: '4'
memory: 4G
reservations:
cpus: '1'
memory: 512M
healthcheck:
test: ["CMD", "asterisk", "-rx", "core show version"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ---------------------------------------------------------------------------
# MariaDB - CDR, Realtime Config, Voicemail
# ---------------------------------------------------------------------------
mariadb:
image: mariadb:11.4
container_name: asterisk-mariadb
restart: unless-stopped
networks:
- asterisk-net
# Do NOT expose port externally in production
# Uncomment only for debugging:
# ports:
# - "127.0.0.1:3306:3306/tcp"
environment:
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MARIADB_DATABASE=${DB_NAME:-asterisk}
- MARIADB_USER=${DB_USER:-asterisk}
- MARIADB_PASSWORD=${DB_PASSWORD}
- TZ=${TZ:-UTC}
volumes:
- mariadb-data:/var/lib/mysql
- ./mariadb/init:/docker-entrypoint-initdb.d:ro
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--max-connections=200
--innodb-buffer-pool-size=256M
--innodb-log-file-size=64M
--max-allowed-packet=64M
--slow-query-log=1
--slow-query-log-file=/var/lib/mysql/slow.log
--long-query-time=2
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
memory: 256M
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 5
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# ---------------------------------------------------------------------------
# Redis - ARI State, Session Cache
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: asterisk-redis
restart: unless-stopped
networks:
- asterisk-net
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 128mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
volumes:
- redis-data:/data
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 15s
timeout: 3s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ---------------------------------------------------------------------------
# Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy
# ---------------------------------------------------------------------------
nginx:
image: nginx:1.27-alpine
container_name: asterisk-nginx
restart: unless-stopped
networks:
- asterisk-net
ports:
- "80:80/tcp"
- "443:443/tcp"
environment:
- DOMAIN=${DOMAIN}
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certs:/etc/nginx/certs:ro
- certbot-webroot:/var/www/certbot:ro
depends_on:
- asterisk
deploy:
resources:
limits:
cpus: '1'
memory: 256M
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# ---------------------------------------------------------------------------
# Certbot - Let's Encrypt Certificate Management
# ---------------------------------------------------------------------------
certbot:
image: certbot/certbot:latest
container_name: asterisk-certbot
restart: unless-stopped
volumes:
- certs:/etc/letsencrypt
- certbot-webroot:/var/www/certbot
# Check for renewal twice daily (standard recommendation)
entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done"
depends_on:
- nginx
# ---------------------------------------------------------------------------
# Coturn - TURN/STUN Server for WebRTC NAT Traversal
# ---------------------------------------------------------------------------
coturn:
image: coturn/coturn:latest
container_name: asterisk-coturn
restart: unless-stopped
network_mode: host
volumes:
- ./coturn/turnserver.conf:/etc/turnserver.conf:ro
- certs:/etc/certs:ro
command: ["-c", "/etc/turnserver.conf"]
deploy:
resources:
limits:
cpus: '1'
memory: 512M
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# =============================================================================
# Networks
# =============================================================================
networks:
asterisk-net:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/24
# =============================================================================
# Volumes
# =============================================================================
volumes:
mariadb-data:
driver: local
redis-data:
driver: local
recordings:
driver: local
driver_opts:
type: none
device: /opt/asterisk-docker/data/recordings
o: bind
voicemail:
driver: local
driver_opts:
type: none
device: /opt/asterisk-docker/data/voicemail
o: bind
asterisk-logs:
driver: local
driver_opts:
type: none
device: /opt/asterisk-docker/data/logs
o: bind
certs:
driver: local
driver_opts:
type: none
device: /opt/asterisk-docker/data/certs
o: bind
certbot-webroot:
driver: local
Starting the Stack
cd /opt/asterisk-docker
# Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs}
# Build and start everything
docker compose up -d --build
# Watch logs during startup
docker compose logs -f asterisk
# Verify all containers are healthy
docker compose ps
# Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
6. Asterisk Configuration Templates
Instead of hardcoding IP addresses and passwords in Asterisk config files, we use templates. The entrypoint script runs envsubst to replace ${VARIABLE} placeholders with values from the container's environment.
PJSIP Configuration
Create asterisk/configs/pjsip.conf.template:
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; =============================================================================
; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90
; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no
; =============================================================================
; Transports
; =============================================================================
; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256
; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
; =============================================================================
; Endpoint Templates
; =============================================================================
; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP}
; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes
; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy
; =============================================================================
; Sample Endpoints
; =============================================================================
; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1
[1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM}
[1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300
; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1
[1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM}
[1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default
[1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM}
[1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com
; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
Extensions (Dialplan)
Create asterisk/configs/extensions.conf.template:
; =============================================================================
; Dialplan - Containerized Asterisk
; =============================================================================
[general]
static = yes
writeprotect = no
clearglobalvars = no
[globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567>
; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN})
same => n,Set(CALLERID(name)=${CALLERID(name)})
same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail)
same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b)
same => n,Hangup()
same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u)
same => n,Hangup()
; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales)
same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales])
same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK)
same => n,VoiceMail(2001@${VM_CONTEXT},u)
same => n,Hangup()
; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support)
same => n,Set(CALLERID(name)=${CALLERID(name)} [Support])
same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK)
same => n,VoiceMail(2002@${VM_CONTEXT},u)
same => n,Hangup()
; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN})
same => n,Answer()
same => n,ConfBridge(${EXTEN},default_bridge,default_user)
same => n,Hangup()
; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference)
same => n,Answer()
same => n,ConfBridge(3000,default_bridge,admin_user)
same => n,Hangup()
; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access)
same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT})
same => n,Hangup()
; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox)
same => n,VoiceMailMain(@${VM_CONTEXT})
same => n,Hangup()
; ---- Echo test ----
exten => *43,1,NoOp(Echo Test)
same => n,Answer()
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock)
same => n,Answer()
same => n,SayUnixTime(,,IMp)
same => n,Hangup()
; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2})
same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK)
same => n,Hangup()
; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1})
same => n,Set(CALLERID(all)=${TRUNK_CID})
same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK)
same => n,Hangup()
; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1})
same => n,Stasis(${ARI_APP},${EXTEN:1})
same => n,Hangup()
; ---- Invalid extension ----
exten => i,1,Playback(invalid)
same => n,Hangup()
; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN})
same => n,Answer()
same => n,Wait(1)
same => n,Set(TIMEOUT(response)=10)
same => n,Set(TIMEOUT(digit)=5)
same => n(ivr),Background(custom/welcome)
same => n,WaitExten(5)
same => n,Goto(ivr-timeout,s,1)
; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected)
same => n,Playback(custom/transferring-sales)
same => n,Goto(from-internal,2001,1)
; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected)
same => n,Playback(custom/transferring-support)
same => n,Goto(from-internal,2002,1)
; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected)
same => n,Goto(from-internal,1001,1)
; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception)
same => n,Playback(custom/no-input)
same => n,Goto(from-internal,1001,1)
; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)})
same => n,Hangup(21)
exten => _[a-z].,1,Hangup(21)
Modules Configuration
Create asterisk/configs/modules.conf (not templated — static):
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; =============================================================================
[modules]
autoload = no
; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so
; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so
; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so
; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so
; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so
; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so
; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so
; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so
; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so
; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so
; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so
; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
HTTP Configuration (for ARI)
Create asterisk/configs/http.conf.template:
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; =============================================================================
[general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
ARI Configuration
Create asterisk/configs/ari.conf.template:
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; =============================================================================
[general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN}
[${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
RTP Configuration
Create asterisk/configs/rtp.conf (not templated):
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; =============================================================================
[general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
CDR Configuration
Create asterisk/configs/cdr.conf:
; =============================================================================
; CDR (Call Detail Records) Configuration
; =============================================================================
[general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
CDR Adaptive ODBC
Create asterisk/configs/cdr_adaptive_odbc.conf:
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; =============================================================================
[asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
ODBC Configuration
Create asterisk/configs/res_odbc.conf.template:
; =============================================================================
; ODBC Connection Configuration
; =============================================================================
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
Create asterisk/configs/odbc.ini.template:
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
Voicemail Configuration
Create asterisk/configs/voicemail.conf.template:
; =============================================================================
; Voicemail Configuration
; =============================================================================
[general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r
[default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
ConfBridge Configuration
Create asterisk/configs/confbridge.conf:
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; =============================================================================
[general]
[default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left
[default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no
[admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
7. RTP & NAT Challenges
This is the single hardest aspect of running Asterisk in Docker. If you get everything else right but NAT wrong, you will have one-way audio or no audio at all. This section explains why and gives you multiple solutions.
The Problem
When Asterisk runs inside a Docker container with bridge networking, it has a private IP address (e.g., 172.25.0.2). When it negotiates RTP with an external SIP peer, it tells the peer to send audio to 172.25.0.2 — which the peer cannot reach. The peer sends audio into the void, and you get one-way or no audio.
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2
│ │ │
│◄── SIP INVITE ──────────────►│◄── port forward ────────►│
│ (signaling works fine) │ (5060 mapped) │
│ │ │
│ RTP: "Send audio to │ │
│ 172.25.0.2:15000" │ ← PROBLEM! │
│ Phone can't reach that │ │
│ │ │
Solution 1: PJSIP NAT Settings (Bridge Networking)
This is what we configured in pjsip.conf.template. The key settings:
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
How it works: Asterisk tells the remote peer "send audio to 198.51.100.10:15000" (the public IP). Docker's port mapping forwards UDP 15000 on the host to the container. rtp_symmetric ensures Asterisk sends its audio back to wherever the peer's audio came from, not to the SDP-advertised address.
Solution 2: Host Networking (Simplest)
If NAT is giving you trouble, the simplest solution is to remove the network abstraction entirely:
# In docker-compose.yml, for the asterisk service:
asterisk:
# Remove: networks, ports
network_mode: host
environment:
# PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall
- PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
With host networking, Asterisk binds directly to the host's network interfaces. No port mapping, no Docker NAT, no bridge. SIP and RTP work exactly as they would on bare metal.
Trade-offs:
| Aspect | Bridge Networking | Host Networking |
|---|---|---|
| Network isolation | Full isolation, explicit port mapping | None — container shares host network |
| NAT complexity | High — must configure external_media_address, local_net, port ranges | Low — same as bare metal |
| Multiple Asterisk instances | Possible (different port ranges) | Not possible (port conflicts) |
| Security | Better — only exposed ports are reachable | Worse — all container ports reachable |
| Performance | Slight overhead (~50-100 us per packet) | No overhead |
| Recommendation | Dev/test, isolated environments | Production with real trunks |
Solution 3: Macvlan Networking
Macvlan gives the container its own IP address on the physical network, like a real machine:
networks:
asterisk-macvlan:
driver: macvlan
driver_opts:
parent: eth0 # Your host's network interface
ipam:
config:
- subnet: 198.51.100.0/24
gateway: 198.51.100.1
services:
asterisk:
networks:
asterisk-macvlan:
ipv4_address: 198.51.100.20 # Unique IP for the container
When to use: You have spare public IPs and want full isolation without NAT. The container behaves exactly like a separate physical machine on the network.
Caveat: The host cannot communicate with the macvlan container by default (a known Docker limitation). You need a macvlan bridge sub-interface on the host if other containers need to reach Asterisk.
RTP Port Range: Why 10000-20000?
Each active call uses one RTP port (audio) plus optionally another for RTCP. The default range of 10000-20000 supports up to 5000 simultaneous calls. For smaller deployments, you can narrow this:
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
And in docker-compose.yml:
ports:
- "10000-10200:10000-10200/udp"
Important: Docker creates iptables rules for each mapped port. Mapping 10000 ports is fine on modern kernels, but if you see slow container startup, narrow the range.
ICE/STUN/TURN for WebRTC
WebRTC clients are always behind NAT (they run in browsers). They use ICE (Interactive Connectivity Establishment) to discover a working path for media. This requires STUN and usually TURN servers.
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2
│ │ │
│── STUN Binding Req ──►│ │
│◄── Your public IP is │ │
│ 203.0.113.50:45000 │ │
│ │ │
│── TURN Allocate Req ─►│ │
│◄── Relay address: │ │
│ 198.51.100.10:49200│ │
│ │ │
│ ICE Negotiation │ SIP/WebSocket │
│◄──────────────────────►◄────────────────────────►│
│ │ │
│◄── RTP via TURN ──────►◄── RTP ────────────────►│
The Coturn container (configured later in Section 11) handles this. In rtp.conf, we point Asterisk at a STUN server:
stunaddr = stun.l.google.com:19302
icesupport = yes
Debugging Audio Issues
When you have no audio or one-way audio, debug in this order:
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}'
# 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken)
# 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50
# 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio
# 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address
# 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
Quick Decision Guide
Need multiple Asterisk containers on one host?
└── YES → Use bridge networking with careful NAT config
└── NO ─┐
├── Have spare public IPs?
│ └── YES → Macvlan (cleanest isolation)
│ └── NO ──┐
│ ├── WebRTC only (no SIP trunks)?
│ │ └── Bridge + TURN server
│ └── SIP trunks with real carriers?
│ └── Host networking (simplest, proven)
└──────────────────────────────────────────────
8. Persistent Storage Strategy
Containers are ephemeral by design. When you docker compose down and up again, anything not in a volume is gone. For a PBX, losing recordings, voicemail, CDRs, or certificates would be catastrophic. This section maps every piece of persistent data to the right volume strategy.
What Needs to Persist
| Data | Path in Container | Volume Type | Backup Priority |
|---|---|---|---|
| Call recordings | /var/spool/asterisk/monitor |
Bind mount | HIGH — irreplaceable |
| Voicemail messages | /var/spool/asterisk/voicemail |
Bind mount | HIGH — irreplaceable |
| Asterisk logs | /var/log/asterisk |
Bind mount | MEDIUM — useful but regenerable |
| CDR database | MariaDB volume | Named volume | HIGH — business records |
| TLS certificates | /etc/asterisk/certs |
Shared volume | HIGH — service continuity |
| MariaDB data | /var/lib/mysql |
Named volume | CRITICAL — all config and records |
| Redis data | /data |
Named volume | LOW — cache, can be rebuilt |
| Custom sounds | /var/lib/asterisk/sounds/custom |
Bind mount (read-only) | MEDIUM — part of deployment |
Bind Mounts vs Named Volumes
We use bind mounts for data you need to access directly from the host (recordings, logs) and named volumes for data managed entirely by Docker (database files).
# Bind mount - you control the exact path on the host
volumes:
recordings:
driver: local
driver_opts:
type: none
device: /opt/asterisk-docker/data/recordings
o: bind
# Named volume - Docker manages the path
volumes:
mariadb-data:
driver: local
Recording Storage Architecture
Recordings grow fast. A single call at G.711 (ulaw/alaw) generates about 1 MB per minute. With 100 concurrent calls averaging 5 minutes each, you generate roughly 30 GB per day. Plan accordingly.
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings
# Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
Recording Cleanup Cron
Recordings will fill your disk if you do not clean them up. Create scripts/cleanup-recordings.sh:
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# =============================================================================
RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log"
echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE"
# Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1)
# Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \
-mtime +${RETENTION_DAYS} -delete
# Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null
# Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1)
DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh
# Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
Log Rotation
Container logs are managed by Docker's json-file driver (configured in docker-compose.yml with max-size and max-file). But Asterisk also writes its own internal logs to /var/log/asterisk/. Handle those separately:
Create scripts/logrotate-asterisk.conf:
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 0640 1000 1000
postrotate
docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true
endscript
}
/opt/asterisk-docker/data/logs/cdr-csv/*.csv
{
monthly
rotate 12
compress
delaycompress
missingok
notifempty
create 0640 1000 1000
}
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
Full Backup Script
Create scripts/backup.sh:
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# =============================================================================
set -e
BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30
# Source environment
set -a
source /opt/asterisk-docker/.env
set +a
echo "=== Asterisk Backup Starting: $DATE ==="
mkdir -p "$BACKUP_PATH"
# ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \
-u root -p"${DB_ROOT_PASSWORD}" \
--all-databases \
--single-transaction \
--routines \
--triggers \
--events \
> "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)"
# ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \
-C /opt/asterisk-docker \
asterisk/configs/ \
nginx/ \
coturn/ \
docker-compose.yml \
.env
# ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then
tar czf "$BACKUP_PATH/voicemail.tar.gz" \
-C /opt/asterisk-docker/data voicemail/
echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else
echo " Voicemail: empty, skipped"
fi
# ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then
tar czf "$BACKUP_PATH/certs.tar.gz" \
-C /opt/asterisk-docker/data certs/
fi
# ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/
# ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME}
Files:
$(ls -lh "$BACKUP_PATH/")
Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null)
Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running")
Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST
# ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)"
# ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete
echo "=== Backup Complete ==="
chmod +x /opt/asterisk-docker/scripts/backup.sh
# Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
Restore Script
Create scripts/restore.sh:
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# =============================================================================
set -e
BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$"
if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then
echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz"
exit 1
fi
# Source environment
set -a
source /opt/asterisk-docker/.env
set +a
echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10
# Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE"
echo "Backup date: $BACKUP_DATE"
# Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down
# Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then
echo "Restoring database..."
docker compose up -d mariadb
sleep 10 # Wait for MariaDB to be ready
docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \
< "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database restored."
fi
# Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then
echo "Restoring configurations..."
tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/
echo " Configs restored."
fi
# Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then
echo "Restoring voicemail..."
tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/
echo " Voicemail restored."
fi
# Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then
echo "Restoring certificates..."
tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/
echo " Certificates restored."
fi
# Start all services
echo "Starting all services..."
docker compose up -d
# Cleanup
rm -rf "$RESTORE_DIR"
echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
chmod +x /opt/asterisk-docker/scripts/restore.sh
9. Database Integration
The MariaDB container stores CDRs, realtime PJSIP configuration (so you can manage endpoints without restarting Asterisk), voicemail metadata, and application state.
Database Init Script
Create mariadb/init/01-schema.sql:
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- =============================================================================
USE asterisk;
-- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00',
clid VARCHAR(80) NOT NULL DEFAULT '',
src VARCHAR(80) NOT NULL DEFAULT '',
dst VARCHAR(80) NOT NULL DEFAULT '',
dcontext VARCHAR(80) NOT NULL DEFAULT '',
channel VARCHAR(80) NOT NULL DEFAULT '',
dstchannel VARCHAR(80) NOT NULL DEFAULT '',
lastapp VARCHAR(80) NOT NULL DEFAULT '',
lastdata VARCHAR(80) NOT NULL DEFAULT '',
duration INT NOT NULL DEFAULT 0,
billsec INT NOT NULL DEFAULT 0,
disposition VARCHAR(45) NOT NULL DEFAULT '',
amaflags INT NOT NULL DEFAULT 0,
accountcode VARCHAR(20) NOT NULL DEFAULT '',
uniqueid VARCHAR(150) NOT NULL DEFAULT '',
userfield VARCHAR(255) NOT NULL DEFAULT '',
peeraccount VARCHAR(20) NOT NULL DEFAULT '',
linkedid VARCHAR(150) NOT NULL DEFAULT '',
sequence INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_calldate (calldate),
INDEX idx_dst (dst),
INDEX idx_src (src),
INDEX idx_uniqueid (uniqueid),
INDEX idx_disposition (disposition),
INDEX idx_accountcode (accountcode),
INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files
-- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints (
id VARCHAR(40) NOT NULL,
transport VARCHAR(40) DEFAULT NULL,
aors VARCHAR(200) DEFAULT NULL,
auth VARCHAR(40) DEFAULT NULL,
context VARCHAR(40) DEFAULT 'from-internal',
disallow VARCHAR(200) DEFAULT 'all',
allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw',
direct_media ENUM('yes','no') DEFAULT 'no',
connected_line_method VARCHAR(40) DEFAULT NULL,
direct_media_method VARCHAR(40) DEFAULT NULL,
direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL,
disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL,
dtmf_mode VARCHAR(40) DEFAULT 'rfc4733',
external_media_address VARCHAR(40) DEFAULT NULL,
force_rport ENUM('yes','no') DEFAULT 'yes',
ice_support ENUM('yes','no') DEFAULT 'no',
identify_by VARCHAR(80) DEFAULT NULL,
mailboxes VARCHAR(40) DEFAULT NULL,
media_address VARCHAR(40) DEFAULT NULL,
media_encryption VARCHAR(40) DEFAULT 'no',
media_use_received_transport ENUM('yes','no') DEFAULT NULL,
100rel VARCHAR(40) DEFAULT NULL,
outbound_auth VARCHAR(40) DEFAULT NULL,
outbound_proxy VARCHAR(256) DEFAULT NULL,
rewrite_contact ENUM('yes','no') DEFAULT 'yes',
rtp_ipv6 ENUM('yes','no') DEFAULT NULL,
rtp_symmetric ENUM('yes','no') DEFAULT 'yes',
send_diversion ENUM('yes','no') DEFAULT NULL,
send_pai ENUM('yes','no') DEFAULT 'yes',
send_rpid ENUM('yes','no') DEFAULT 'yes',
timers_min_se INT DEFAULT NULL,
timers VARCHAR(40) DEFAULT NULL,
timers_sess_expires INT DEFAULT NULL,
callerid VARCHAR(40) DEFAULT NULL,
callerid_privacy VARCHAR(40) DEFAULT NULL,
callerid_tag VARCHAR(40) DEFAULT NULL,
trust_id_inbound ENUM('yes','no') DEFAULT 'yes',
trust_id_outbound ENUM('yes','no') DEFAULT NULL,
use_ptime ENUM('yes','no') DEFAULT NULL,
use_avpf ENUM('yes','no') DEFAULT NULL,
force_avp ENUM('yes','no') DEFAULT NULL,
media_encryption_optimistic ENUM('yes','no') DEFAULT NULL,
inband_progress ENUM('yes','no') DEFAULT NULL,
call_group VARCHAR(40) DEFAULT NULL,
pickup_group VARCHAR(40) DEFAULT NULL,
named_call_group VARCHAR(40) DEFAULT NULL,
named_pickup_group VARCHAR(40) DEFAULT NULL,
device_state_busy_at INT DEFAULT NULL,
t38_udptl ENUM('yes','no') DEFAULT NULL,
t38_udptl_ec VARCHAR(40) DEFAULT NULL,
t38_udptl_maxdatagram INT DEFAULT NULL,
fax_detect ENUM('yes','no') DEFAULT NULL,
t38_udptl_nat ENUM('yes','no') DEFAULT NULL,
t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL,
tone_zone VARCHAR(40) DEFAULT NULL,
language VARCHAR(40) DEFAULT NULL,
one_touch_recording ENUM('yes','no') DEFAULT NULL,
record_on_feature VARCHAR(40) DEFAULT NULL,
record_off_feature VARCHAR(40) DEFAULT NULL,
rtp_engine VARCHAR(40) DEFAULT NULL,
allow_transfer ENUM('yes','no') DEFAULT NULL,
allow_subscribe ENUM('yes','no') DEFAULT NULL,
sdp_owner VARCHAR(40) DEFAULT NULL,
sdp_session VARCHAR(40) DEFAULT NULL,
tos_audio VARCHAR(10) DEFAULT NULL,
tos_video VARCHAR(10) DEFAULT NULL,
sub_min_expiry INT DEFAULT NULL,
from_domain VARCHAR(40) DEFAULT NULL,
from_user VARCHAR(40) DEFAULT NULL,
mwi_from_user VARCHAR(40) DEFAULT NULL,
dtls_verify VARCHAR(40) DEFAULT NULL,
dtls_rekey VARCHAR(40) DEFAULT NULL,
dtls_cert_file VARCHAR(200) DEFAULT NULL,
dtls_private_key VARCHAR(200) DEFAULT NULL,
dtls_cipher VARCHAR(200) DEFAULT NULL,
dtls_ca_file VARCHAR(200) DEFAULT NULL,
dtls_ca_path VARCHAR(200) DEFAULT NULL,
dtls_setup VARCHAR(40) DEFAULT NULL,
srtp_tag_32 ENUM('yes','no') DEFAULT NULL,
webrtc ENUM('yes','no') DEFAULT 'no',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Authentication
CREATE TABLE IF NOT EXISTS ps_auths (
id VARCHAR(40) NOT NULL,
auth_type VARCHAR(40) DEFAULT 'userpass',
nonce_lifetime INT DEFAULT NULL,
md5_cred VARCHAR(40) DEFAULT NULL,
password VARCHAR(80) DEFAULT NULL,
realm VARCHAR(40) DEFAULT NULL,
username VARCHAR(40) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors (
id VARCHAR(40) NOT NULL,
contact VARCHAR(255) DEFAULT NULL,
default_expiration INT DEFAULT 300,
mailboxes VARCHAR(80) DEFAULT NULL,
max_contacts INT DEFAULT 3,
minimum_expiration INT DEFAULT 60,
remove_existing ENUM('yes','no') DEFAULT 'yes',
qualify_frequency INT DEFAULT 60,
qualify_timeout FLOAT DEFAULT 3.0,
authenticate_qualify ENUM('yes','no') DEFAULT NULL,
outbound_proxy VARCHAR(256) DEFAULT NULL,
support_path ENUM('yes','no') DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts (
id VARCHAR(255) NOT NULL,
uri VARCHAR(255) DEFAULT NULL,
expiration_time BIGINT DEFAULT NULL,
qualify_frequency INT DEFAULT NULL,
outbound_proxy VARCHAR(256) DEFAULT NULL,
path TEXT DEFAULT NULL,
user_agent VARCHAR(255) DEFAULT NULL,
qualify_timeout FLOAT DEFAULT NULL,
reg_server VARCHAR(20) DEFAULT NULL,
authenticate_qualify ENUM('yes','no') DEFAULT NULL,
via_addr VARCHAR(40) DEFAULT NULL,
via_port INT DEFAULT NULL,
call_id VARCHAR(255) DEFAULT NULL,
endpoint VARCHAR(40) DEFAULT NULL,
prune_on_boot ENUM('yes','no') DEFAULT 'yes',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips (
id VARCHAR(40) NOT NULL,
endpoint VARCHAR(40) DEFAULT NULL,
match VARCHAR(80) DEFAULT NULL,
srv_lookups ENUM('yes','no') DEFAULT NULL,
match_header VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases (
id VARCHAR(40) NOT NULL,
domain VARCHAR(80) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations (
id VARCHAR(40) NOT NULL,
auth_rejection_permanent ENUM('yes','no') DEFAULT NULL,
client_uri VARCHAR(255) DEFAULT NULL,
contact_user VARCHAR(40) DEFAULT NULL,
expiration INT DEFAULT NULL,
max_retries INT DEFAULT NULL,
outbound_auth VARCHAR(40) DEFAULT NULL,
outbound_proxy VARCHAR(256) DEFAULT NULL,
retry_interval INT DEFAULT NULL,
forbidden_retry_interval INT DEFAULT NULL,
server_uri VARCHAR(255) DEFAULT NULL,
transport VARCHAR(40) DEFAULT NULL,
support_path ENUM('yes','no') DEFAULT NULL,
line ENUM('yes','no') DEFAULT NULL,
endpoint VARCHAR(40) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users (
uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT,
context VARCHAR(50) NOT NULL DEFAULT 'default',
mailbox VARCHAR(20) NOT NULL,
password VARCHAR(20) NOT NULL DEFAULT '1234',
fullname VARCHAR(150) DEFAULT NULL,
email VARCHAR(250) DEFAULT NULL,
pager VARCHAR(250) DEFAULT NULL,
tz VARCHAR(80) DEFAULT 'central',
attach ENUM('yes','no') DEFAULT 'yes',
saycid ENUM('yes','no') DEFAULT 'yes',
dialout VARCHAR(10) DEFAULT NULL,
callback VARCHAR(10) DEFAULT NULL,
review ENUM('yes','no') DEFAULT 'no',
operator ENUM('yes','no') DEFAULT 'no',
envelope ENUM('yes','no') DEFAULT 'no',
sayduration ENUM('yes','no') DEFAULT 'yes',
saydurationm INT DEFAULT 1,
sendvoicemail ENUM('yes','no') DEFAULT 'no',
delete_vm ENUM('yes','no') DEFAULT 'no',
nextaftercmd ENUM('yes','no') DEFAULT 'yes',
forcename ENUM('yes','no') DEFAULT 'no',
forcegreetings ENUM('yes','no') DEFAULT 'no',
hidefromdir ENUM('yes','no') DEFAULT 'yes',
stamp DATETIME DEFAULT NULL,
PRIMARY KEY (uniqueid),
INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members (
uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT,
membername VARCHAR(40) DEFAULT NULL,
queue_name VARCHAR(128) DEFAULT NULL,
interface VARCHAR(128) DEFAULT NULL,
penalty INT DEFAULT NULL,
paused INT DEFAULT NULL,
state_interface VARCHAR(128) DEFAULT NULL,
PRIMARY KEY (uniqueid),
UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS queue_rules (
rule_name VARCHAR(80) NOT NULL DEFAULT '',
time VARCHAR(32) NOT NULL DEFAULT '0',
min_penalty VARCHAR(32) NOT NULL DEFAULT '0',
max_penalty VARCHAR(32) NOT NULL DEFAULT '0',
INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES
('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'),
('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1');
INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES
('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'),
('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN');
INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES
('1001', 3, 'yes', 60, 5.0, 300),
('1002', 3, 'yes', 60, 5.0, 300);
-- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES
('default', '1001', '1234', 'Reception', '[email protected]'),
('default', '1002', '1234', 'Sales', '[email protected]');
PJSIP Realtime Configuration
To tell Asterisk to read PJSIP configuration from the database instead of (or in addition to) config files, add this to pjsip.conf.template or create a separate sorcery.conf:
Create asterisk/configs/sorcery.conf:
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; =============================================================================
[res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts
[res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips
[res_pjsip_outbound_registration]
registration = realtime,ps_registrations
Create asterisk/configs/extconfig.conf:
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; =============================================================================
[settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
Managing Endpoints via Database
With realtime configuration, you can add/modify/remove SIP endpoints without restarting Asterisk:
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow,
direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact,
callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all',
'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes',
'"Support" <1003>', '1003@default');
INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN');
INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60);
-- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \
-e "SELECT id, context, callerid FROM ps_endpoints;"
ODBC Connection Pooling
The res_odbc.conf.template configures connection pooling. Key settings:
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
Monitor connection health from the Asterisk CLI:
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
10. ARI (Asterisk REST Interface)
ARI lets you control Asterisk calls from external applications via REST API and WebSocket events. This is the modern way to build telephony applications — your code handles the logic, Asterisk handles the media.
ARI Architecture
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘
REST API:
POST /ari/channels → Originate a call
POST /ari/channels/{id}/answer → Answer a call
POST /ari/channels/{id}/play → Play audio
POST /ari/bridges → Create a bridge (conference)
POST /ari/bridges/{id}/addChannel → Add channel to bridge
DELETE /ari/channels/{id} → Hang up
WebSocket (/ari/events):
StasisStart → Call entered your application
StasisEnd → Call left your application
ChannelDtmfReceived → User pressed a key
ChannelStateChange → Channel state changed
PlaybackFinished → Audio playback completed
ARI Application: Auto-Attendant
This Python application implements a simple IVR using ARI. When a call enters the Stasis application (via exten => _7.,1,Stasis(autoattendant,...)), it plays a greeting, waits for DTMF input, and routes accordingly.
Create ari-app/requirements.txt:
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
Create ari-app/app.py:
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events.
Requires:
- Asterisk with ARI enabled (http.conf + ari.conf)
- Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN})
- pip install requests websocket-client redis
"""
import json
import logging
import os
import sys
import threading
import time
from typing import Any
import redis
import requests
import websocket
# =============================================================================
# Configuration
# =============================================================================
ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant")
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!")
# Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant")
# =============================================================================
# Redis Client (for state tracking)
# =============================================================================
redis_client = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASS,
decode_responses=True,
)
# =============================================================================
# ARI REST Client
# =============================================================================
class ARIClient:
"""Thin wrapper around the ARI REST API."""
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url.rstrip("/")
self.auth = (username, password)
self.session = requests.Session()
self.session.auth = self.auth
def get(self, path: str, **kwargs) -> Any:
resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else None
def post(self, path: str, **kwargs) -> Any:
resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else None
def delete(self, path: str, **kwargs) -> Any:
resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else None
# ---- Channel Operations ----
def answer(self, channel_id: str):
"""Answer a channel."""
return self.post(f"/channels/{channel_id}/answer")
def hangup(self, channel_id: str, reason: str = "normal"):
"""Hang up a channel."""
return self.delete(f"/channels/{channel_id}", params={"reason": reason})
def play(self, channel_id: str, media: str, playback_id: str = None):
"""Play media on a channel. Media format: 'sound:filename' or 'tone:dial'."""
params = {"media": media}
if playback_id:
params["playbackId"] = playback_id
return self.post(f"/channels/{channel_id}/play", params=params)
def dial(self, channel_id: str, endpoint: str, context: str = None,
caller_id: str = None, timeout: int = 30):
"""Originate a call and bridge it."""
params = {
"endpoint": endpoint,
"app": ARI_APP,
"timeout": timeout,
}
if caller_id:
params["callerId"] = caller_id
return self.post("/channels", params=params)
# ---- Bridge Operations ----
def create_bridge(self, bridge_type: str = "mixing", name: str = None):
"""Create a bridge (conference)."""
params = {"type": bridge_type}
if name:
params["name"] = name
return self.post("/bridges", params=params)
def add_to_bridge(self, bridge_id: str, channel_id: str):
"""Add a channel to a bridge."""
return self.post(f"/bridges/{bridge_id}/addChannel",
params={"channel": channel_id})
def remove_from_bridge(self, bridge_id: str, channel_id: str):
"""Remove a channel from a bridge."""
return self.post(f"/bridges/{bridge_id}/removeChannel",
params={"channel": channel_id})
def destroy_bridge(self, bridge_id: str):
"""Destroy a bridge."""
return self.delete(f"/bridges/{bridge_id}")
# =============================================================================
# Call Handler
# =============================================================================
class CallHandler:
"""Handles a single call through the IVR flow."""
# IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial)
MENU = {
"1": ("Sales", "PJSIP/1001"),
"2": ("Support", "PJSIP/1004"),
"3": ("Directory", None), # Sub-menu
"0": ("Operator", "PJSIP/1001"),
}
def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict):
self.ari = ari
self.channel_id = channel_id
self.caller_info = caller_info
self.state = "greeting"
self.dtmf_buffer = ""
self.attempts = 0
self.max_attempts = 3
self.bridge_id = None
# Track state in Redis
redis_client.hset(f"call:{channel_id}", mapping={
"state": self.state,
"caller_num": caller_info.get("number", "unknown"),
"caller_name": caller_info.get("name", "unknown"),
"start_time": str(int(time.time())),
})
def start(self):
"""Begin the IVR flow."""
log.info(f"Call {self.channel_id}: answering from {self.caller_info}")
self.ari.answer(self.channel_id)
time.sleep(0.5) # Brief pause after answer
self._play_greeting()
def _play_greeting(self):
"""Play the main greeting."""
self.state = "greeting"
self._update_state()
# Play greeting - uses built-in Asterisk sounds
# In production, replace with custom recordings
self.ari.play(self.channel_id, "sound:custom/welcome",
playback_id=f"greeting-{self.channel_id}")
def handle_dtmf(self, digit: str):
"""Process a DTMF digit."""
log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'")
if self.state in ("greeting", "waiting"):
self._process_menu_selection(digit)
elif self.state == "directory":
self._process_directory_input(digit)
def _process_menu_selection(self, digit: str):
"""Handle main menu DTMF selection."""
if digit in self.MENU:
description, endpoint = self.MENU[digit]
log.info(f"Call {self.channel_id}: selected '{description}'")
if endpoint:
self._transfer_to(endpoint, description)
elif digit == "3":
self.state = "directory"
self._update_state()
self.ari.play(self.channel_id,
"sound:dir-pls-enter-person",
playback_id=f"directory-{self.channel_id}")
elif digit == "#":
# Repeat menu
self._play_greeting()
else:
self.ari.play(self.channel_id, "sound:option-is-invalid",
playback_id=f"invalid-{self.channel_id}")
self.attempts += 1
if self.attempts >= self.max_attempts:
log.info(f"Call {self.channel_id}: max attempts, routing to operator")
self._transfer_to("PJSIP/1001", "Operator (timeout)")
def _transfer_to(self, endpoint: str, description: str):
"""Transfer the caller to an endpoint via a bridge."""
self.state = "transferring"
self._update_state()
try:
# Create a bridge
bridge = self.ari.create_bridge("mixing",
f"transfer-{self.channel_id}")
self.bridge_id = bridge["id"]
# Add the caller to the bridge
self.ari.add_to_bridge(self.bridge_id, self.channel_id)
# Play ringing tone to caller
self.ari.play(self.channel_id, "tone:ring",
playback_id=f"ring-{self.channel_id}")
# Originate a call to the target
target = self.ari.dial(self.channel_id, endpoint,
caller_id=self.caller_info.get("number", ""))
log.info(f"Call {self.channel_id}: dialing {endpoint} "
f"(bridge: {self.bridge_id})")
# Store transfer info in Redis
redis_client.hset(f"call:{self.channel_id}", mapping={
"state": "transferring",
"transfer_to": description,
"bridge_id": self.bridge_id,
})
except Exception as e:
log.error(f"Call {self.channel_id}: transfer failed: {e}")
self.ari.play(self.channel_id, "sound:an-error-has-occurred",
playback_id=f"error-{self.channel_id}")
def _process_directory_input(self, digit: str):
"""Handle directory (spell-by-name) input."""
if digit == "*":
# Go back to main menu
self.state = "greeting"
self._play_greeting()
return
self.dtmf_buffer += digit
if len(self.dtmf_buffer) >= 3:
# Look up by extension (simplified - real implementation would
# search by name using the keypad mapping)
ext = self.dtmf_buffer[:4]
self.dtmf_buffer = ""
log.info(f"Call {self.channel_id}: directory lookup for {ext}")
self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}")
def handle_playback_finished(self, playback_id: str):
"""Handle playback completion."""
if playback_id.startswith("greeting-"):
self.state = "waiting"
self._update_state()
# Wait for DTMF, replay after timeout handled by dialplan
def cleanup(self):
"""Clean up when the call ends."""
log.info(f"Call {self.channel_id}: cleanup")
if self.bridge_id:
try:
self.ari.destroy_bridge(self.bridge_id)
except Exception:
pass
redis_client.delete(f"call:{self.channel_id}")
def _update_state(self):
"""Update call state in Redis."""
redis_client.hset(f"call:{self.channel_id}", "state", self.state)
# =============================================================================
# WebSocket Event Handler
# =============================================================================
# Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS)
def on_message(ws, message):
"""Handle incoming WebSocket events from Asterisk."""
try:
event = json.loads(message)
event_type = event.get("type", "unknown")
channel = event.get("channel", {})
channel_id = channel.get("id", "")
if event_type == "StasisStart":
# New call entered our application
caller = channel.get("caller", {})
caller_info = {
"number": caller.get("number", "unknown"),
"name": caller.get("name", "unknown"),
}
log.info(f"StasisStart: {channel_id} from {caller_info}")
handler = CallHandler(ari, channel_id, caller_info)
calls[channel_id] = handler
# Start IVR in a separate thread to avoid blocking the event loop
threading.Thread(target=handler.start, daemon=True).start()
elif event_type == "StasisEnd":
# Call left our application
log.info(f"StasisEnd: {channel_id}")
handler = calls.pop(channel_id, None)
if handler:
handler.cleanup()
elif event_type == "ChannelDtmfReceived":
# DTMF digit received
digit = event.get("digit", "")
handler = calls.get(channel_id)
if handler:
handler.handle_dtmf(digit)
elif event_type == "PlaybackFinished":
# Audio playback completed
playback = event.get("playback", {})
playback_id = playback.get("id", "")
# Find which call this playback belongs to
target_channel = playback.get("target_uri", "").replace("channel:", "")
handler = calls.get(target_channel)
if handler:
handler.handle_playback_finished(playback_id)
elif event_type == "ChannelDestroyed":
# Channel was destroyed (hangup)
handler = calls.pop(channel_id, None)
if handler:
handler.cleanup()
except Exception as e:
log.error(f"Error handling event: {e}", exc_info=True)
def on_error(ws, error):
log.error(f"WebSocket error: {error}")
def on_close(ws, close_status_code, close_msg):
log.warning(f"WebSocket closed: {close_status_code} - {close_msg}")
def on_open(ws):
log.info("WebSocket connected to Asterisk ARI")
# =============================================================================
# Main
# =============================================================================
def main():
"""Connect to ARI WebSocket and handle events."""
log.info(f"Starting ARI Auto-Attendant")
log.info(f"ARI URL: {ARI_URL}")
log.info(f"Application: {ARI_APP}")
# Verify ARI is reachable
try:
info = ari.get("/asterisk/info")
log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}")
except Exception as e:
log.error(f"Cannot connect to ARI: {e}")
sys.exit(1)
# Build WebSocket URL
ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}"
# Connect with auto-reconnect
while True:
try:
ws = websocket.WebSocketApp(
ws_url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close,
)
ws.run_forever(ping_interval=30, ping_timeout=10)
except KeyboardInterrupt:
log.info("Shutting down...")
break
except Exception as e:
log.error(f"WebSocket connection failed: {e}")
log.info("Reconnecting in 5 seconds...")
time.sleep(5)
if __name__ == "__main__":
main()
Running the ARI App as a Container
Add to docker-compose.yml:
# ---------------------------------------------------------------------------
# ARI Application - Auto-Attendant
# ---------------------------------------------------------------------------
ari-app:
build:
context: ./ari-app
dockerfile: Dockerfile
container_name: asterisk-ari-app
restart: unless-stopped
networks:
- asterisk-net
environment:
- ARI_URL=http://asterisk:8088
- ARI_USERNAME=${ARI_USERNAME}
- ARI_PASSWORD=${ARI_PASSWORD}
- ARI_APP=autoattendant
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD}
depends_on:
asterisk:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
Create ari-app/Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
Testing ARI
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq .
# List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq .
# List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq .
# Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \
"http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>"
# Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
11. WebRTC Support
WebRTC lets users make and receive calls directly from a web browser — no SIP softphone needed. Asterisk acts as a WebRTC-to-SIP gateway, converting between browser-native WebRTC and traditional SIP/RTP.
Components Required
| Component | Role |
|---|---|
Asterisk res_pjsip_transport_websocket |
WebSocket transport for SIP signaling |
Asterisk res_http_websocket |
HTTP WebSocket server |
| DTLS-SRTP | Encrypted media (mandatory for WebRTC) |
| Coturn | TURN/STUN server for NAT traversal |
| Nginx | TLS reverse proxy (browsers require HTTPS/WSS) |
| SIP.js / JsSIP | JavaScript SIP library for the browser |
PJSIP WebSocket Transport
Already configured in Section 6 (transport-wss and endpoint-webrtc template). Key requirements:
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables:
; use_avpf = yes
; media_encryption = dtls
; dtls_verify = fingerprint
; dtls_setup = actpass
; ice_support = yes
; media_use_received_transport = yes
; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
Coturn Configuration
Create coturn/turnserver.conf:
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# =============================================================================
# Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349
# Relay port range (for media relay)
min-port=49152
max-port=65535
# External IP (your server's public IP)
external-ip=YOUR_SERVER_IP
# Realm
realm=YOUR_DOMAIN
# Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026!
# TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem
# Logging
log-file=stdout
verbose
# Performance
total-quota=100
stale-nonce=600
max-bps=1000000
# Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
# Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false
# Fingerprint for STUN messages (recommended)
fingerprint
# Enable channel binding lifetime management
channel-lifetime=600
Nginx WebSocket Proxy
Create nginx/nginx.conf:
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# =============================================================================
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
client_max_body_size 50M;
# WebSocket upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP -> HTTPS redirect
server {
listen 80;
server_name YOUR_DOMAIN;
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Redirect everything else to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name YOUR_DOMAIN;
# TLS certificates (from Let's Encrypt via certbot)
ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# ARI REST API proxy
location /ari/ {
proxy_pass http://asterisk:8088/ari/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ARI WebSocket proxy
location /ari/events {
proxy_pass http://asterisk:8088/ari/events;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# SIP WebSocket proxy (for WebRTC SIP.js clients)
location /ws {
proxy_pass http://asterisk:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Static web phone files (optional)
location / {
root /var/www/webphone;
index index.html;
try_files $uri $uri/ =404;
}
# Health check
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
}
Obtaining Let's Encrypt Certificates
Before starting the full stack, obtain certificates:
# Start just Nginx (for ACME challenge)
docker compose up -d nginx
# Request certificate
docker compose run --rm certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d YOUR_DOMAIN \
--email admin@YOUR_DOMAIN \
--agree-tos \
--no-eff-email
# Restart Nginx with the new certificate
docker compose restart nginx
Web Phone Client (SIP.js)
Create a simple web phone using SIP.js. This goes in nginx/webphone/index.html (or served separately):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Phone</title>
<script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; background: #1a1a2e;
}
.phone {
background: #16213e; border-radius: 20px; padding: 30px;
width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.display {
background: #0f3460; border-radius: 10px; padding: 15px;
margin-bottom: 20px; text-align: center;
}
.display .number {
color: #e94560; font-size: 24px; font-weight: bold;
min-height: 36px; word-break: break-all;
}
.display .status {
color: #a0a0a0; font-size: 12px; margin-top: 5px;
}
.keypad {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 10px; margin-bottom: 20px;
}
.key {
background: #0f3460; border: none; color: white;
font-size: 22px; padding: 18px; border-radius: 10px;
cursor: pointer; transition: background 0.2s;
}
.key:hover { background: #1a5276; }
.key:active { background: #e94560; }
.key .sub { display: block; font-size: 10px; color: #666; }
.actions { display: flex; gap: 10px; }
.btn {
flex: 1; padding: 15px; border: none; border-radius: 10px;
font-size: 16px; font-weight: bold; cursor: pointer;
}
.btn-call { background: #27ae60; color: white; }
.btn-call:hover { background: #2ecc71; }
.btn-hangup { background: #e74c3c; color: white; }
.btn-hangup:hover { background: #c0392b; }
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
.config {
margin-top: 15px; padding-top: 15px;
border-top: 1px solid #0f3460;
}
.config input {
width: 100%; padding: 8px; margin: 3px 0;
background: #0f3460; border: 1px solid #1a5276;
color: white; border-radius: 5px; font-size: 12px;
}
.config label { color: #666; font-size: 11px; }
.config button {
width: 100%; padding: 10px; margin-top: 8px;
background: #3498db; color: white; border: none;
border-radius: 5px; cursor: pointer; font-size: 13px;
}
audio { display: none; }
</style>
</head>
<body>
<div class="phone">
<div class="display">
<div class="number" id="display">Ready</div>
<div class="status" id="status">Not registered</div>
</div>
<div class="keypad">
<button class="key" onclick="press('1')">1<span class="sub"> </span></button>
<button class="key" onclick="press('2')">2<span class="sub">ABC</span></button>
<button class="key" onclick="press('3')">3<span class="sub">DEF</span></button>
<button class="key" onclick="press('4')">4<span class="sub">GHI</span></button>
<button class="key" onclick="press('5')">5<span class="sub">JKL</span></button>
<button class="key" onclick="press('6')">6<span class="sub">MNO</span></button>
<button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button>
<button class="key" onclick="press('8')">8<span class="sub">TUV</span></button>
<button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button>
<button class="key" onclick="press('*')">*</button>
<button class="key" onclick="press('0')">0<span class="sub">+</span></button>
<button class="key" onclick="press('#')">#</button>
</div>
<div class="actions">
<button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button>
<button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button>
</div>
<div class="config" id="configPanel">
<label>Server (WSS URL)</label>
<input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws">
<label>Extension</label>
<input id="cfgExt" value="1010" placeholder="1010">
<label>Password</label>
<input id="cfgPass" type="password" value="" placeholder="SIP password">
<label>TURN Server</label>
<input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478">
<label>TURN Password</label>
<input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret">
<button onclick="register()">Register</button>
</div>
<audio id="remoteAudio" autoplay></audio>
<audio id="localAudio" autoplay muted></audio>
</div>
<script>
let userAgent = null;
let currentSession = null;
let dialedNumber = '';
const display = document.getElementById('display');
const status = document.getElementById('status');
const btnCall = document.getElementById('btnCall');
const btnHangup = document.getElementById('btnHangup');
function press(digit) {
dialedNumber += digit;
display.textContent = dialedNumber;
// Send DTMF if in-call
if (currentSession) {
currentSession.sessionDescriptionHandler
.sendDtmf(digit);
}
}
function setStatus(text, isError = false) {
status.textContent = text;
status.style.color = isError ? '#e74c3c' : '#a0a0a0';
}
function register() {
const server = document.getElementById('cfgServer').value;
const ext = document.getElementById('cfgExt').value;
const pass = document.getElementById('cfgPass').value;
const turnServer = document.getElementById('cfgTurn').value;
const turnPass = document.getElementById('cfgTurnPass').value;
// Extract domain from WSS URL
const domain = new URL(server).hostname;
setStatus('Registering...');
const transportOptions = {
server: server,
keepAliveInterval: 30,
};
const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`);
const userAgentOptions = {
authorizationPassword: pass,
authorizationUsername: ext,
transportOptions: transportOptions,
uri: uri,
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: turnServer,
username: 'webrtc',
credential: turnPass,
}
]
}
},
delegate: {
onInvite: handleIncomingCall,
}
};
userAgent = new SIP.UserAgent(userAgentOptions);
const registerer = new SIP.Registerer(userAgent);
userAgent.start().then(() => {
registerer.register();
setStatus(`Registered as ${ext}`);
document.getElementById('configPanel').style.display = 'none';
}).catch(err => {
setStatus(`Registration failed: ${err.message}`, true);
console.error('Registration error:', err);
});
}
function handleIncomingCall(invitation) {
const caller = invitation.remoteIdentity.displayName ||
invitation.remoteIdentity.uri.user || 'Unknown';
display.textContent = `Incoming: ${caller}`;
setStatus('Ringing...');
// Auto-answer (in production, show accept/reject buttons)
currentSession = invitation;
setupSessionHandlers(invitation);
invitation.accept({
sessionDescriptionHandlerOptions: {
constraints: { audio: true, video: false }
}
});
btnCall.disabled = true;
btnHangup.disabled = false;
}
function makeCall() {
if (!userAgent || !dialedNumber) return;
const domain = userAgent.configuration.uri.host;
const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`);
setStatus(`Calling ${dialedNumber}...`);
const inviter = new SIP.Inviter(userAgent, target, {
sessionDescriptionHandlerOptions: {
constraints: { audio: true, video: false }
}
});
currentSession = inviter;
setupSessionHandlers(inviter);
inviter.invite();
btnCall.disabled = true;
btnHangup.disabled = false;
}
function setupSessionHandlers(session) {
session.stateChange.addListener((state) => {
switch (state) {
case SIP.SessionState.Establishing:
setStatus('Connecting...');
break;
case SIP.SessionState.Established:
setStatus('In Call');
// Attach remote audio
const remoteStream = new MediaStream();
session.sessionDescriptionHandler
.peerConnection.getReceivers()
.forEach(receiver => {
if (receiver.track) {
remoteStream.addTrack(receiver.track);
}
});
document.getElementById('remoteAudio').srcObject = remoteStream;
break;
case SIP.SessionState.Terminated:
setStatus('Call Ended');
display.textContent = 'Ready';
currentSession = null;
dialedNumber = '';
btnCall.disabled = false;
btnHangup.disabled = true;
break;
}
});
}
function hangUp() {
if (!currentSession) return;
switch (currentSession.state) {
case SIP.SessionState.Initial:
case SIP.SessionState.Establishing:
currentSession.cancel();
break;
case SIP.SessionState.Established:
currentSession.bye();
break;
}
currentSession = null;
dialedNumber = '';
display.textContent = 'Ready';
setStatus('Ready');
btnCall.disabled = false;
btnHangup.disabled = true;
}
// Clear display on double-click
display.addEventListener('dblclick', () => {
dialedNumber = '';
display.textContent = 'Ready';
});
</script>
</body>
</html>
Testing WebRTC
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss
# 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls"
# 3. Verify Coturn is running
docker compose logs coturn | tail -5
# 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN
# 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
12. Monitoring & Logging
A containerized Asterisk stack without monitoring is a black box. This section adds observability using the same tools from Tutorial 01 (Grafana + Prometheus + Loki).
Docker Health Checks
Already configured in docker-compose.yml. Verify:
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ...
# Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
Prometheus Exporter for Asterisk
Create a sidecar exporter that exposes Asterisk metrics. This is a simplified version of Tutorial 08's exporter.
Create asterisk/prometheus-exporter.py:
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics
Connects to Asterisk via AMI (local socket or TCP).
"""
import http.server
import os
import re
import socket
import time
AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200"))
def ami_command(action: str, **kwargs) -> str:
"""Send an AMI command and return the response."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((AMI_HOST, AMI_PORT))
# Read banner
sock.recv(4096)
# Login
login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n"
sock.send(login.encode())
sock.recv(4096)
# Send command
cmd = f"Action: {action}\r\n"
for k, v in kwargs.items():
cmd += f"{k}: {v}\r\n"
cmd += "\r\n"
sock.send(cmd.encode())
# Read response (with timeout)
response = b""
while True:
try:
data = sock.recv(4096)
if not data:
break
response += data
if b"\r\n\r\n" in data:
break
except socket.timeout:
break
# Logoff
sock.send(b"Action: Logoff\r\n\r\n")
sock.close()
return response.decode("utf-8", errors="replace")
except Exception as e:
return f"Error: {e}"
def collect_metrics() -> str:
"""Collect all metrics and return Prometheus text format."""
lines = []
# ---- Active channels ----
resp = ami_command("Command", Command="core show channels count")
match = re.search(r"(\d+) active channel", resp)
channels = int(match.group(1)) if match else 0
match2 = re.search(r"(\d+) active call", resp)
calls = int(match2.group(1)) if match2 else 0
lines.append("# HELP asterisk_active_channels Number of active channels")
lines.append("# TYPE asterisk_active_channels gauge")
lines.append(f"asterisk_active_channels {channels}")
lines.append("# HELP asterisk_active_calls Number of active calls")
lines.append("# TYPE asterisk_active_calls gauge")
lines.append(f"asterisk_active_calls {calls}")
# ---- PJSIP endpoints ----
resp = ami_command("Command", Command="pjsip show endpoints")
available = len(re.findall(r"Avail\b", resp))
unavailable = len(re.findall(r"Unavail\b", resp))
lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints")
lines.append("# TYPE asterisk_pjsip_endpoints_available gauge")
lines.append(f"asterisk_pjsip_endpoints_available {available}")
lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints")
lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge")
lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}")
# ---- Uptime ----
resp = ami_command("Command", Command="core show uptime seconds")
match = re.search(r"System uptime:\s+(\d+)", resp)
uptime = int(match.group(1)) if match else 0
match2 = re.search(r"Last reload:\s+(\d+)", resp)
reload_time = int(match2.group(1)) if match2 else 0
lines.append("# HELP asterisk_uptime_seconds System uptime in seconds")
lines.append("# TYPE asterisk_uptime_seconds gauge")
lines.append(f"asterisk_uptime_seconds {uptime}")
# ---- SIP registrations ----
resp = ami_command("Command", Command="pjsip show registrations")
registered = len(re.findall(r"Registered\b", resp))
unregistered = len(re.findall(r"Unregistered\b", resp))
lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count")
lines.append("# TYPE asterisk_pjsip_registrations_registered gauge")
lines.append(f"asterisk_pjsip_registrations_registered {registered}")
lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count")
lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge")
lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}")
# ---- ConfBridge conferences ----
resp = ami_command("Command", Command="confbridge list")
conferences = len(re.findall(r"Conference\s+\d+", resp))
lines.append("# HELP asterisk_confbridge_active Active conference rooms")
lines.append("# TYPE asterisk_confbridge_active gauge")
lines.append(f"asterisk_confbridge_active {conferences}")
return "\n".join(lines) + "\n"
class MetricsHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler for /metrics endpoint."""
def do_GET(self):
if self.path == "/metrics":
metrics = collect_metrics()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.end_headers()
self.wfile.write(metrics.encode())
elif self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK\n")
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress access logs
if __name__ == "__main__":
print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}")
server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler)
server.serve_forever()
AMI Configuration for Exporter
Create asterisk/configs/manager.conf.template:
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; =============================================================================
[general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0
[${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
Promtail Sidecar for Log Shipping
Add Promtail to the Docker Compose stack to ship Asterisk logs to Loki:
# ---------------------------------------------------------------------------
# Promtail - Log Shipping to Loki
# ---------------------------------------------------------------------------
promtail:
image: grafana/promtail:latest
container_name: asterisk-promtail
restart: unless-stopped
networks:
- asterisk-net
volumes:
- asterisk-logs:/var/log/asterisk:ro
- ./promtail/config.yml:/etc/promtail/config.yml:ro
command: -config.file=/etc/promtail/config.yml
deploy:
resources:
limits:
cpus: '0.25'
memory: 128M
Create promtail/config.yml:
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# =============================================================================
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
# Point to your Loki instance
- url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push
scrape_configs:
# Asterisk messages log
- job_name: asterisk-messages
static_configs:
- targets:
- localhost
labels:
job: asterisk
host: asterisk-docker
__path__: /var/log/asterisk/messages
pipeline_stages:
- regex:
expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$'
- labels:
level:
module:
- timestamp:
source: timestamp
format: "2006-01-02 15:04:05.000"
# Asterisk security log
- job_name: asterisk-security
static_configs:
- targets:
- localhost
labels:
job: asterisk-security
host: asterisk-docker
__path__: /var/log/asterisk/security
Grafana Dashboard
Import or create a Grafana dashboard with these panels:
| Panel | Query | Visualization |
|---|---|---|
| Active Calls | asterisk_active_calls |
Stat |
| Active Channels | asterisk_active_channels |
Stat |
| PJSIP Endpoints Available | asterisk_pjsip_endpoints_available |
Stat |
| Calls Over Time | rate(asterisk_active_calls[5m]) |
Time Series |
| Uptime | asterisk_uptime_seconds / 86400 |
Stat (days) |
| Container CPU | rate(container_cpu_usage_seconds_total{name="asterisk"}[5m]) |
Time Series |
| Container Memory | container_memory_usage_bytes{name="asterisk"} |
Time Series |
| Asterisk Logs | Loki query: {job="asterisk"} |= "WARNING" or "ERROR" |
Logs |
13. Scaling & High Availability
A single Asterisk container handles hundreds of concurrent calls. But when you need more — multi-tenant hosting, geographic redundancy, or zero-downtime upgrades — you need scaling patterns.
Architecture: Multiple Asterisk Instances Behind Kamailio
┌──────────────┐
│ Kamailio │ SIP Load Balancer / Proxy
│ (SIP Proxy) │ - Distributes calls by hash or weight
└──────┬───────┘ - Health checks Asterisk instances
│ - Handles registration (single point)
┌────────────┼────────────┐
│ │ │
┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐
│ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │
│ (Container)│ │ (Container)│ │ (Container)│
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
┌─────▼───────────────▼───────────────▼─────┐
│ Shared Services │
│ MariaDB (Primary + Replica) │
│ Redis Cluster │
│ NFS/S3 (Recordings) │
└────────────────────────────────────────────┘
Docker Compose for Scaled Asterisk
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3
services:
asterisk:
build: ./asterisk
# NO ports mapping - Kamailio handles external traffic
networks:
- asterisk-net
environment:
- PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
- DB_HOST=mariadb-primary
# Each instance gets unique name via Docker
volumes:
- ./asterisk/configs:/etc/asterisk/templates:ro
- recordings-nfs:/var/spool/asterisk/monitor
depends_on:
- mariadb-primary
- redis
deploy:
replicas: 3
resources:
limits:
cpus: '2'
memory: 2G
kamailio:
image: kamailio/kamailio:latest
ports:
- "5060:5060/udp"
- "5060:5060/tcp"
- "5061:5061/tcp"
volumes:
- ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro
networks:
- asterisk-net
mariadb-primary:
image: mariadb:11.4
environment:
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MARIADB_DATABASE=${DB_NAME}
- MARIADB_USER=${DB_USER}
- MARIADB_PASSWORD=${DB_PASSWORD}
- MARIADB_REPLICATION_MODE=master
volumes:
- mariadb-primary-data:/var/lib/mysql
networks:
- asterisk-net
mariadb-replica:
image: mariadb:11.4
environment:
- MARIADB_REPLICATION_MODE=slave
- MARIADB_MASTER_HOST=mariadb-primary
- MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
volumes:
- mariadb-replica-data:/var/lib/mysql
depends_on:
- mariadb-primary
networks:
- asterisk-net
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- redis-data:/data
networks:
- asterisk-net
volumes:
mariadb-primary-data:
mariadb-replica-data:
redis-data:
recordings-nfs:
driver: local
driver_opts:
type: nfs
o: "addr=NFS_SERVER_IP,nolock,soft,rw"
device: ":/exports/asterisk-recordings"
networks:
asterisk-net:
driver: bridge
Shared State with Redis
When running multiple Asterisk instances, use Redis for shared state:
# Example: Track active calls across all instances
import redis
import json
r = redis.Redis(host='redis', port=6379, password='...')
# When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id):
r.hset(f"active_call:{call_id}", mapping={
"caller": caller,
"callee": callee,
"instance": instance_id,
"start_time": str(time.time()),
})
r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL
# Query active calls across all instances
def get_active_calls():
calls = []
for key in r.scan_iter("active_call:*"):
call = r.hgetall(key)
calls.append(call)
return calls
Recording Storage with NFS or S3
When running multiple Asterisk instances, recordings must go to a shared filesystem:
Option A: NFS Mount
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
Option B: S3-Compatible Storage
Use a post-recording script to upload recordings to S3:
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID})
RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings"
if [ -f "$RECORDING_FILE" ]; then
aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \
--storage-class STANDARD_IA
# Optionally delete local copy after upload
# rm "$RECORDING_FILE"
fi
Blue-Green Deployments
Deploy new Asterisk versions without dropping calls:
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/
# 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk
# 3. Verify the new container is healthy
docker compose ps
# 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally
# 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk
# 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
14. Production Checklist & Troubleshooting
Pre-Deployment Checklist
Use this checklist before going live:
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container)
NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket
STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes)
PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file)
MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana
OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
Container Security Hardening
# Add to the asterisk service in docker-compose.yml:
asterisk:
# ... existing config ...
security_opt:
- no-new-privileges:true
read_only: true # Read-only root filesystem
tmpfs:
- /tmp:size=100M
- /var/run/asterisk:size=10M
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Bind to ports < 1024 (5060)
- SYS_NICE # Set process priority (for real-time audio)
- NET_RAW # For ICMP (qualify/keepalive)
Resource Limits Reference
| Deployment Size | Concurrent Calls | CPU | RAM | RTP Range |
|---|---|---|---|---|
| Small (home/dev) | 5-10 | 1 core | 512 MB | 10000-10050 |
| Medium (SMB PBX) | 20-50 | 2 cores | 1 GB | 10000-10200 |
| Large (business PBX) | 50-200 | 4 cores | 2 GB | 10000-11000 |
| Carrier/hosting | 200-1000 | 8+ cores | 4+ GB | 10000-20000 |
Common Issues and Solutions
Issue: No Audio (One-Way or Both Ways)
Symptom: Call connects, you can see the call in the CLI, but no audio.
Root cause: Almost always NAT/RTP misconfiguration.
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external
# Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000
# Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20
# Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media
# Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
Issue: Registration Failures
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports"
# Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response
# Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001"
# Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP
# Check 5: Firewall
iptables -L -n | grep 5060
Issue: Codec Negotiation Failure
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow
# Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs"
# Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus"
# Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
Issue: DNS Resolution Inside Container
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com
# Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf
# Fix: Add explicit DNS servers in docker-compose.yml
asterisk:
dns:
- 8.8.8.8
- 1.1.1.1
Issue: Container Keeps Restarting
# Check logs for crash reason
docker compose logs --tail=50 asterisk
# Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed"
# Check resource limits
docker stats asterisk --no-stream
# Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
Issue: Slow Container Startup with Large Port Range
Symptom: docker compose up takes 30+ seconds for the asterisk container.
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules.
# Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp"
# Fix 2: Use host networking (no port mapping needed)
# network_mode: host
# Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
Useful Operational Commands
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes
# ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload"
# ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \
-e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;"
# ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on"
# ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version
# ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups
File Reference
| File | Purpose |
|---|---|
docker-compose.yml |
Service definitions, networks, volumes |
.env |
Environment variables (passwords, IPs, domains) |
asterisk/Dockerfile |
Multi-stage build for Asterisk 21 |
asterisk/entrypoint.sh |
Config templating and startup |
asterisk/configs/pjsip.conf.template |
SIP transports, endpoints, trunks |
asterisk/configs/extensions.conf.template |
Dialplan (IVR, ring groups, routing) |
asterisk/configs/modules.conf |
Module loading (load only what's needed) |
asterisk/configs/rtp.conf |
RTP port range |
asterisk/configs/http.conf.template |
HTTP/ARI server |
asterisk/configs/ari.conf.template |
ARI authentication |
asterisk/configs/manager.conf.template |
AMI configuration |
asterisk/configs/res_odbc.conf.template |
ODBC connection pool |
asterisk/configs/odbc.ini.template |
ODBC DSN definition |
asterisk/configs/cdr.conf |
CDR settings |
asterisk/configs/cdr_adaptive_odbc.conf |
CDR to database mapping |
asterisk/configs/voicemail.conf.template |
Voicemail boxes |
asterisk/configs/confbridge.conf |
Conference bridge profiles |
asterisk/configs/sorcery.conf |
Realtime database mapping |
asterisk/configs/extconfig.conf |
External config mapping |
mariadb/init/01-schema.sql |
Database schema (CDR, PJSIP realtime, voicemail) |
nginx/nginx.conf |
Reverse proxy, TLS, WebSocket |
coturn/turnserver.conf |
TURN/STUN for WebRTC |
ari-app/app.py |
ARI auto-attendant application |
scripts/backup.sh |
Full stack backup |
scripts/restore.sh |
Restore from backup |
scripts/cleanup-recordings.sh |
Recording retention cleanup |
What's Next: Tutorial 39 covers Asterisk security hardening — fail2ban integration, SIP TLS enforcement, SRTP, intrusion detection, and GeoIP-based call filtering for fraud prevention.