Skip to content
Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Running forge-voice is a Compose bring-up plus three decisions that have nothing to do with this repo: which SIP trunk you buy, how that trunk authenticates you, and how the bridge reaches both Asterisk and the Forge API across whatever network topology you have. The bring-up is easy. The three decisions are where installs actually get stuck, so most of this page is about them.

This service is not shipped by forge-deploy and is not part of the main stack. Nothing in Installation brings it up, and no forge-deploy script or Compose file references it. You clone it and run it yourself, next to Forge.

What it wraps

Two containers. Asterisk itself, from a stock upstream image, with this repo's four config files mounted read-only over /etc/asterisk; and the Node bridge, built from the repo's own Dockerfile, which connects to Asterisk over ARI, the Asterisk REST Interface, and exposes the HTTP endpoints described on Home.

File What it decides
asterisk/pjsip.conf The trunk: transport, endpoint, auth, AOR, registration, and the context inbound calls land in
asterisk/extensions.conf The dialplan. Both directions hand off to the forge-voice Stasis app; the recording template lives here, commented
asterisk/ari.conf The ARI user the bridge authenticates as, and the origin allowlist
asterisk/http.conf Asterisk's HTTP listener, which ARI rides on
src/server.js Endpoints, in-memory call tracking, and the webhook post

Everything Asterisk-side is intentionally minimal. Production installs are expected to grow the dialplan — DID routing, business-hours handling, voicemail — and that work belongs in extensions.conf, not in the bridge.

What you need before you start

Docker with Compose, a SIP trunk account, and — if you want calls logged — a reachable Forge API. Without the API the bridge still places calls; it simply reports nothing. To run the bridge outside Docker instead, you need a current Node runtime (the Dockerfile and CI pin the major version; follow those rather than a number written in prose) and an Asterisk instance with ARI enabled somewhere it can reach.

You do not need a Twilio account, carrier licensing, or a kernel-mode SIP stack. Asterisk does all of that inside its container.

Configuration

Copy .env.example to .env and fill it in. Nothing here belongs in a committed file — .env is gitignored, keep it that way.

Variable Purpose
SIP_TRUNK_HOST Your provider's hostname, used for the endpoint, the AOR contact and the registration URIs
SIP_TRUNK_USER / SIP_TRUNK_PASS Trunk credentials, for a registration-based trunk
ARI_PASS The password the bridge authenticates to Asterisk's ARI with. ARI_USER defaults to forge-voice
FORGE_API_WEBHOOK Where call-state events are posted. Defaults to the API's voice webhook route on the Compose service name
FORGE_WEBHOOK_SECRET Shared secret sent as X-Forge-Voice-Secret. Unset, the bridge logs an error and posts nothing

QB_API_WEBHOOK and QB_WEBHOOK_SECRET are read as fallbacks for installs configured before the project was renamed. New installs should ignore them.

Always set ARI_PASS explicitly. The bridge falls back to a placeholder value when it is unset rather than refusing to start, which produces a service that looks up but cannot authenticate — or, worse, one that can be authenticated against by anyone who read the repo.

The README's "Known issue" section is stale. It warns that docker-compose.yml forwards only the legacy QB_* names, so the FORGE_* values never reach the container. That was true; the commit that wrote the warning also fixed the Compose file, and the warning survived the rewrite. The current Compose file forwards FORGE_API_WEBHOOK and FORGE_WEBHOOK_SECRET directly, with the legacy pair passed through only when set. Ignore the warning; it is a documentation bug worth a PR.

The placeholder trap in the Asterisk configs

pjsip.conf and ari.conf are written with ${SIP_TRUNK_USER}-style placeholders, and the Compose file passes the matching variables into the Asterisk container. Nothing renders them. Asterisk's static configuration parser does not expand shell-style variables out of the process environment, there is no entrypoint or envsubst step anywhere in this repo, and the files are mounted read-only so nothing could rewrite them in place. The values arrive at Asterisk literally, character for character.

So before your first bring-up, render those files yourself: template them into a generated directory and mount that, or bake real values into a copy that is not committed. A trunk that never registers, and an ARI login that fails with the right password in .env, are both this trap.

Configuring the Forge side

Three settings on the main application, none of them in this repo:

  1. Voice:WebhookSecret on forge-api, set to the same value as FORGE_WEBHOOK_SECRET here. There is no entry for it in the shipped appsettings.json and no variable for it in the deploy stack's environment file, so supply it as an environment variable using .NET's section separator — Voice__WebhookSecret — on the API container. Unset, the webhook answers 503.
  2. CAP-EXT-VOIP-SYNC, enabled. It gates the whole voice controller. Off by default on purpose, because call handling is legally fraught. See Capability Gating.
  3. The forge-ui outbound-call provider, if and only if you want the browser click-to-dial path. Read the last section of Home first — that path needs code changes, not configuration.

Trunk and registration

pjsip.conf as shipped describes one trunk, authenticating by username and password, registering outbound. That covers most retail SIP providers. Three things about it are worth understanding rather than copying.

