Cheap Phone Service and Whole-House Intercom with FreePBX + SignalWire
- 10 minutes read - 2055 wordsBusiness phone service — RingCentral and the like — runs $20–35 per seat per month, locks your desk phones to their firmware, and puts your dial tone at the mercy of someone else’s cloud. I replaced all of it with a self-hosted PBX that costs a few dollars a month in actual usage, keeps every phone on hardware I own, and adds something the commercial offerings either charge extra for or simply don’t do: push-to-talk intercom between rooms. Dial the kitchen, it answers itself on speaker, you talk.
The whole thing is four Docker containers on one host, one DNS record on another, and a single SIP trunk in the cloud. Everything else is a text file.
A note on the examples: this runs on my real network with real family phone numbers on it. Every phone number, personal name, and account identifier below is synthetic — invented for illustration and drawn from the
555-01xxrange reserved for fiction. The configuration is real and copied verbatim from the running system; only the personal data is swapped. If you build one of these, keep your own credentials and family numbers out of your blog. I’m practicing what I preach.
The stack #
| Component | Role |
|---|---|
| FreePBX / Asterisk 20 (Docker) | the PBX brain — extensions, routing, dialplan |
| SignalWire | pay-as-you-go SIP trunk for real PSTN phone numbers |
| Yealink T4x desk phones | auto-provisioned off a tiny nginx server |
| Pi-hole | supplies the one DNS record that makes zero-touch provisioning work |
| Tailscale | private remote admin, nothing exposed to the internet |
It’s spread over three machines: a Docker host running the PBX and its helpers, a Pi-hole box for local DNS, and a workstation I used for the phone-unlocking side quest (more on that later).
Part 1 — The trunk: real phone numbers for pennies #
The connection to the outside phone network is a single SignalWire SIP trunk. In Asterisk’s PJSIP terms it’s one endpoint that registers outbound to SignalWire and carries every inbound and outbound PSTN call. Here’s the real trunk config from the box, with the account subdomain masked:
[signalwire_sip]
type=endpoint
context=from-signalwire
disallow=all
allow=ulaw,alaw
outbound_auth=signalwire_sip
from_domain=yourspace-xxxxxxxx.sip.signalwire.com
from_user=deskline
media_encryption=sdes ; SRTP — media is encrypted
rtp_symmetric=yes
dtmf_mode=rfc4733
direct_media=no
Two things worth copying:
media_encryption=sdes— SignalWire supports SRTP, so the audio leaving the LAN is encrypted rather than cleartext RTP. Cheap doesn’t have to mean insecure.- The trunk registers outbound. Inbound calls arrive over that established session, which means there is no open SIP port on my network for the internet to hammer.
Live status, straight from the PBX:
$ asterisk -rx "pjsip show registrations"
signalwire_sip/sip:yourspace-xxxxxxxx.sip.signalwire.com Registered (exp. 199s)
The economics are the entire point. SignalWire bills per-minute usage plus a small monthly charge per phone number. For a home/office with a few extensions and light call volume, that’s an order of magnitude less than per-seat SaaS — and there’s no seat count to manage at all.
The first-call ghost story #
Debugging the first outbound call is a rite of passage. Dialing the new number from a cell phone returned “the number you have dialed is not in service” — yet SignalWire’s own call inspector showed the call completing with a perfect score:
Direction Outbound MOS Score 4.50
From +1 (555) 0100 Quality Percentage 100.0%
To +1 (555) 0177 Outbound Packets Lost 0
Duration 6 seconds
Carrier says dead, metrics say flawless. That contradiction is the classic fingerprint of a dialplan problem, not a trunk problem: the audio path was perfect, the inbound number just wasn’t being routed anywhere yet. Which brings us to the interesting part.
Part 2 — Turning the dialplan into a firewall #
FreePBX gives you friendly GUI building blocks — Inbound Routes, Ring Groups, an IVR — and for anything they can’t express, a hand-written Asterisk dialplan in extensions_custom.conf. This build leans on both, and the custom contexts are where it gets fun: I use the dialplan as a call-level firewall.
The normal path (built in the GUI) #
The main business number answers with an IVR, which routes to a ring group that tries the desk phone, then the mobile softphone, in sequence:
Main number ──► IVR "Main-IVR" ──► press 1 ──► main extension
└──── ring group "Desk then Mobile" (hunt, 20s)
Nothing exotic — this is FreePBX doing exactly what it’s good at.
The locked-down path (hand-written) #
A second phone number is assigned to one specific family member and is deliberately boxed in: it may only be dialed by three approved cell numbers, and the phone on that line may only dial out to those same three numbers. That’s not a FreePBX checkbox — it’s dialplan. Here’s the real inbound guard (numbers synthetic):
[from-signalwire]
exten => s,1,Set(numb=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
same => n,GotoIf($["${numb:-10}" = "3035550177"]?checkline)
same => n,Goto(from-pstn,${numb:1},1)
same => n(checkline),GotoIf($["${CALLERID(num):-10}" = "7205550149"]?ringline)
same => n,GotoIf($["${CALLERID(num):-10}" = "7205550188"]?ringline)
same => n,GotoIf($["${CALLERID(num):-10}" = "7205550132"]?ringline)
same => n,Noop(Rejected call from ${CALLERID(num)})
same => n,Hangup(21) ; 21 = "call rejected"
same => n(ringline),Goto(did-line,s,1)
And the matching outbound cage for that extension:
[from-104]
exten => 101,1,Goto(ext-local,101,1) ; internal calls always allowed
exten => 102,1,Goto(ext-local,102,1)
exten => 103,1,Goto(ext-local,103,1)
exten => _X.,1,Set(num10=${EXTEN:-10})
same => n,GotoIf($["${num10}" = "7205550149"]?pstn)
same => n,GotoIf($["${num10}" = "7205550188"]?pstn)
same => n,GotoIf($["${num10}" = "7205550132"]?pstn)
same => n,Noop(Blocked outbound: ${EXTEN})
same => n,Hangup(21) ; anything else: rejected
same => n(pstn),Set(CALLERID(num)=13035550177)
same => n,Set(CALLERID(name)=Maya Alvarez) ; per-extension outbound caller ID
same => n,Dial(PJSIP/1${num10}@signalwire_sip,30)
Two patterns worth stealing:
- CID whitelisting with
Hangup(21). Comparing${CALLERID(num):-10}(the last 10 digits) sidesteps the whole+1/1/ bare-10-digit formatting mess. Rejected callers get SIP cause 21 — a proper “rejected” signal back to the carrier — instead of a silent drop. - Per-extension caller ID name in two lines. Commercial providers make you pay for or fight a portal to set outbound caller ID name. Here this line presents “Maya Alvarez,” while the main line presents the business name. It’s just two
Set()statements.
Part 3 — The intercom (the reason I built this) #
This is the feature that justifies the whole project. Any phone can dial another room and have it answer automatically over the speaker — nobody has to pick up. It’s a building-wide PA/interphone made out of ordinary SIP desk phones.
Auto-answer is one SIP header #
There’s a myth that intercom needs special hardware. It doesn’t. It needs one header. When Asterisk rings the destination, it adds:
[ext-local](+)
exten => 103,1,Set(PJSIP_HEADER(add,Alert-Info)=<http://127.0.0.1>;info=alert-autoanswer;delay=0)
same => n,Dial(PJSIP/103,20)
Alert-Info: ...info=alert-autoanswer tells the phone “don’t ring — answer now.” FreePBX ships a translation table of every vendor’s spelling of this, which is why the same feature works across brands. Pulled live from the PBX’s paging_autoanswer table:
| Phone brand | Alert-Info value |
|---|---|
| Polycom | info=Auto Answer |
| Panasonic | Intercom |
| Sangoma / Digium | ring-answer |
| Grandstream / generic | <uri>;answer-after=0 |
On the handset side, the Yealinks are told to trust it, straight from their provisioning file:
features.intercom.allow = 1
features.intercom.auto_answer = 1
features.intercom.tone_enable = 1 ; a short beep before the mic opens
FreePBX also exposes feature codes to control it per handset — verified on the running system:
*80 intercom-prefix dial *80<ext> to force an intercom call
*54 intercom-on allow this phone to receive intercom
*55 intercom-off do-not-disturb for intercom
So “multi-room intercom” is simply: dial the room’s extension (or *80 + extension), the far phone auto-answers on speaker, you talk. No cloud, no app, pure LAN SIP — the audio never leaves the switch, so latency is single-digit milliseconds.
A phone that cannot call the outside world #
One handset sits in a common area and is set up so it physically cannot place a PSTN call. If a kid or a guest picks it up and dials a number, nothing happens. Dialplan does the enforcing:
[from-intercom]
exten => 101,1,Goto(ext-local,101,1) ; can reach the two main extensions
exten => 102,1,Goto(ext-local,102,1)
exten => _X.,1,Noop(Blocked outbound attempt from intercom phone)
same => n,Hangup(21) ; everything else: dead end
The trick is the endpoint’s context=from-intercom. In Asterisk, an extension’s context is its permission set — this phone lives in a context that simply has no route to the trunk, so no amount of dialing can reach the PSTN. That’s the correct way to build a lobby phone or a kids’ phone: the capability is removed at the switch, not hidden behind a menu that a determined seven-year-old will find.
Part 4 — Zero-touch provisioning (and freeing carrier-locked phones) #
Configuring desk phones by hand is miserable. This setup provisions them automatically — but getting there first meant breaking the phones out of a previous carrier’s lock.
The provisioning server is deliberately tiny #
An nginx:alpine container serving one directory of per-MAC config files:
server {
listen 80;
server_name yllocalserver _;
root /usr/share/nginx/html;
location / { try_files $uri =404; autoindex off; }
}
html/
<mac-address>.cfg # one config file per phone, keyed by MAC
Contacts.xml # shared directory pushed to every handset
Yealink phones, on boot, request http://<their-MAC>.cfg from their provisioning URL. Point them here once and every future change is a file edit plus a reboot.
The one DNS record that ties it together #
The phones are told their provisioning server is literally http://yllocalserver — no IP address. That name is resolved by Pi-hole to the provisioning host. This is why a DNS box belongs in a phone-system writeup: it’s the local DNS authority, so a single custom A-record turns a hardcoded hostname into zero-touch onboarding — and relocating the provisioning server later is a one-line DNS change instead of re-touching every phone.
$ nslookup yllocalserver <pi-hole>
Name: yllocalserver
Address: <provisioning host>
The carrier lock nobody warns you about #
These Yealinks came locked to a previous provider, and a fresh one would not register no matter how correct the SIP settings looked. Two mechanisms were fighting the config, both found by pointing the phones’ syslog at a host and actually reading it:
A phantom VPN. The old provider’s provisioning had set
network.vpn_enable = 1pointing at a VPN that no longer existed. Every SIP packet was being routed into a dead tunnel interface and silently black-holed. The fix, baked permanently into the local provisioning config:## Disable VPN – was static-locked from previous carrier provisioning, ## routing SIP through a dead VPN interface and blocking all registration. static.network.vpn_enable = 0A hardcoded provisioning URL still aimed at the old provider’s servers, overridden locally:
static.auto_provision.server.url = http://yllocalserver auto_provision.power_on = 1
Reading the phone’s own debug syslog was the whole key — it showed the handset loading up.pem 802.1x certs and leftover openvpn.url keys from the previous carrier, which is how the dead-VPN theory got confirmed rather than guessed at. (The deeper firmware-unlock rabbit hole — carrier-specific version octets, root shell, dumping flash partitions — is its own post.)
Part 5 — Security and remote access #
Because this system has a real phone number, it’s a target the moment it’s reachable. Two layers handle that:
- Nothing is port-forwarded. Remote administration goes through a Tailscale sidecar container, so the PBX is reachable from my own devices on the tailnet and invisible to the public internet. Combined with the outbound-registered trunk, there is no inbound SIP surface at all.
- fail2ban runs inside the FreePBX container with 11 jails — including the Asterisk- and FreePBX-specific ones — banning SIP brute-force sources. One gotcha worth flagging: fail2ban can’t insert firewall rules inside a container without
cap_add: NET_ADMIN. Without that capability it happily watches and logs and never actually bans anything. Add the cap, or your jails are decorative.
The finished system #
$ asterisk -rx "pjsip show endpoints"
Endpoint: 101 Unavailable (desk softphone, registers on demand)
Endpoint: 102 Not in use ─ main desk phone
Endpoint: 103 Not in use ─ intercom-only, common area
Endpoint: 104 Unavailable (locked personal line)
Endpoint: signalwire_sip Not in use ─ SignalWire trunk, Registered
- Asterisk 20.15.2, from the
izdock/izpbx-asterisk:20image - Four extensions, one main business number plus one restricted line
- ulaw/alaw codecs, SRTP on the trunk, RTP ports 10000–20000
- Cost: SignalWire per-minute + per-number, no per-seat licensing, hardware fully owned
Four containers on one host, one DNS record on another, one trunk in the cloud. The commercial service it replaced cost more every month than this does in a year — and it couldn’t ring my kitchen.
Built and documented with the help of Claude Code, which read the live PBX config directly so the examples above are the real thing rather than idealized snippets. Personal data synthesized before publishing.