Docs Add a vCon server to an OpenSIPS voice stack
Add a vCon server to an OpenSIPS voice stack
Install vcon-server with Valkey and PostgreSQL, build OpenSIPS and rtpengine from their main branches, and have OpenSIPS record every call into it over SIPREC. No sipnab involved.
On this page
A vCon is a JSON container that holds one conversation: who took part, the recording, and anything a later tool adds, such as a transcript. This guide stands up a vCon server beside an OpenSIPS proxy and has OpenSIPS record every call into it. This guide does not use sipnab. When you have this working, Send sipnab’s vCons to a vCon server adds sipnab as a second source.
The parts, and what each one does:
| Part | Role |
|---|---|
| vcon-server | Receives vCons over HTTP, runs them through a processing chain, and stores them. Its worker process is the conserver. |
| Valkey | The conserver’s working store: queues and cached vCons. Valkey is the open-source fork of Redis and speaks the same protocol. |
| PostgreSQL | Keeps the finished vCons. |
| OpenSIPS | Your SIP proxy. Its siprec module starts a recording for each call. |
| rtpengine | Relays each call’s media and sends a copy of it to the recorder. |
| vcon-siprec-adapter | The recorder. It receives the copy over SIPREC, turns each finished call into a vCon, and posts it to vcon-server. |
SIPREC (RFC 7866) is the standard way for a SIP server to hand a copy of a call to a recorder: the proxy opens a second SIP session to the recorder and sends it both directions of the audio.
Everything below runs on one machine, which keeps the example short. The last section says what changes when the parts live on different machines.
Tested on
Every command on this page ran as written, in order, on 2026-09-25 on two x86_64 virtual machines with 2 cores and 4 GB of memory: a clean Debian 13 (kernel 6.12.63), and Ubuntu 24.04.5 (kernel 6.8.0). The commands pin the components to the versions below. Newer commits may behave differently, and pinning them keeps the guide describing what you get.
| Software | Version or commit |
|---|---|
| Docker Engine / Compose | 29.8.1 / v5.5.1, from the Docker apt repository |
| vcon-server | d441470b, main on 2026-09-20 |
| Valkey / PostgreSQL | valkey/valkey:9.1.2-alpine / postgres:17.11-alpine |
| OpenSIPS | f46ef9337b, master, 4.1.0-dev |
| rtpengine | 8da4be3355, master, packaged as 26.3.0.0 |
| vcon-siprec-adapter | fa09b939d3, main |
| SIPp (for the test call) | the distribution’s sip-tester: 3.7.3 on Debian, 3.7.2 on Ubuntu |
The whole vCon stack used about 165 MiB of memory while idle.
The examples use 192.0.2.10 as the machine’s address. Replace it with yours
everywhere it appears.
1. Install Docker
vcon-server runs as containers. Install Docker Engine and the Compose plugin from the Docker repository:
# Run all of these, in order.
sudo apt-get update
sudo apt-get install -y ca-certificates curl git
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID $VERSION_CODENAME stable" \
| sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
Log out and back in so that your account’s new docker group takes effect,
then check:
docker compose version
2. Install the vCon server
Check out vcon-server at the tested commit:
# Run all of these, in order.
sudo mkdir -p /opt/vcon && sudo chown "$USER": /opt/vcon
cd /opt/vcon
git clone https://github.com/vcon-dev/vcon-server.git
git -C vcon-server checkout d441470b
The docker-compose.yml in the checkout runs Redis and development tooling. Write a
smaller one beside the checkout that runs Valkey and PostgreSQL instead:
cat > /opt/vcon/compose.yml <<'EOF'
name: vcon
services:
valkey:
image: valkey/valkey:9.1.2-alpine
command: ["valkey-server", "--save", "20", "1", "--notify-keyspace-events", "Ex",
"--appendonly", "yes", "--dir", "/data"]
volumes: [valkey_data:/data]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
interval: 10s
retries: 12
restart: unless-stopped
postgres:
image: postgres:17.11-alpine
environment:
POSTGRES_DB: vcon
POSTGRES_USER: vcon
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vcon -d vcon"]
interval: 10s
retries: 12
restart: unless-stopped
conserver:
build: {context: ./vcon-server, dockerfile: ./docker/Dockerfile}
command: ["python", "/app/conserver/main.py"]
volumes:
- ./vcon-server:/app
- ./config.yml:/app/config.yml:ro
env_file: [.env]
depends_on:
valkey: {condition: service_healthy}
postgres: {condition: service_healthy}
restart: unless-stopped
api:
build: {context: ./vcon-server, dockerfile: ./docker/Dockerfile}
command: ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
volumes:
- ./vcon-server:/app
- ./config.yml:/app/config.yml:ro
ports: ["8000:8000"]
env_file: [.env]
depends_on:
valkey: {condition: service_healthy}
restart: unless-stopped
volumes:
valkey_data: {}
postgres_data: {}
EOF
Create the two files that hold secrets. umask 077 makes both readable by you
alone. Each secret is a fresh random value:
# Run all of these, in order.
cd /opt/vcon
umask 077
cat > .env <<EOF
REDIS_URL=redis://valkey
CONSERVER_CONFIG_FILE=/app/config.yml
CONSERVER_API_TOKEN=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
EOF
. ./.env
cat > config.yml <<EOF
ingress_auth:
siprec: "$(openssl rand -hex 32)"
links:
mark_recorder:
module: links.tag
options:
tags: [vcon_role:recorder]
storages:
postgres_recorded:
module: storage.postgres
options:
host: postgres
port: 5432
database: vcon
user: vcon
password: "$POSTGRES_PASSWORD"
table_name: vcons_recorded
chains:
siprec_recorder:
links: [mark_recorder]
ingress_lists: [siprec]
storages: [postgres_recorded]
enabled: 1
EOF
umask 022
What that configuration says:
ingress_authgives the ingress listsiprecits own key. A producer that holds this key can post tosiprecand nothing else. The fullCONSERVER_API_TOKENin.envis for reading vCons back; keep it off the producers.chainsroutes everything that arrives onsiprecthrough themark_recorderlink, which tags each vConvcon_role:recorder, and into thevcons_recordedtable. PostgreSQL creates the table on the first write.
Start it and check that the API answers:
# Run all of these, in order.
cd /opt/vcon
docker compose up -d --build
until curl -sf localhost:8000/health; do sleep 2; done; echo
docker compose ps
The API takes a few seconds to start, so the until line waits for /health
to answer {"status":"healthy",...}. All four services then show running,
and valkey and postgres also show healthy.
Prove it stores what it accepts
The ingress route answers 204 when it has queued a vCon, not when it has
stored it. Post a small one and look for it in PostgreSQL:
# Run all of these, in order.
cd /opt/vcon
KEY=$(sed -n 's/^ siprec: "\(.*\)"/\1/p' config.yml)
UUID=$(cat /proc/sys/kernel/random/uuid)
printf '{"vcon":"0.0.1","uuid":"%s","created_at":"%s","parties":[{"tel":"+15551230001"},{"tel":"+15551230002"}],"dialog":[],"analysis":[],"attachments":[]}' \
"$UUID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/test.vcon.json
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"localhost:8000/vcon/external-ingress?ingress_list=siprec" \
-H "x-conserver-api-token: $KEY" -H "Content-Type: application/json" \
--data-binary @/tmp/test.vcon.json
rm /tmp/test.vcon.json
sleep 3
docker compose exec -T postgres psql -U vcon -d vcon -c \
"select id, vcon_json->'attachments' from vcons_recorded where id='$UUID'"
The curl prints 204, and the query returns one row whose attachment is the
["vcon_role:recorder"] tag. A wrong key gets 403 instead.
3. Build rtpengine
rtpengine’s own documentation recommends building Debian packages from the
source tree, which gives you a systemd unit, a configuration file and a clean
uninstall. The build installs its dependencies from the tree’s
debian/control, then runs rtpengine’s test suite, which takes a while:
# Run all of these, in order.
sudo apt-get install -y --no-install-recommends build-essential devscripts equivs fakeroot
sudo mkdir -p /usr/local/src/voice && sudo chown "$USER": /usr/local/src/voice
cd /usr/local/src/voice
git clone https://github.com/sipwise/rtpengine.git
cd rtpengine
git checkout 8da4be3355
sudo mk-build-deps -i -r -t "apt-get -y --no-install-recommends" debian/control
dpkg-buildpackage -us -uc -b -j2
cd ..
sudo apt-get install -y ./ngcp-rtpengine-daemon_26.3.0.0+0~mr26.3.0.0_amd64.deb \
./ngcp-rtpengine-utils_26.3.0.0+0~mr26.3.0.0_all.deb
The package’s configuration asks for rtpengine’s kernel forwarding module, which this guide does not install. Tell it to forward in userspace, and restart it:
# Run all of these, in order.
sudo sed -i '0,/^table = 0/s//table = -1/' /etc/rtpengine/rtpengine.conf
sudo systemctl restart ngcp-rtpengine-daemon
systemctl is-active ngcp-rtpengine-daemon
It listens for OpenSIPS on 127.0.0.1:2223 and relays media on ports
30000-39999.
4. Build OpenSIPS
OpenSIPS master builds with compiler optimizations turned off, which is right
for OpenSIPS’s own developers and wrong for a proxy carrying calls. Turn them
back on before you build, and add the siprec module, which the default build
leaves out:
# Run all of these, in order.
sudo apt-get install -y --no-install-recommends bison flex uuid-dev pkg-config libncurses-dev
cd /usr/local/src/voice
git clone https://github.com/OpenSIPS/opensips.git
cd opensips
git checkout f46ef9337b
make Makefile.conf
sed -i 's/^DEFS+= -DCC_O0/#DEFS+= -DCC_O0/' Makefile.conf
make -j2 all include_modules="siprec"
sudo make install include_modules="siprec"
/usr/local/sbin/opensips -V | head -2
The second line of opensips -V lists the build flags. CC_O0 is no longer
among them.
Leave DBG_MALLOC as it is. With both it and CC_O0 switched off, this commit
of master does not compile: net/tcp_conn_defs.h calls get_ticks() without
including the header that declares it, and only DBG_MALLOC’s headers happen
to supply it.
OpenSIPS installs under /usr/local: the binary is /usr/local/sbin/opensips,
modules are in /usr/local/lib64/opensips/modules/, and OpenSIPS reads its
configuration from /usr/local/etc/opensips/.
5. Configure OpenSIPS to record every call
This configuration is a minimal proxy with recording added. Your own script
does much more (registration, authentication, routing to carriers). The
recording part is the block marked below, and the loadmodule lines it needs.
sudo tee /usr/local/etc/opensips/opensips.cfg >/dev/null <<'EOF'
# OpenSIPS as a SIP proxy that records every call over SIPREC.
log_level=3
stderror_enabled=no
syslog_enabled=yes
syslog_facility=LOG_LOCAL0
udp_workers=2
open_files_limit=4096
socket=udp:192.0.2.10:5060 # the address your phones and carriers reach
mpath="/usr/local/lib64/opensips/modules/"
loadmodule "proto_udp.so" # built into the core, but still loaded by name
loadmodule "signaling.so"
loadmodule "sl.so"
loadmodule "tm.so"
loadmodule "rr.so"
loadmodule "maxfwd.so"
loadmodule "sipmsgops.so"
loadmodule "mi_fifo.so"
modparam("mi_fifo", "fifo_name", "/run/opensips/opensips_fifo")
# Recording: siprec needs dialog, b2b_entities and rtp_relay, loaded first.
loadmodule "dialog.so"
loadmodule "uac_auth.so"
loadmodule "b2b_entities.so"
loadmodule "siprec.so"
loadmodule "rtp_relay.so"
loadmodule "rtpengine.so"
modparam("rtpengine", "rtpengine_sock", "udp:127.0.0.1:2223")
route {
if (!mf_process_maxfwd_header(10)) {
send_reply(483, "Too Many Hops");
exit;
}
if (has_totag()) {
if (is_method("ACK") && t_check_trans()) {
t_relay();
exit;
}
if (!loose_route()) {
send_reply(404, "Not here");
exit;
}
t_relay();
exit;
}
if (is_method("CANCEL")) {
if (t_check_trans())
t_relay();
exit;
}
t_check_trans();
if (!is_method("INVITE")) {
send_reply(405, "Method Not Allowed");
exit;
}
record_route();
# --- recording starts here ---
create_dialog();
rtp_relay_engage("rtpengine");
siprec_start_recording("sip:127.0.0.1:5090");
# --- recording ends here ---
# Where the call goes. Here, a test callee on this machine; in your
# stack, lookup("location"), dispatcher or a carrier.
$du = "sip:127.0.0.1:5070";
t_relay();
}
EOF
What the recording block does, line by line:
create_dialog()makes OpenSIPS track the call, so that it knows when the call ends and can end the recording with it.rtp_relay_engage("rtpengine")puts rtpengine in the media path. The recorder can only receive a copy of audio that passes through rtpengine.siprec_start_recording("sip:127.0.0.1:5090")opens the SIPREC session to the recorder. It starts only when the callee’s SDP arrives, usually in the200 OK.
loadmodule "uac_auth.so" is there because b2b_entities asks for it at
startup. Leaving it out works, but logs a warning on every start.
Run OpenSIPS as its own user, under systemd:
# Run all of these, in order.
sudo useradd --system --home-dir /run/opensips --shell /usr/sbin/nologin opensips
sudo chown root:opensips /usr/local/etc/opensips /usr/local/etc/opensips/opensips.cfg
sudo chmod 750 /usr/local/etc/opensips
sudo chmod 640 /usr/local/etc/opensips/opensips.cfg
sudo tee /etc/systemd/system/opensips.service >/dev/null <<'EOF'
[Unit]
Description=OpenSIPS SIP server
After=network.target ngcp-rtpengine-daemon.service
[Service]
Type=forking
User=opensips
Group=opensips
RuntimeDirectory=opensips
RuntimeDirectoryMode=775
PIDFile=/run/opensips/opensips.pid
ExecStart=/usr/local/sbin/opensips -P /run/opensips/opensips.pid -f /usr/local/etc/opensips/opensips.cfg -m 64 -M 8
Restart=always
TimeoutStopSec=30s
LimitNOFILE=262144
[Install]
WantedBy=multi-user.target
EOF
sudo /usr/local/sbin/opensips -C -f /usr/local/etc/opensips/opensips.cfg
sudo systemctl daemon-reload
sudo systemctl enable --now opensips
systemctl is-active opensips
opensips -C checks the configuration and prints config file ok before you
start anything.
6. Install the recorder
vcon-siprec-adapter runs as a container. It uses the host’s network directly,
because a call’s audio arrives on a range of UDP ports and publishing a range
through Docker is slow. Its ports stay clear of everything else on this
machine: SIP on 127.0.0.1:5090, audio on 40000-40999, and its health
check on 127.0.0.1:8081.
# Run all of these, in order.
sudo mkdir -p /opt/siprec && sudo chown "$USER": /opt/siprec
cd /opt/siprec
git clone https://github.com/vcon-dev/vcon-siprec-adapter.git
git -C vcon-siprec-adapter checkout fa09b939d3
mkdir -p vcons dlq logs
KEY=$(sed -n 's/^ siprec: "\(.*\)"/\1/p' /opt/vcon/config.yml)
umask 077
cat > config.yaml <<EOF
server:
listen_address: "127.0.0.1"
sip_port_udp: 5090
sip_port_tcp: 5090
sip_port_tls: 5091
tls_cert: null
tls_key: null
user_agent: "SIPREC-SRS/1.0"
max_sessions: 100
session_timeout: 3600
storage:
local_path: "/app/vcons"
filename_pattern: "{timestamp}_{call_id}.vcon.json"
create_directories: true
cleanup_temp_files: true
webhooks:
enabled: true
dlq_path: "/app/dlq"
endpoints:
- url: "http://127.0.0.1:8000/vcon/external-ingress?ingress_list=siprec"
headers:
x-conserver-api-token: "$KEY"
retry_attempts: 3
timeout: 30
backoff_factor: 2.0
hmac_secret: null
media:
mode: "inline"
publisher: "none"
signing:
enabled: false
health:
enabled: true
host: "127.0.0.1"
port: 8081
rtp:
buffer_size: 65536
supported_codecs: ["PCMU/8000", "PCMA/8000", "G722/8000", "opus/48000"]
audio_format: "wav"
sample_rate: 8000
channels: 1
port_range_start: 40000
port_range_end: 40999
lawful_basis:
enabled: true
lawful_basis: "legitimate_interests"
purposes: ["recording"]
expiration: null
justification: "Recorded by the operator of this SIP service."
logging:
level: "INFO"
file: "/app/logs/siprec-srs.log"
max_size: "10MB"
backup_count: 5
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
EOF
umask 022
cat > compose.yml <<'EOF'
name: siprec
services:
srs:
build: ./vcon-siprec-adapter
network_mode: host
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./vcons:/app/vcons
- ./dlq:/app/dlq
- ./logs:/app/logs
environment:
SIPREC_PUBLIC_IP: 127.0.0.1
restart: unless-stopped
EOF
docker compose up -d --build
sleep 5
curl -s 127.0.0.1:8081/healthz
The health check answers {"status": "ok", ...}. Four settings deserve a word:
sip_port_tls: 5091with no certificate. TLS stays off. The adapter’s ownconfig.yamlpoints at certificate files that do not exist, and it does not start with them. Its validator also refuses a TLS port ofnull.webhooksposts each finished vCon to thesiprecingress list with that list’s key. If every attempt fails, the vCon goes todlq/(dead-letter queue) instead, so it is not lost.storage.local_pathkeeps a copy of every vCon, audio included, in/opt/siprec/vcons. That copy is call data; the operating section says how to clear it.signing: enabled: false. A signed (JWS) vCon does not match the shape vcon-server’s ingress route accepts.
7. Place a test call
SIPp plays both ends: a callee that echoes audio back, and a caller that dials
through OpenSIPS and plays a recorded G.711 sample. SIPp’s built-in caller
ignores the Record-Route header OpenSIPS adds, so its BYE would miss the
proxy and draw 404 Not here. The three sed lines make it honor the route set,
the way a real phone does, and drop the DTMF clip the recorder cannot store:
# Run all of these, in order.
sudo apt-get install -y sip-tester
mkdir -p ~/sipp/pcap && cd ~/sipp
ln -sf /usr/share/sip-tester/*.pcap pcap/
sipp -sd uac_pcap > uac_rr.xml
sed -i 's|<recv response="200" rtd="true" crlf="true">|<recv response="200" rtd="true" crlf="true" rrs="true">|' uac_rr.xml
sed -i -E 's#^( *)(ACK|BYE) sip:\[service\]@\[remote_ip\]:\[remote_port\] SIP/2.0#\1\2 [next_url] SIP/2.0\n\1[routes]#' uac_rr.xml
sed -i '/dtmf_2833_1.pcap/d' uac_rr.xml
sipp -sn uas -i 127.0.0.1 -p 5070 -rtp_echo -m 1 -bg
sudo sipp -sf uac_rr.xml 192.0.2.10:5060 -i 192.0.2.10 -p 5080 -s echo -m 1 -timeout 90s
The caller needs sudo because it plays the audio sample through a raw socket.
At the end SIPp’s statistics screen shows Successful call at 1.
8. Find the call’s vCon
When the call ends, the recorder builds the vCon and posts it. Its log names the vCon’s UUID:
# Run all of these, in order.
cd /opt/siprec
docker compose logs --since 5m srs | grep -E "Vcon object initialized|delivered vCon"
Look it up in PostgreSQL, with the UUID from that log line:
# Run all of these, in order.
cd /opt/vcon
docker compose exec -T postgres psql -U vcon -d vcon -c \
"select id, jsonb_array_length(vcon_json->'parties') as parties,
jsonb_array_length(vcon_json->'dialog') as recordings
from vcons_recorded order by created_at desc limit 1"
The test call produced one vCon with 2 parties and 2 recordings, one for each
direction of the audio, each an audio/wav of about 150 KB.
9. Operate it
Check health.
# Run all of these, in order.
curl -s -w '\n' localhost:8000/health # vcon-server API
curl -s -w '\n' 127.0.0.1:8081/healthz # recorder
systemctl is-active opensips ngcp-rtpengine-daemon
cd /opt/vcon && docker compose ps
Read the logs.
# Run all of these, in order.
cd /opt/vcon && docker compose logs --tail 50 conserver
cd /opt/siprec && docker compose logs --tail 50 srs
sudo journalctl -u opensips -n 50
sudo journalctl -u ngcp-rtpengine-daemon -n 50
The conserver logs one Completed processing vCon <uuid> ... Chain: siprec_recorder line for every vCon it stores.
Restart after a configuration change. vcon-server reads config.yml on each
request, but restart it anyway so that both processes agree:
# Run all of these, in order.
cd /opt/vcon && docker compose restart conserver api
sudo systemctl restart opensips
Clear the recorder’s local copies. Once a vCon is in PostgreSQL, the copy in
/opt/siprec/vcons is a second copy of the call. The container writes it as
root, so remove it with sudo. This deletes copies more than a day old:
sudo find /opt/siprec/vcons -name '*.vcon.json' -mtime +1 -delete
Check /opt/siprec/dlq as well: anything there is a vCon that never reached
vcon-server.
Back up the store. Everything worth keeping is in PostgreSQL:
# Run all of these, in order.
cd /opt/vcon
docker compose exec -T postgres pg_dump -U vcon vcon | gzip > vcon-$(date +%F).sql.gz
Uninstall. docker compose down -v removes the containers and their
volumes, which is every stored vCon:
# Run all of these, in order.
cd /opt/siprec && docker compose down
cd /opt/vcon && docker compose down -v
sudo systemctl disable --now opensips ngcp-rtpengine-daemon
sudo apt-get purge -y ngcp-rtpengine-daemon ngcp-rtpengine-utils
Put the parts on different machines
Nothing above depends on sharing a machine except the addresses:
- vcon-server on its own machine. Change the recorder’s webhook
urlto that machine’s address. Port 8000 is now reachable from the network, so allow it only from the machines that post vCons. - The recorder on its own machine. Change the address in
siprec_start_recording()to the recorder’s, setlisten_addressandSIPREC_PUBLIC_IPto the address OpenSIPS and rtpengine reach it on, and open its SIP port and its audio range (40000-40999) to them. - rtpengine on its own machine. Change
rtpengine_sockto that machine and make rtpengine’s control port listen on an address OpenSIPS can reach (listen-ngin/etc/rtpengine/rtpengine.conf).
When something does not work
ERROR:core:main: no transport protocol loaded.loadmodule "proto_udp.so"is missing. The OpenSIPS binary contains the module, but the script must still name it.loading config file ... Permission denied. OpenSIPS runs asopensipsand cannot read its configuration.make installcreates/usr/local/etc/opensipsreadable by root alone, so give the group both the directory and the file, as in step 5.- The test call’s
BYEgets404 Not here. The caller ignored the route set. Use the editeduac_rr.xml, not SIPp’s built-inuac_pcap. /healthanswers but nothing reaches PostgreSQL. A204from the ingress route means queued. Readdocker compose logs conserver; a storage failure is logged there.- rtpengine logs
FAILED TO OPEN KERNEL TABLE 0.table = -1was not set, or the service was not restarted after setting it.