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:

PartRole
vcon-serverReceives vCons over HTTP, runs them through a processing chain, and stores them. Its worker process is the conserver.
ValkeyThe conserver’s working store: queues and cached vCons. Valkey is the open-source fork of Redis and speaks the same protocol.
PostgreSQLKeeps the finished vCons.
OpenSIPSYour SIP proxy. Its siprec module starts a recording for each call.
rtpengineRelays each call’s media and sends a copy of it to the recorder.
vcon-siprec-adapterThe 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.

SoftwareVersion or commit
Docker Engine / Compose29.8.1 / v5.5.1, from the Docker apt repository
vcon-serverd441470b, main on 2026-09-20
Valkey / PostgreSQLvalkey/valkey:9.1.2-alpine / postgres:17.11-alpine
OpenSIPSf46ef9337b, master, 4.1.0-dev
rtpengine8da4be3355, master, packaged as 26.3.0.0
vcon-siprec-adapterfa09b939d3, 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_auth gives the ingress list siprec its own key. A producer that holds this key can post to siprec and nothing else. The full CONSERVER_API_TOKEN in .env is for reading vCons back; keep it off the producers.
  • chains routes everything that arrives on siprec through the mark_recorder link, which tags each vCon vcon_role:recorder, and into the vcons_recorded table. 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 the 200 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: 5091 with no certificate. TLS stays off. The adapter’s own config.yaml points at certificate files that do not exist, and it does not start with them. Its validator also refuses a TLS port of null.
  • webhooks posts each finished vCon to the siprec ingress list with that list’s key. If every attempt fails, the vCon goes to dlq/ (dead-letter queue) instead, so it is not lost.
  • storage.local_path keeps 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 url to 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, set listen_address and SIPREC_PUBLIC_IP to 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_sock to that machine and make rtpengine’s control port listen on an address OpenSIPS can reach (listen-ng in /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 as opensips and cannot read its configuration. make install creates /usr/local/etc/opensips readable by root alone, so give the group both the directory and the file, as in step 5.
  • The test call’s BYE gets 404 Not here. The caller ignored the route set. Use the edited uac_rr.xml, not SIPp’s built-in uac_pcap.
  • /health answers but nothing reaches PostgreSQL. A 204 from the ingress route means queued. Read docker compose logs conserver; a storage failure is logged there.
  • rtpengine logs FAILED TO OPEN KERNEL TABLE 0. table = -1 was not set, or the service was not restarted after setting it.