A self-hosted Docker stack that bridges PSTN phone calls into Discord voice channels. Call a phone number, enter a PIN, and you're in the voice chat - no internet required on the caller's end.
Built for a friend stationed on a ship with reliable cell service but no usable internet. Works with any SIP trunk provider, runs on any Docker host.
Your Phone ──PSTN──> VoIP.ms DID ──SIP/RTP──> Asterisk Container
│
├─ Caller ID whitelist check
├─ PIN authentication (3 attempts)
└─ AudioSocket handoff
│
v
Bridge Container (Node.js)
│
├─ 8kHz mono PCM ↔ 48kHz stereo PCM conversion
└─ Discord voice channel (join/leave/stream)
- You call the DID from a whitelisted phone number
- Asterisk checks your caller ID and prompts for a PIN
- On success, audio is handed off via AudioSocket to the bridge service
- The bridge joins a Discord voice channel and streams audio bidirectionally
- When you hang up, the bot leaves the voice channel
- Docker host - tested on Mac Mini M4 with Docker Desktop. Linux with Docker works too (and avoids some NAT quirks, see Troubleshooting).
- VoIP.ms account (or any SIP trunk provider) - ~$3-5/month
- Discord bot - free
- Router with port forwarding - UDP ports 5060 and 10000-10100
- Static/reserved LAN IP for the Docker host
git clone https://github.com/yourusername/blake-bridge.git
cd blake-bridge
cp .env.example .env
# Edit .env with your values (see setup guides below)
# Edit config/whitelist.txt with allowed phone numbers
docker compose up -d- Create an account at voip.ms
- Add funds ($15 minimum - lasts months at low usage)
- Create a Sub Account:
- Main menu > Sub Accounts > Create Sub Account
- Pick a username and password
- Protocol: SIP/UDP
- Device Type: Asterisk/PBX
- Allowed Codecs: G.711 u-law
- DTMF Mode: RFC 2833
- NAT: Yes
- Note the Server field (e.g.
chicago1.voip.ms)
- Purchase a DID:
- DID Numbers > Order DID
- Pick any US number (area code doesn't matter for inbound)
- Route the DID to your sub account
You now have:
| Value | .env Variable |
|---|---|
Sub account username (e.g. 123456_mysub) |
VOIPMS_USERNAME |
| Sub account password | VOIPMS_PASSWORD |
SIP server (e.g. chicago1.voip.ms) |
VOIPMS_SERVER |
Cost estimate: ~$0.85/month DID + ~$0.009/min inbound = ~$3.55/month at 5 hours usage.
- Go to Discord Developer Portal
- Click New Application - name it whatever you want (e.g. "Phone Bridge")
- Go to the Bot tab:
- Click Reset Token and copy the token - this is
DISCORD_BOT_TOKEN - Under Privileged Gateway Intents, enable Server Members Intent
- Click Reset Token and copy the token - this is
- Go to OAuth2 > URL Generator:
- Scopes:
bot - Bot Permissions:
Connect,Speak,Send Messages,Use Voice Activity - Copy the generated URL and open it to invite the bot to your server
- Scopes:
- Enable Developer Mode in Discord: User Settings > Advanced > Developer Mode
- Get the IDs (right-click > Copy ID):
| What to copy | .env Variable |
|---|---|
| Server (guild) name | DISCORD_GUILD_ID |
| Target voice channel | DISCORD_VOICE_CHANNEL_ID |
| Text channel for notifications | DISCORD_TEXT_CHANNEL_ID |
Make sure the bot has permission to connect/speak in the voice channel and send messages in the text channel.
- Assign a static LAN IP to your Docker host (via router DHCP reservation)
- Forward these ports on your router to that LAN IP:
| Protocol | Port(s) | Purpose |
|---|---|---|
| UDP | 5060 | SIP signaling |
| UDP | 10000-10100 | RTP media (audio) |
- Note your Docker host's LAN IP - this is
MAC_MINI_LAN_IP
Copy .env.example to .env and fill in all values:
cp .env.example .env# === VoIP.ms SIP Trunk ===
VOIPMS_SERVER=chicago1.voip.ms # SIP server from sub account settings
VOIPMS_USERNAME=123456_mysubaccount # Sub account username
VOIPMS_PASSWORD=your_password_here # Sub account password
# === Bridge Security ===
BRIDGE_PIN=123456 # 4-6 digit PIN callers must enter
CALLER_WHITELIST=+16715551234,+16715555678 # Comma-separated E.164 phone numbers
# === Discord ===
DISCORD_BOT_TOKEN=your-bot-token # From Discord Developer Portal
DISCORD_GUILD_ID=000000000000000000 # Right-click server > Copy ID
DISCORD_VOICE_CHANNEL_ID=000000000000000000 # Right-click voice channel > Copy ID
DISCORD_TEXT_CHANNEL_ID=000000000000000000 # Right-click text channel > Copy ID
# === Network ===
MAC_MINI_LAN_IP=192.168.1.100 # Docker host's static LAN IP
# === Asterisk Ports (defaults are fine) ===
ASTERISK_SIP_PORT=5060
ASTERISK_RTP_START=10000
ASTERISK_RTP_END=10100
# === AudioSocket (defaults are fine) ===
AUDIOSOCKET_LISTEN_PORT=9092
AUDIOSOCKET_UUID=f47ac10b-58cc-4372-a567-0e02b2c3d479Edit config/whitelist.txt with one phone number per line in E.164 format:
+16715551234
+16715555678
Only calls from these numbers will be accepted. All others get an immediate rejection tone.
docker compose up -dThe first build takes several minutes (Asterisk compiles from source). Subsequent starts are fast.
Check that everything is running:
docker compose logs -fYou should see:
- Asterisk:
Asterisk Ready.andEndpoint voipms-endpoint is now Reachable - Bridge:
Discord bot logged in as YourBot#1234andAudioSocket server listening on port 9092
- Call your VoIP.ms DID from a whitelisted number
- You'll hear "password" - enter your PIN followed by
# - You'll hear a beep - the bot joins Discord and audio flows both ways
- Hang up to disconnect - the bot leaves the voice channel
Two Docker containers managed by docker compose:
| Container | Role |
|---|---|
blake-asterisk |
Asterisk 20 PBX - SIP trunk, IVR, caller ID auth, PIN entry, AudioSocket handoff |
blake-bridge |
Node.js/TypeScript - AudioSocket server, Discord voice bot, audio transcoding |
Phone (8kHz G.711) → Asterisk → slin 8kHz mono PCM → AudioSocket TCP
→ Bridge: upsample to 48kHz stereo → Discord voice (Opus/DAVE encrypted)
Discord voice → Opus decode → 48kHz stereo PCM
→ Bridge: downsample to 8kHz mono → AudioSocket TCP → Asterisk → G.711 → Phone
Audio quality is PSTN-grade (8kHz narrowband) - the caller sounds like a phone call to Discord users. Discord audio sent to the phone is downsampled but perfectly intelligible for voice chat.
blake-bridge/
├── docker-compose.yml # Container orchestration
├── .env.example # Environment variable template
├── config/
│ └── whitelist.txt # Allowed caller IDs
├── asterisk/
│ ├── Dockerfile # Builds Asterisk 20 from source
│ ├── entrypoint.sh # Templates env vars into configs at startup
│ └── config/
│ ├── pjsip.conf # SIP trunk configuration
│ ├── extensions.conf # IVR dialplan (caller ID check → PIN → bridge)
│ ├── rtp.conf # RTP port range
│ ├── modules.conf # Asterisk module loading
│ └── agi-bin/
│ └── check_callerid.sh # Caller ID whitelist AGI script
└── bridge/
├── Dockerfile
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Entry point, graceful shutdown
├── audiosocket.ts # AudioSocket TCP protocol parser
├── discord.ts # Discord bot, voice channel management
├── bridge.ts # Glue: AudioSocket ↔ Discord audio streams
├── audio.ts # PCM sample rate conversion (8kHz ↔ 48kHz)
├── config.ts # Environment variable validation
└── logger.ts # Structured JSON logger
- Caller ID whitelist - unknown numbers are immediately rejected with no indication a bridge exists
- PIN authentication - 3 attempts max, then hangup
- SIP endpoint identification - Asterisk only accepts SIP traffic from your VoIP.ms server IP
- Discord bot - minimal permissions (Connect, Speak, Send Messages)
- No exposed ports for the bridge container - it only makes outbound connections to Discord
This is usually a NAT issue between Docker and VoIP.ms. The qualify_frequency=30 and expiration=120 settings in pjsip.conf keep the NAT pinhole alive. If calls stop working:
# Force re-registration
docker exec blake-asterisk asterisk -rx "pjsip send unregister voipms"
sleep 5
docker exec blake-asterisk asterisk -rx "pjsip send register voipms"
# Wait 30-60 seconds before trying to callDocker Desktop runs containers in a Linux VM, which adds an extra NAT layer. This causes the SIP source port to differ from what's advertised. The config handles this with:
external_signaling_address/external_media_addressset to your public IP (auto-detected at startup)qualify_frequency=30to keep the NAT mapping aliveexpiration=120for frequent re-registration
On native Linux Docker, these issues don't apply and you can simplify the config.
- Verify UDP ports 10000-10100 are forwarded on your router
- Check that
external_media_addressresolved to your public IP:docker logs blake-asterisk | grep "Detected public IP" - Ensure VoIP.ms sub account has NAT: Yes and DTMF Mode: RFC 2833
- Set DTMF Mode to RFC 2833 in your VoIP.ms sub account settings
- The PIN is entered during the "password" prompt - press digits then
#
- Ensure the bot has Connect and Speak permissions in the target voice channel
- Discord now requires DAVE (end-to-end encryption) for voice - this project uses
@discordjs/voice1.0.0-dev with DAVE support via@snazzah/davey - Check:
docker logs blake-bridgefor voice connection state changes
# Asterisk SIP registration
docker exec blake-asterisk asterisk -rx "pjsip show registrations"
# Active calls
docker exec blake-asterisk asterisk -rx "core show channels"
# Bridge logs
docker logs blake-bridge
# Full stack logs
docker compose logs -fAny SIP trunk provider works - only the .env SIP variables and VoIP.ms-specific settings change:
| Provider | DID Cost | Inbound/min | ~Monthly (5hr) |
|---|---|---|---|
| VoIP.ms | $0.85 | $0.009 | $3.55 |
| Telnyx | $1.00 | $0.01 | $4.00 |
| Twilio | $1.15 | $0.0085 | $3.70 |
MIT