Registration versus IP authentication. A registering trunk sends credentials to the provider and is matched on inbound calls by the From header. An IP-authenticated trunk does not register at all; the provider recognises you by source address, and Asterisk has to recognise the provider the same way. For that you need the [trunk-identify] block — present in the file, commented, with placeholder addresses — set to your provider's signalling addresses. Without it, inbound calls on an IP-authenticated trunk match no endpoint and are refused. Providers publish these addresses and change them occasionally; treat the list as something to re-check, not set once.

An endpoint's context is where calls from it are routed — that is, inbound. This is the single most common misconfiguration in a file this small, and this repo shipped it wrong once: the endpoint pointed at outbound, so every incoming call entered the outbound dialplan and was treated as a dial attempt. It now reads context=inbound. Outbound calls do not use the endpoint context at all — the bridge originates them straight into the Stasis app over ARI.

Every call has to reach Stasis. That is what makes it visible to the bridge: StasisStart fires for a channel the bridge did not originate, which is how an inbound call becomes a communication record in Forge. If you extend the dialplan, whatever you add must still end at Stasis(forge-voice) or the call happens and Forge never hears about it. The shipped [inbound] context also carries a catch-all so an unrouted DID is visible in the log instead of being dropped silently.

Caller ID and endpoint naming are provider-specific. The Originate builds an endpoint string of the form PJSIP/<number>@trunk, and passes a caller-ID string straight through. Providers differ on whether they want E.164, a leading 1, or a stripped national number, and most will reject or rewrite a caller ID you have not proven you own. Expect to adjust both the number formatting and the caller ID for your trunk — this is the tuning the code's own header comment warns about.

Codecs. The endpoint allows ulaw, alaw and g722 and disallows everything else. If your provider offers only a compressed codec, or you want one, that list is where you change it, and transcoding costs the Asterisk container CPU.

Networking, which is where the real traps are

Asterisk runs with host networking, deliberately: RTP media needs a wide UDP port range and predictable addressing, and NAT traversal through a Docker bridge is a fight not worth having. That one choice creates the rest of the topology problems.

The bridge reaching Asterisk. The Compose file points ARI_URL at host.docker.internal. On Docker Desktop that resolves. On Linux it does not, unless you add a host-gateway mapping to the voice-bridge service's extra_hosts, run the bridge on host networking too, or point ARI_URL at the host's bridge-gateway address. Since a self-hosted Forge box is almost always Linux, assume this needs fixing on your first bring-up. The symptom is honest at least: the bridge logs a failed ARI connection, retries, and /health reports the ARI connection down.

The bridge reaching the Forge API. The default webhook URL uses the API's Compose service name, which only resolves if the bridge is on the same Docker network as the Forge stack. This repo's Compose file declares no networks at all, so out of the box it is not. Either join the bridge to the Forge stack's network as an external network, or set FORGE_API_WEBHOOK to an address the bridge can actually reach.

The browser reaching the bridge. AsteriskOutboundService is Angular code running in the user's browser, not server-side code, so its base URL must be resolvable from the browser — a Docker service name never is. And a different host or port is a different origin, which the bridge's Express app does not answer preflight for. If you pursue the browser dialling path, the clean answer is to reverse-proxy the bridge under the SPA's own origin at a path prefix; that removes the CORS problem instead of configuring around it.

Exposure. The bridge publishes 3030 and authenticates nothing; Asterisk's HTTP listener binds all interfaces on the host. Both are fine on a loopback-only or private-network install and are not fine on anything reachable from outside. Bind the published port to loopback, keep the SIP and ARI ports behind the host firewall, and put any externally reachable path through an authenticating reverse proxy. The hub's Hardening a Production Install sets the posture the rest of the stack expects, and this sidecar should not be the hole in it.

The allowed_origins list in ari.conf names a single origin. A Node ARI client sends no Origin header, so that list only matters if you ever point a browser-based ARI tool at the same instance — do not read it as the thing protecting ARI.

Verifying it works

Bring the stack up and check /health: it reports liveness and, separately, whether ARI is connected. Those are different failures and the endpoint distinguishes them for a reason. Then confirm the trunk registered from the Asterisk console before you place anything.

The end-to-end check that actually proves the seam is an inbound one, because it exercises the whole path without touching the unwired browser side: call your DID, let it complete, and look for a Communication row against the caller in Forge. If the call connects but nothing lands, work the chain in order — did the call reach Stasis, did the bridge log the inbound channel, did the webhook post, did the API accept it. A 503 from the API means Voice:WebhookSecret is unset; a 401 means the two secrets differ; a capability refusal means CAP-EXT-VOIP-SYNC is off. The API logs each of those distinctly.

Enabling recording, if you decide to

The dialplan carries a commented MixMonitor line with a disclosure playback above it, and the pair is commented as a set on purpose. Recording is a legal decision before it is a technical one — consent requirements differ by state and country, and several jurisdictions require every party to agree — so this repo will not make it for you. If you enable it, enable the disclosure with it, and know that the ingestion handler on the API side already stores audio and any transcript as separately hashed artifacts, so that a later dispute can tell a recording apart from a transcription of it. Retention of that audio then becomes your problem under Data Ownership and Export.