A self-hosted PBX for a small private network, packaged as a Docker image. Built on Asterisk 23.3.0 (compiled from the upstream tarball inside the Dockerfile; multistage build on Debian 13 Trixie, ~370 MB final image).
Intended use case is a handful of phones at home, mixing:
- softphones on laptops/desktops (Linphone, MicroSIP, Zoiper, pjsua …),
- analogue phones via SIP-to-FXS adapters (Grandstream HT812, Cisco SPA122, Cisco ATA191-MPP — see HARDWARE.org for per-device setup),
- a mobile SIP client reaching the PBX over TLS+SRTP.
The [softphone], [ata] and [mobile] endpoint templates in
etc-asterisk/pjsip.conf cover those three cases; adding a new phone
is a ~6-line copy-paste. All configuration lives in etc-asterisk/
and is bind-mounted live — edit a file, reload (pjsip reload /
dialplan reload / module reload from the Asterisk console), no
image rebuild needed.
See IDEAS.org for a brainstorm of directions to take this further — feature codes, IVR, conferencing, home-automation hooks, hardware to add, ops & security.
Build and run via the justfile:
just # list recipes
just build # build the docker image
just run # run with ./etc-asterisk bind-mounted into the container
just lint # hadolint the Dockerfile
just config-test # boot Asterisk briefly and fail on ERROR lines
just push # push to the registry
just all # build + pushAlternatively use docker compose (adds persistent volumes for
/var/lib/asterisk and /var/log/asterisk):
docker compose up --buildWhy compose rather than just run for the persistent deployment:
restart: unless-stoppedbrings Asterisk back after a reboot or a crash, without needing a systemd unit.stop_signal: SIGINT— Asterisk only shuts down cleanly on SIGINT. Docker’s default SIGTERM cuts in-flight calls and half-writes voicemail recordings; compose handles this sodocker compose downis safe.- Named volumes for
/var/spool/asterisk(voicemail, MoH),/var/lib/asterisk(CDRs, astdb) and/var/log/asterisksurvive container recreation —just rundrops all of those on exit. - Healthcheck via
core waitfullybooted, sodocker psshowshealthyonce the dialplan is loaded and the AMI/CLI socket is up.
The foreground PID 1 inside the container is asterisk -cvvv, whose
stdin is owned by compose. Don’t docker attach: a stray Ctrl-C
would SIGINT PID 1 and shut Asterisk down. Open a remote console
over the CLI socket instead — it’s a separate connection, safe to
disconnect:
docker compose exec asterisk asterisk -rvvvExit with exit or Ctrl-D. For one-shot commands (same mechanism
the healthcheck uses):
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "pjsip set logger on"The SIP-TLS transport (port 5061, configured in
etc-asterisk/pjsip.conf under [transport-tls]) expects a cert and
key under etc-asterisk/keys/. Generate a fresh self-signed pair with:
just gen-keys # CN=asterisk.local, valid 10 years
just gen-keys pbx.example.lan 365 # custom CN and validity (days)This writes two files into etc-asterisk/keys/ (both gitignored):
| File | Role | Where it lives |
|---|---|---|
asterisk.key | Private key | Server only — never copy off the box |
asterisk.pem | Public cert | Distribute to TLS clients that need to trust it |
A self-signed cert is not in any client’s default trust store, so each SIP-TLS client must either:
- Import
asterisk.pemas a trusted root, or - Disable certificate verification (fine for a homelab; not for prod).
In pjsua, --use-tls turns on TLS; trust-store flags vary by build —
see pjsua --help for --ca-list.
To reach the PBX over Tailscale from remote clients, install and authenticate Tailscale on the host machine with DNS interception disabled:
tailscale up --accept-dns=falseThe --accept-dns=false flag is critical for SIP: Tailscale’s DNS rewriting
can interfere with SIP’s use of DNS SRV records and host lookups, causing
registration and routing failures. Disable it to let the system use its
configured resolvers.
Important: The Asterisk container uses network_mode: host (in compose) or
--network host (via just run) rather than Docker bridge networking. This is
required for SIP — the protocol embeds the server’s IP address in SDP payloads,
and container-to-host address translation breaks SIP signaling between remote
clients and the PBX. With host networking, Asterisk binds directly on the VM’s
interfaces, where Tailscale routes to it transparently.
Remote clients on the same Tailscale network can then register to the PBX at
its Tailscale IP (e.g., sip:6001@100.x.x.x;transport=tls) with no additional
firewall configuration.
Run these inside the running Asterisk console (the -cvvv foreground
process). To get a console against a container started detached or
via compose:
docker exec -it asterisk asterisk -rvvv # compose (container_name: asterisk)
docker exec -it $(docker ps -qf ancestor=ghcr.io/ast/wjmasterisk) asterisk -rvvv # just runYou can also fire a single command without an interactive console
using asterisk -rx "<command>".
etc-asterisk/ is bind-mounted live — edit a file, then reload the
relevant subsystem:
dialplan reload # after editing extensions.conf
pjsip reload # after editing pjsip.conf (endpoints, transports)
module reload # reload everything that supports it
voicemail reload # after editing voicemail.conf
moh reload # after editing musiconhold.conf
core reload # broad reload — most .conf files at oncepjsip show endpoints # every endpoint + its current state
pjsip show endpoint 6010 # one endpoint in full detail
pjsip show aors # AORs and their configured contacts
pjsip show contacts # who is actually registered right now
pjsip show registrations # outbound registrations (if any)
pjsip show transports # the UDP/TCP/TLS listenerscore show channels # active channels (one row per call leg)
core show channels verbose # same, with more columns
core show channel <name> # deep detail on one channel
hangup request <name> # drop a stuck callpjsip set logger on # dump every SIP packet to the console
pjsip set logger off
rtp set debug on # log RTP flow — use for one-way-audio bugs
rtp show settings # the configured RTP port range
core set verbose 5 # more chatty console
core set debug 3 # internal debug loggingcore show codecs # codecs this build knows about
core show uptime # how long Asterisk has been up
core show settings # version, paths, channel counts
core waitfullybooted # blocks until startup is complete (used by the healthcheck)Configuration notes for the analogue-phone adapters live in HARDWARE.org — common ATA settings plus per-device SIP field tables for the Cisco SPA122 and ATA191-MPP. The primary adapter, the Grandstream HT812, has its own full walkthrough in HT812.org (network discovery, web-UI fields, rotary-phone support, gotchas).
Reference: https://docs.pjsip.org/en/latest/specific-guides/other/cli_cmd.html
sudo apt install build-essential libasound2-dev libssl-dev libncurses5-dev \
libreadline-dev libavcodec-dev libavformat-dev libswscale-dev libopus-dev
git clone https://github.com/pjsip/pjproject.git
cd pjproject
./configure --enable-shared
make dep
make -j$(nproc)The compiled apps land in pjproject/pjsip-apps/bin/.
PBX points at the Asterisk host. Default 127.0.0.1 assumes pjsua
runs on the same machine as the PBX; override for other hosts, e.g.
PBX=192.168.1.16 or PBX=asterisk.local.
To actually call between two extensions you need **two pjsua processes
running side by side**, one registered as each extension. They use
different --local-port values so they don’t fight for the same UDP
port. Open two terminals and run one block in each:
Terminal 1 — register as 6002:
PBX=${PBX:-127.0.0.1}
pjsua --id sip:6002@$PBX \
--registrar sip:$PBX \
--realm '*' \
--username 6002 \
--password 6002 \
--local-port=5063
# --use-tls # add for SIP-TLS on 5061Terminal 2 — register as 6001:
PBX=${PBX:-127.0.0.1}
pjsua --id sip:6001@$PBX \
--registrar sip:$PBX \
--realm '*' \
--username 6001 \
--password 6001 \
--local-port=5062Verify both are registered (in a third terminal, against the running
Asterisk container) — both endpoints should show Available with a
contact listed:
docker exec -it $(docker ps -qf ancestor=ghcr.io/ast/wjmasterisk) \
asterisk -rx "pjsip show endpoints"Once pjsua is running you get an interactive prompt. The most useful single-letter commands:
| Key | What it does |
|---|---|
m | Make a call (prompts for the destination URI) |
a | Answer the incoming call with 200 OK |
g | Answer with a specific code (200 answer, 486 busy, 603 decline) |
h | Hang up the current call (ha hangs up all) |
H | Hold the call |
v | re-inVite — release hold / resume |
] [ | Switch to next / previous call (when more than one is active) |
x | Blind transfer the current call |
# | Send DTMF (e.g. to enter a voicemail PIN, or dial *97, *43) |
dq | Dump current call quality (jitter, loss, RTT) — best audio-debug tool |
d | Dump status (accounts, registrations, active calls) |
Cp | Show / change codec priorities |
V | Adjust audio volume |
rr | Re-register the account (useful when toggling network) |
q | Quit |
The full menu is printed any time you press ? at the prompt.
Press m, then type the destination URI at the Make call: prompt.
This is pjsua’s own CLI — not a shell — so the $PBX variable used
above is NOT expanded here. Substitute the actual address (or
127.0.0.1 when pjsua is on the same machine as Asterisk):
sip:6001@127.0.0.1;transport=udp ; another softphone sip:6010@127.0.0.1;transport=udp ; an ATA line sip:*43@127.0.0.1;transport=udp ; echo test — repeat your voice sip:*44@127.0.0.1;transport=udp ; music on hold — one-client audio test sip:*97@127.0.0.1;transport=udp ; check your own voicemail
(If you set PBX=192.168.1.16 when starting pjsua, use that IP in the
URIs above. The variable substitution happens only in the shell command
that launches pjsua, not inside pjsua’s prompt.)
To answer an incoming call, press a. To reject with a specific status,
press g and pick:
200 → answer 486 → busy 603 → decline
*43from any phone — the echo test will repeat your voice back. If you hear yourself, the audio path is healthy in both directions.- Park / unpark via
x(transfer) to a parking lot extension (not configured by default — would needres_parking.soloaded and aparkextinfeatures.conf). dqduring a call: watch the jitter and packet-loss counters when moving the mobile client between WiFi and cellular.- Change codec priority with
Cpand observe negotiation in the Asterisk CLI withpjsip set logger on→ look for the m= line in the SDP.
| Field | Value |
|---|---|
| Username | 6002 (or any configured extension) |
| Display name | 6002 (optional) |
| SIP Domain | asterisk (Tailscale hostname) |
| Password | same as username (default) |
| Transport | UDP |
The SIP domain should be the Tailscale hostname of the Asterisk VM. If the
short hostname does not resolve, use the full Tailscale domain
(asterisk.<tailnet>.ts.net).
Before dialling, confirm the 6002 account is selected as the active account in
Linphone (shown in the top bar or account switcher). If another account or the
system identity is active, Asterisk will reject the call with “No matching
endpoint found”.
Dial by typing the extension directly (Linphone fills in the domain from the active account):
| Destination | What it does |
|---|---|
*43 | Echo test — repeats your voice back |
*44 | Music on hold — one-way audio test |
*97 | Voicemail |
6001 | Another softphone (must be registered) |
| Field | Value |
|---|---|
| Username | 6003 (or any configured extension) |
| Display name | 6003 (optional) |
| Domain | asterisk.local (or IP/Tailscale address) |
| Password | same as username (default) |
| Transport | UDP (or TLS for 5061) |
Launch Zoiper and select “Add account”. Enter the SIP credentials above.
For remote access via Tailscale, use the Tailscale IP or hostname
(e.g., 100.x.x.x) as the domain.
Dial directly in the keypad or contact list. The same test extensions work:
| Destination | What it does |
|---|---|
*43 | Echo test — repeats your voice back |
*44 | Music on hold — one-way audio test |
*97 | Voicemail |
6001 | Another softphone (must be registered) |
Download from microsip.org. Launch and go to Settings → Accounts:
| Field | Value |
|---|---|
| Display name | 6004 |
| User name | 6004 (or any configured extension) |
| Server | asterisk.local (or IP/Tailscale address) |
| Password | same as username (default) |
| Transport | UDP (leave blank) or TLS for 5061 |
Apply, and MicroSIP will register automatically.
Double-click a contact or type directly in the dial field. Same test extensions as other softphones:
| Destination | What it does |
|---|---|
*43 | Echo test — repeats your voice back |
*44 | Music on hold — one-way audio test |
*97 | Voicemail |
6001 | Another softphone (must be registered) |