I wanted to actually understand SIP, not just read about it. So I set myself a hands-on goal: run my own SIP server in the cloud and make a real voice call between my phone and my laptop, with the audio flowing through that server. This post is the full story, including the dead ends I hit, because the mistakes taught me more than the happy path did.
What you'll have at the end: one small AWS EC2 instance running Kamailio (the SIP server) and rtpengine (the audio relay), two softphones registered to it, and a working two-way call.
What SIP actually is (the mental model I needed)
Before touching anything, here's the model that made it click for me.
SIP (Session Initiation Protocol) is just the "who's calling whom and are they picking up" part of a call. It's signaling. It does not carry your voice. Think of it like the ringing and the "hello?" negotiation.
The actual audio travels over a separate protocol called RTP on a different set of ports. This split is the single most important thing to understand, because it's the source of 90% of the problems people hit:
- SIP → port 5060, sets up and tears down the call
- RTP → a range of ports (I used 10000–20000), carries the voice
There are two roles in the system:
- A server (the registrar/proxy) that knows where everyone is and routes calls. I used Kamailio.
- Clients (softphones) that have a mic and speaker. These live on devices with audio hardware — my phone and my laptop. Not on the server. A cloud server has no microphone, so a softphone running on it couldn't capture my voice. That realization saved me from a wrong turn early on.
My phone EC2 (one instance) My laptop
[softphone 1001] <--SIP--> [Kamailio + rtpengine] <--SIP--> [softphone 1002]
mic/spk <------ RTP audio relayed through EC2 ------> mic/spk
What is what: every piece in plain English
Before the step-by-step, let me explain each piece the way I wish someone had explained it to me. The big picture first: I built a tiny phone company in the cloud. When you make a normal call, a phone carrier's servers connect you to the other person. This lab is my own miniature version of that carrier, running on a single AWS server, connecting my phone to my laptop.
The two halves of every call
This is the one concept everything else hangs on. A phone call is really two separate conversations happening at the same time:
- Signaling — "the arranging." Who's calling, who's being called, is it ringing, did they answer, did they hang up. It's the paperwork of the call. The protocol is SIP, and it rides on port 5060.
- Media — "the actual voice." The real sound of your voice streaming to the other person. The protocol is RTP, and it rides on a range of ports (I used 10000–20000).
Why split them? Because they're different jobs. Signaling is a few small text messages ("ring this person"). Media is a continuous stream of audio. Keep this one line in your head and most problems make sense: SIP sets up the call, RTP carries the voice. The infamous "it rings but I hear nothing" bug is almost always RTP ports not being open — because people forget the voice travels separately from the signaling.
Kamailio — the switchboard operator
Kamailio is the SIP server, and its whole job is signaling. It does two things:
- Registrar: it keeps a live list of who's online and where. When my phone says "Hi, I'm 1001, here's my address," Kamailio writes it down. That's registration — the green checkmark on the phone.
- Proxy: when 1001 calls 1002, Kamailio looks up where 1002 is and forwards the call. It's the operator patching two lines together.
Crucially, Kamailio never touches your voice. It only handles the "who's calling whom" messages.
rtpengine — the audio relay in the middle
rtpengine handles the media (RTP) — the actual voice. Why do you need it on a cloud server? Because my phone and laptop are on different networks, both behind NAT (home router, mobile carrier). They can't send audio straight to each other because neither can see the other's real address. So rtpengine sits in the middle on the public server and relays the audio: phone → rtpengine → laptop, and back. Both sides only ever have to reach the server, which has a public address everyone can see.
The database (DBTEXT) — the user list
This stores the accounts: 1001 and its password, 1002 and its password. When a phone tries to register, Kamailio checks this list to confirm the password. I used DBTEXT, which is just plain text files on disk — the simplest option, no separate database server to run.
The softphones — the actual phones
A softphone is a phone app with a mic and speaker. I used Zoiper on my phone (extension 1001) and MicroSIP on my laptop (extension 1002). They register to Kamailio and place the calls. This is exactly why they live on my devices and not on the server — the server has no microphone.
The AWS pieces
- EC2 instance — just a rented Linux computer in Amazon's datacenter. It runs Kamailio + rtpengine. Nothing magic, a normal Ubuntu box I reach over SSH.
-
Public IP vs Private IP — the instance has two addresses: a private one (
172.31.x) it knows itself by inside AWS, and a public one (52.91.x) the outside world uses to reach it. This split is the root of the NAT problem: the server thinks it's172.31.x, but phones must talk to52.91.x. If the server naively tells a phone "send audio to 172.31.x," the phone fails because that address is meaningless on the internet. That's why I had to explicitly tell both Kamailio and rtpengine to advertise the public IP. - Security Group — AWS blocks all incoming traffic by default. The Security Group is where I poked holes for the ports I needed: SIP (5060) and the RTP range. Forget the RTP ports and calls connect but have no sound — the classic trap.
How one call actually flows
Putting it together, here's what happens when I dial 1002 from 1001:
- Both phones register. Zoiper (1001) and MicroSIP (1002) each tell Kamailio "I'm online, here's my address." Kamailio checks their passwords against the user list and records where they are.
- I dial. Phone 1001 sends an INVITE ("I want to call 1002") to Kamailio.
- Kamailio routes it. It looks up where 1002 is, forwards the INVITE, and tells rtpengine to set up an audio relay for this call.
- The laptop rings (180 Ringing).
- I answer. MicroSIP sends 200 OK — the call is connected.
- Voice flows. RTP audio streams phone → rtpengine → laptop and back. We're talking.
- I hang up. One side sends BYE, the other replies 200 OK, and rtpengine tears down the relay.
That exact sequence is what shows up in the Kamailio log later — the INVITE ... 200 OK and BYE ... 200 OK lines are steps 2–7 happening for real.
One-line cheat sheet
| Piece | Plain-English job |
|---|---|
| SIP | the language for arranging calls (ring, answer, hang up) |
| RTP | the stream that carries your actual voice |
| Kamailio | the switchboard — tracks who's online, connects callers |
| rtpengine | the audio relay in the middle; fixes the NAT problem |
| DBTEXT | the file holding users and passwords |
| Softphone (Zoiper / MicroSIP) | the phone app with a mic and speaker |
| EC2 | the rented cloud computer running it all |
| Public / Private IP | two addresses for one server; the NAT headache |
| Security Group | the firewall; I opened the SIP + RTP ports |
Why I moved from WSL to AWS
My first attempt was on my Windows laptop using WSL (Ubuntu). It technically works, but WSL2 puts Linux behind a NAT'd virtual network with a 172.x address, so reaching the SIP server from another device meant fighting with mirrored networking and port forwarding. On top of that, Ubuntu 20.04's Kamailio control tool had an old Python 2 vs 3 bug that broke user creation.
A disposable EC2 instance sidestepped all of it: a clean public IP, no WSL networking quirks, and a newer Ubuntu with a working toolchain. And when I'm done, I just terminate it.
Lesson learned: for anything involving inbound network traffic, a cloud VM with a real public IP is far less fiddly than WSL.
Step 1 — Launch the EC2 instance
I launched a small Ubuntu 24.04 instance (a t3a.medium is plenty for a lab).
-
Public IP (what the softphones connect to):
52.91.109.231 -
Private IP (what the instance sees on itself):
172.31.42.238
That public/private split is the NAT situation I'd have to handle later. Keep both numbers handy; everything references them.
Here's my instance in the EC2 console. Note the public IPv4 address — that's the one every softphone will connect to.
Step 2 — Open the ports (AWS Security Group)
This is where cloud SIP most often "silently fails." The firewall is the Security Group, and SIP needs more than one port family open. My inbound rules:
| Type | Protocol | Port range | Source | Why |
|---|---|---|---|---|
| All UDP | UDP | 0–65535 | 0.0.0.0/0 | covers SIP 5060 and the RTP media range in one rule |
| Custom TCP | TCP | 5060 | 0.0.0.0/0 | SIP fallback if a network blocks UDP |
| SSH | TCP | 22 | my IP | so I can log in |
The gotcha: if you only open 5060 and forget the RTP range, calls will connect and ring, but you'll hear silence. Opening All UDP is the lazy-but-reliable lab choice.
Security note: All UDP open to the whole internet means SIP scanners will start probing within minutes. Fine for a short-lived lab — just terminate the instance when done. Don't leave it running for days.
My inbound rules. The All-UDP rule is the important one — it covers both SIP signaling (5060) and the RTP media ports in a single line, which is what lets the audio through.
Step 3 — Install Kamailio and rtpengine
sudo apt update
sudo apt install -y kamailio kamailio-extra-modules sqlite3
sudo apt install -y rtpengine
Ubuntu 24.04 gave me Kamailio 5.7.4 and rtpengine 11.5.1 straight from the repos — no building from source. rtpengine is the piece that makes audio work across NAT: it relays the RTP stream and rewrites the media addresses so each side sends voice to a reachable IP.
Confirming both packages installed cleanly. Ubuntu 24.04 ships recent-enough versions that I didn't have to compile anything.
Step 4 — Configure rtpengine (the NAT fix)
The whole audio problem comes down to one line in /etc/rtpengine/rtpengine.conf:
interface = 172.31.42.238!52.91.109.231
port-min = 10000
port-max = 20000
That interface = PRIVATE!PUBLIC syntax is the magic. rtpengine binds on the private IP the OS actually has (172.31.x), but advertises the public IP (52.91.x) in the call setup, so remote phones know where to send audio. Without this, you get one-way or no audio every time.
Then:
sudo systemctl restart rtpengine-daemon
sudo systemctl status rtpengine-daemon
rtpengine up and running. "active (running)" is what you want to see here — if it's not, the audio relay is dead and calls will be silent.
Step 5 — Point the control tool at the database
I used DBTEXT, a flat-file database. Zero extra services to run, which is perfect for a lab. In /etc/kamailio/kamctlrc:
SIP_DOMAIN=52.91.109.231
DBENGINE=DBTEXT
DB_PATH="/etc/kamailio/dbtext"
I used the public IP as the SIP domain so the softphones don't need any DNS — they just point at the IP.
Step 6 — Turn on the features in kamailio.cfg
Kamailio's default config is modular; you switch features on with #!define flags at the very top of /etc/kamailio/kamailio.cfg:
#!define WITH_AUTH
#!define WITH_USRLOCDB
#!define WITH_NAT
#!define WITH_RTPENGINE
#!define DBURL "text:///etc/kamailio/dbtext"
-
WITH_AUTH— require a password to register -
WITH_USRLOCDB— remember where users registered -
WITH_NAT+WITH_RTPENGINE— enable NAT handling and route media through rtpengine -
DBURL— use the flat-file DB
I also added one listen line so Kamailio advertises its public IP in SIP headers (otherwise phones try to reply to the unreachable private IP):
listen=udp:172.31.42.238:5060 advertise 52.91.109.231:5060
The very top of my kamailio.cfg. These five define lines are the switches that turn on auth, NAT handling, and the rtpengine integration. They must appear exactly once — more on that mistake below.
Step 7 — Create the two users
sudo kamdbctl create # y to presence tables, n to extra tables
sudo kamctl add 1001 test1001
sudo kamctl add 1002 test1002
sudo kamctl db show subscriber
1001 is my phone, 1002 is my laptop. The db show listed both with hashed passwords, so I knew they were stored.
Both users stored in the database. You can see 1001 and 1002, each with a hashed password — Kamailio never keeps the plaintext.
The three mistakes I had to fix (this is where I learned the most)
Here's the honest part. Kamailio refused to start three separate times, and each failure taught me something.
Mistake 1 — Duplicate defines
I'd added the #!define lines in the editor, then re-added them with a command. Result:
CRITICAL: pp_define(): already defined: WITH_AUTH
Kamailio rejects a flag defined twice. I deleted the duplicate block and kept exactly one set.
Takeaway: kamailio -c -f /etc/kamailio/kamailio.cfg checks syntax before you start the service. Use it every time.
Mistake 2 — The database module wasn't loaded
Config parsed fine, but the service still died. Running it in the foreground (sudo kamailio -f ... -D -E) finally showed the real error:
ERROR: db_bind_mod(): Module db_text not found. Missing loadmodule?
I'd told Kamailio to use DBTEXT but never loaded the db_text module. The default config only loads the MySQL one. Fix:
sudo sed -i '/^loadmodule "jsonrpcs.so"/i loadmodule "db_text.so"' /etc/kamailio/kamailio.cfg
Takeaway: systemctl status hides the real error. Running the daemon in the foreground shows exactly why it won't start. That one trick unblocked me twice.
Mistake 3 — File permissions
The phone was now reaching the server (I could see REGISTER requests in the log — huge milestone), but every one failed with:
db_text: failed to open file [/etc/kamailio/dbtext/subscriber]
auth_db: get_ha1(): failed to query database
The subscriber file was owned by root with -rw------- permissions, but the Kamailio service runs as the kamailio user, which couldn't read it. kamctl had created it as root with locked-down perms because it holds password hashes. Fix:
sudo chown -R kamailio:kamailio /etc/kamailio/dbtext
sudo chmod 640 /etc/kamailio/dbtext/subscriber
sudo systemctl restart kamailio
Takeaway: "it works when I run it with sudo but not as a service" almost always means a permission or user mismatch.
Step 8 — Register the softphones
Phone (1001): I used Zoiper (Linphone works too). On the account setup screen that asks about an authentication username / outbound proxy, I left it unchecked and tapped Skip — my auth username is the same as my SIP username. The account settings:
| Field | Value |
|---|---|
| Username | 1001 |
| Password | test1001 |
| Domain / host | 52.91.109.231 |
| Transport | UDP |
A green checkmark and "Account is ready" meant it had registered to the cloud server.
The moment it clicked: the green check and "Account is ready" mean my phone successfully registered to the server running in AWS.
Laptop (1002): I installed MicroSIP on Windows (lightweight, simple) and added an account with the same shape but username 1002 / password test1002. It showed "Online" at the bottom.
The laptop side, registered as 1002. "Online" here is the Windows equivalent of the phone's green check — both endpoints are now live on the server.
Step 9 — Make the call
I kept the live log running on the server the whole time:
sudo journalctl -u kamailio -f
Then I dialed 1002 from my phone. The laptop rang, I answered, and the log showed the money line:
INVITE ... code=200;reason=OK;src_user=1001;...;dst_user=1002
BYE ... code=200;reason=OK ...
A 200 OK on the INVITE means the call connected end to end. The BYE with 200 OK means it hung up cleanly. Two-way audio, through my own server in the cloud.
Tip: if the phone and laptop are in the same room, use headphones on at least one of them or you'll get awful echo/feedback as each picks up the other's speaker.
The call connected on the phone — live and counting.
And the same call on the laptop side in MicroSIP. Both ends are talking through the cloud server.
The proof in the server log: the INVITE got a 200 OK (call answered) and the BYE got a 200 OK (clean hang-up). This is exactly what a successful call looks like from Kamailio's side.
Reading the SIP call flow
Once it worked, the log became a great teacher. A successful call looks like this:
- INVITE — caller asks to start a call
- 100 Trying — server acknowledges it's working on it
- 180 Ringing — the other phone is ringing
- 200 OK — the callee answered
- ACK — caller confirms, media starts flowing via rtpengine
- BYE / 200 OK — someone hangs up
I also saw a few 486 Busy Here responses — those were just calls I declined or didn't pick up. Not errors, just normal call outcomes.
What I'd do differently / next steps
- Try SIP over TCP or TLS. Some networks block UDP 5060; TCP got through reliably, and TLS would encrypt the signaling.
- Tighten the Security Group. All-UDP-open is fine for an afternoon, not for anything persistent.
- Add a third user and try a group scenario, or point a desk phone at it.
-
Watch the raw packets with
ngrep -d any port 5060orsngrepto see the actual SIP messages, not just the accounting log.
The whole thing in one glance
| Piece | What it does | Where it runs |
|---|---|---|
| Kamailio | SIP registrar + proxy (signaling) | EC2 |
| rtpengine | relays the voice (RTP), fixes NAT | EC2 |
| DBTEXT | stores users 1001 / 1002 | EC2 (flat files) |
| Zoiper (1001) | softphone with a mic | my phone |
| MicroSIP (1002) | softphone with a mic | my laptop |
One cloud server, two phones, a real call. The hard parts weren't the concepts — they were the duplicate config line, the unloaded module, and the file permission. Each one failed loudly, and reading the error (especially in the foreground) was always the way out.
Don't forget: when you're finished, stop or terminate the EC2 instance so it isn't billing or sitting open to the internet.





Top comments (0)