Self-hosted video conferencing on a VPS
The bandwidth math that decides your box size, plus Jitsi, BigBlueButton and Galène compared on RAM, open UDP ports and what breaks behind NAT.
Self-hosted video conferencing on a VPS is a bandwidth problem
Self-hosted video conferencing fails on small servers for one reason, and it is almost never the install. The server piece that every modern tool uses is an SFU (selective forwarding unit). It takes one video stream from each participant and forwards a copy of it to every other participant, so the traffic leaving the server grows with the square of the head count. A 1 GB or 2 GB VPS on a shared uplink will run the software fine. It will not carry the all-hands you are picturing.
So do the work in this order. Count the people, work out the megabits, then pick the box. The install is twenty minutes of copy and paste. The uplink decides whether anyone can hear you.
Why does bandwidth grow with the square of the participants?
Start with mesh. Every browser encodes its camera and sends a copy directly to each other browser, and no media server touches the video. A two person mesh call needs a signalling server and nothing else, which is why one to one calls are almost free to host. Mesh stops working around four or five people, because a laptop on a home connection has to upload four or five separate copies of its own video at the same time.
An SFU works differently. Each browser uploads one copy to the server. The server reads the RTP (real-time transport protocol) headers and forwards those packets on to the other participants without decoding the video. That is the whole trick, and it is why an SFU is light on CPU and heavy on network.
The older arrangement is an MCU (multipoint control unit). It decodes every incoming stream, composites them into one picture, and re-encodes that picture. Outbound bandwidth is tiny. CPU cost is enormous. Almost nothing uses an MCU for video now, and nothing in this guide does.
Now the arithmetic for an SFU. Say every person sends video at 1.2 Mbps and nobody has their camera off. The server receives N times 1.2 Mbps, which is linear and harmless. The server sends N times (N minus 1) times 1.2 Mbps, because each of the N people has to receive the other N minus 1 streams. That second figure is the one that ends projects.
The data behind this chart
[
{
"label": "4 people",
"sfu_egress_mbps": 14.4,
"egress_gb_per_hour": 6.5,
"monthly_volume_tb": 0.13
},
{
"label": "8 people",
"sfu_egress_mbps": 67.2,
"egress_gb_per_hour": 30.2,
"monthly_volume_tb": 0.6
},
{
"label": "15 people",
"sfu_egress_mbps": 252,
"egress_gb_per_hour": 113.4,
"monthly_volume_tb": 2.3
},
{
"label": "30 people",
"sfu_egress_mbps": "1,044",
"egress_gb_per_hour": 469.8,
"monthly_volume_tb": 9.4
},
{
"label": "50 people",
"sfu_egress_mbps": "2,940",
"egress_gb_per_hour": "1,323",
"monthly_volume_tb": 26.5
}
]Those rows are arithmetic, not a measurement of any particular server. The monthly column assumes twenty hours of calls in a month. Read the last row first. Fifty people on camera need 2,940 Mbps of sustained outbound traffic from a single machine. Thirty people need 1,044 Mbps. Four people need 14.4 Mbps, which any VPS handles without noticing. Between the four person row and the thirty person row the head count grows seven and a half times while the outbound traffic grows more than seventy times.
Real deployments come in under these numbers, and it is worth knowing exactly how. Jitsi and LiveKit both use simulcast: a sender publishes several quality layers at once, and the SFU forwards a low layer to anyone who is not on screen. Jitsi also has a last-N setting that forwards video only for the most recent speakers. Both save a lot of traffic. Neither changes the shape of the curve, and both stop helping the moment everyone turns their camera on and pins each other.
What does a VPS uplink actually give you?
A plan page says "1 Gbps port". That is the speed of the virtual network card, not a promise about the next hop. The link is shared with the other tenants on the same physical host, so sustained throughput during a busy hour is lower than the port speed, and a conference call is exactly a sustained load. Most plans also carry a monthly transfer allowance, after which you are throttled or billed.
That allowance is where the square turns up on an invoice. Twenty hours of the thirty person call in one month moves 9.4 TB out of the server, at 469.8 GB per hour. Twenty hours of the fifty person call moves 26.5 TB. Check the transfer allowance before you check the RAM figure, and if the plan page is vague about it, that vagueness is your answer. Reading a cheap VPS offer properly matters more for this workload than for almost any other.
Jitsi Meet: the default answer, and what it needs
Jitsi Meet is the one most people should start with. It installs from the project's own Debian repository, it configures nginx and a certificate during the install, and the videobridge (JVB) uses a single UDP port, which keeps the firewall rules short. It needs Debian 11 or newer, or Ubuntu 22.04 or newer.
sudo apt update
sudo apt install -y apt-transport-https curl gnupg
sudo add-apt-repository universe
sudo apt update
sudo curl -sL https://prosody.im/files/prosody-debian-packages.key -o /usr/share/keyrings/prosody-debian-packages.key
echo "deb [signed-by=/usr/share/keyrings/prosody-debian-packages.key] http://packages.prosody.im/debian $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/prosody-debian-packages.list
sudo apt install -y lua5.2
curl -sL https://download.jitsi.org/jitsi-key.gpg.key | sudo sh -c 'gpg --dearmor > /usr/share/keyrings/jitsi-keyring.gpg'
echo "deb [signed-by=/usr/share/keyrings/jitsi-keyring.gpg] https://download.jitsi.org stable/" | sudo tee /etc/apt/sources.list.d/jitsi-stable.list
sudo apt update
sudo apt install -y jitsi-meetThe installer asks for a hostname and then offers a certificate choice. Pick the Let's Encrypt option, and give it a domain name that already resolves to this server's public address. The certificate is issued over an HTTP challenge, so a name pointing anywhere else fails at that step.
Then open the ports. These are the ones the handbook documents, with SSH first so that ufw enable does not lock you out:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 10000/udp
sudo ufw allow 3478/udp
sudo ufw allow 5349/tcp
sudo ufw enableTCP 80 and 443 serve the web app and let the certificate renew. UDP 10000 carries all the audio and video, and it is the one people forget. UDP 3478 and TCP 5349 belong to the coturn server that the Jitsi package installs alongside the bridge, which is the fallback path for anyone whose network blocks UDP.
sudo systemctl status jitsi-videobridge2
sudo ss -ulnp | grep 10000The first command should report the service as active. The second should show the bridge listening on UDP 10000. If it prints nothing, the bridge did not start, and /var/log/jitsi/jvb.log will say why.
For sizing, Jitsi's handbook publishes its own starting point and BigBlueButton publishes a much larger one:
The data behind this chart
[
{
"label": "Jitsi Meet",
"ram_gb": 8,
"cpu_cores": 4,
"uplink_mbps": "1,000"
},
{
"label": "BigBlueButton 3.0",
"ram_gb": 16,
"cpu_cores": 8,
"uplink_mbps": 250
}
]Jitsi's handbook suggests 8 GB of RAM and 4 dedicated cores for a serious server, with 1,000 Mbps of network often being enough, and it notes that smaller setups run on 4 GB or 2 GB. One detail on that page is worth carrying with you: Prosody, the XMPP server that handles signalling, can use only one core. Extra cores help the bridge and do nothing for signalling.
Why does everyone join the call but nobody sees video?
This is the standard Jitsi failure on a VPS. The participant list fills, chat works, and every video tile stays black. The videobridge advertises the addresses it finds on its own interfaces. On a provider that gives the virtual machine a private address and maps a public one onto it, the only address JVB finds is the private one, so every client tries to send media to something like 10.0.0.5 and the packets go nowhere.
Tell the bridge about both addresses. Add a static mapping to /etc/jitsi/videobridge/jvb.conf:
ice4j {
harvest {
mapping {
static-mappings = [
{
local-address = "10.0.0.5"
public-address = "203.0.113.10"
}
]
}
}
}Restart with sudo systemctl restart jitsi-videobridge2. Take the local address from ip -4 addr show and the public address from your provider's control panel. Older guides set the same thing in /etc/jitsi/videobridge/sip-communicator.properties with the keys org.ice4j.ice.harvest.NAT_HARVESTER_LOCAL_ADDRESS and org.ice4j.ice.harvest.NAT_HARVESTER_PUBLIC_ADDRESS. Those still work, and new installs should use the mapping block above.
The other cause is a firewall you did not configure. Most providers run a network firewall in the control panel that is separate from ufw on the box, and UDP 10000 has to be open in both. To find out which layer drops the packets, run sudo tcpdump -ni any udp port 10000 on the server while somebody joins from outside. No packets at all means nothing reaches the machine, so the block is upstream of the operating system. Packets arriving while the tiles stay black means the bridge is answering with an address the client cannot reach, so it is the mapping. If ufw itself is the part you are unsure about, the ufw rules a VPS actually needs covers the rule ordering that trips people up.
BigBlueButton: heavy, opinionated, and it wants the whole server
BigBlueButton is shaped for teaching. It has a whiteboard, breakout rooms, polls and a presentation area, and its recording pipeline is a first class feature rather than an add-on. It is also the heaviest option here by a wide margin, and it is not a package you add to an existing server.
As of August 2026 the supported path is BigBlueButton 3.0 on Ubuntu 22.04, selected with the jammy-300 version flag. The project's stated production requirements are 16 GB of memory with swap enabled, 8 CPU cores with high single-thread performance, 250 Mbps of symmetric bandwidth, and 500 GB of disk if you keep recordings (50 GB if you disable them). The ports are TCP 80 and 443, plus the UDP range 16384 to 32768.
wget -q https://raw.githubusercontent.com/bigbluebutton/bbb-install/v3.0.x-release/bbb-install.sh
less bbb-install.sh
bash bbb-install.sh -w -v jammy-300 -s bbb.example.com -e info@example.com -gThe project's own examples pipe that script straight into bash. Download it and read it first instead, because it rewrites your nginx configuration, installs its own media and audio stack, pins package versions, and claims the hostname. That is the design rather than a flaw: BigBlueButton expects to own the machine. The -w flag configures the firewall, -s is the hostname, -e is the address Let's Encrypt registers, and -g adds the Greenlight front end. If the same box also terminates TLS for other services, either move BigBlueButton elsewhere or make sure you understand what your nginx reverse proxy config is doing before the script edits it.
Compare the two rows in that chart carefully. BigBlueButton asks for twice the memory and twice the cores of Jitsi's suggestion while asking for a quarter of the bandwidth. The two figures are not measured the same way and they assume different room sizes, so treat each as its own project's starting point rather than a like for like comparison. The CPU gap is real, and it comes from everything BigBlueButton does besides forwarding video.
Galène: the small option
Galène is a compact SFU written in Go. It builds to a single static binary, it ships its own web client, and it includes a TURN server, so there is no XMPP server, no Java runtime and no Rails application to keep alive. If your requirement is a reliable ten person call on a modest box, try this before concluding that you need more hardware.
sudo apt update && sudo apt install -y git golang-go
git clone https://github.com/jech/galene
cd galene
CGO_ENABLED=0 go build -ldflags='-s -w'
mkdir groupsThe golang-go package on Ubuntu 24.04 is Go 1.22. If go build complains that the module needs a newer Go, install a current toolchain from go.dev rather than fighting the distribution package.
A group is Galène's word for a room, and a group is a JSON file:
echo '{"users": {"vimes": {"password":"sybil", "permissions":"op"}}}' > groups/night-watch.json
./galene &Open https://your.server:8443/group/night-watch/ and log in as vimes. Those credentials come straight from the project's README, so change them before the port is reachable from anywhere else. For a real deployment the project documents a systemd unit:
[Unit]
Description=Galene
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/galene
User=galene
Group=galene
ExecStart=/home/galene/galene
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetThe ports are TCP 8443 for the web interface, TCP and UDP 1194 for the built-in TURN server, and a range of high UDP ports for media. Pin that range so you can write one firewall rule for it:
./galene -udp-range 40000-40100The -turn option is the one that matters on a VPS. -turn ':1194' listens on all public IPv4 addresses. -turn '203.0.113.1:1194' tells Galène the address that clients will actually see, which is what you need when the machine's own address is private. -turn '' disables the built-in server so you can point at an external one through data/ice-servers.json. The default is auto, which behaves like :1194 when no ice-servers.json exists.
You can put nginx in front of the web interface by setting proxyURL in data/config.json and proxying the /ws location with the WebSocket upgrade headers. Know what that covers: clients still open direct UDP flows and direct TCP connections to the TURN port, so the reverse proxy handles the page and the signalling only. Media never passes through it.
Galène's documentation says it needs very moderate server resources and publishes no specific figure, so do not expect one. The bandwidth arithmetic above still applies in full. What you save is the memory and the moving parts of everything that is not the SFU.
Owncast: one to many, where bandwidth stays linear
Many "video conferencing" requirements are really one person presenting to an audience that types in chat. If that is yours, an SFU is the wrong tool and the economics change completely. Owncast takes an RTMP stream from OBS or a similar encoder and serves HLS over ordinary HTTPS. Bandwidth per viewer is linear rather than quadratic, and because the output is plain HTTP segments you can move it behind object storage or a CDN and stop paying for it on the origin.
curl -sL https://owncast.online/install.sh -o install-owncast.sh
less install-owncast.sh
bash install-owncast.shThe project's documentation says not to run this as root, and to inspect any remote script before executing it, which is why the download is a separate step above. The installer fetches the current release and an ffmpeg binary if you do not already have one. Run ./owncast from the install directory and open the admin panel at /admin on port 8080. The default login is the user admin with the default stream key abc123 as the password. Change it before you point a domain at the server.
Two consequences follow from the HLS design. Viewers are some seconds or minutes behind live, because HLS ships whole segments, so there is no back and forth conversation. And there is no UDP and no TURN anywhere in the path, so it reaches networks where a WebRTC call cannot connect at all.
Owncast is also the one tool here that transcodes on purpose. Each output quality you enable is another ffmpeg encode of the incoming stream, running for the length of the broadcast. On a small VPS, offer one or two qualities. Five will saturate the CPU while the network sits idle.
Element Call on a Matrix server you already run
If you already run Matrix, video is an addition rather than a second product to operate. It is still more than one package. Element Call needs two things behind your homeserver. The first is a LiveKit SFU, which does the media forwarding. The second is the MatrixRTC authorisation service, element-hq/lk-jwt-service, which hands the client the LiveKit WebSocket URL and a signed JWT (JSON web token) to connect with. That service uses the Matrix federation API, so it needs a TLS reverse proxy in front of it and a name that federation can reach.
LiveKit's documented ports:
- TCP 7880 for the API and the client WebSocket, behind a proxy that terminates TLS
- TCP 7881 for ICE over TCP, used when a client cannot get out over UDP
- UDP 50000 to 60000 for media, where each participant in a room uses two ports
- UDP 3478 and TCP 5349 if you enable the embedded TURN server, and 5349 has to move to 443 unless a load balancer sits in front of it
Two ports per participant sounds alarming and is not. A 10,000 port range covers thousands of participants, and your uplink runs out long before the range does. Open the whole range anyway, because a partly open range fails for some people and works for others, which is the worst kind of fault to debug. The homeserver side of this is its own job, covered in running a Synapse homeserver on a VPS.
Why can one person never connect? TURN and the networks that block UDP
Some vocabulary first, used once each. ICE (interactive connectivity establishment) is the process two WebRTC endpoints use to find a working path between them. STUN (session traversal utilities for NAT) is a small service that tells a client what its own public address looks like from outside. TURN (traversal using relays around NAT) is a relay: when no direct path exists, both sides send their media to the TURN server and it forwards.
You need TURN for the participants whose networks you cannot see. One of them is on a corporate or campus network where outbound UDP is blocked entirely. Another is behind a carrier-grade NAT that hands out a different source port for every destination, which is called symmetric NAT and which makes the address STUN reported useless.
The symptom is specific. Most people join and everything works. One person sees the participant list, sees chat, and gets a black tile and a spinner. Their browser gathered candidates, none of the pairs worked, and ICE ended in a failed state. In Chrome, chrome://webrtc-internals open during the attempt shows the candidate pairs and that failure. Ask that person to retry from a phone on mobile data. If it works there, their network is the cause and TURN is the fix.
TURN over TCP on 443 or 5349 is the fallback that works nearly everywhere, because a network blocking TLS on 443 has blocked the web. Jitsi's package installs and configures coturn for you, which is exactly why its documented firewall rules include UDP 3478 and TCP 5349. Galène has TURN built in on 1194. LiveKit has an embedded TURN server you switch on in config. If you run coturn yourself:
sudo apt install -y coturn
sudo systemctl enable --now coturnlistening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=REPLACE_WITH_A_LONG_RANDOM_STRING
realm=turn.example.com
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
min-port=49152
max-port=65535
no-multicast-peersOn Debian and Ubuntu the packaged service will not start until you set TURNSERVER_ENABLED=1 in /etc/default/coturn. A coturn that is installed but never enabled looks identical from the outside to having no TURN at all, which is why this one line costs people whole evenings.
Now the cost nobody writes down. A relay carries the full media of every relayed participant in both directions. When coturn shares a box with the SFU, most of that traffic crosses loopback and costs you CPU rather than uplink, and the TLS listener encrypts every packet a second time on top of the DTLS the media already carries. When you move TURN to its own machine, that machine needs its own bandwidth plan sized the same way as the SFU's. And TURN over TCP turns real-time media into a reliable stream, so a lost packet is retransmitted instead of skipped, and a relayed participant on a lossy link accumulates delay rather than a brief glitch. Relay is a fallback that connects, at a quality the direct path would have beaten.
When does the SFU transcode, and what does that cost?
An SFU forwards packets and never decodes video, which is why four cores can serve a room that looks impossible on paper. Two features break that property, and both surprise the people who turned them on from a checkbox.
Recording is the first. Jitsi records with Jibri, and Jibri's own documentation describes exactly what it does: it launches a Chrome instance rendered in a virtual framebuffer and captures and encodes the output with ffmpeg. That is a full browser rendering your entire meeting, plus a video encoder, running continuously for the length of the call. The same documentation states that only one recording at a time is supported on a single Jibri, and that Jibri is intended to run on a separate machine or virtual machine with no other applications using the display or audio devices. Recording is a second server, not a checkbox.
Telephone dial-in is the second. Bridging a phone line into a conference means converting Opus at 48 kHz into what the telephone network accepts, usually G.711 at 8 kHz, in both directions and continuously for the whole call. Audio transcoding is far cheaper than video transcoding, but it runs per call leg and it never pauses, so the cost scales with the number of callers. If you want a dial-in number, a self-hosted VoIP server is the component that does that work, and it belongs on its own box for the same reason Jibri does.
How big a box do you actually need?
For two people, almost nothing. Jitsi enables peer to peer mode by default when there are exactly two participants, and in that mode the conference stops sending data through the videobridge and uses the direct connection instead. A third person joining switches it back to the bridge. So a 1 GB VPS running Jitsi is a fine one to one call server and a poor four person one, which is why "it worked when I tested it" is such a common report.
For up to about ten people with cameras on, 67.2 Mbps at eight participants sits inside what an ordinary VPS uplink sustains. Two virtual CPUs and 4 GB will run Jitsi or Galène at that size as long as you are not recording. Watch the transfer counter rather than the CPU graph.
For thirty people, the worst case is 1,044 Mbps sustained, and twenty hours of it is 9.4 TB. This is the size where you price the bandwidth before you price the box. Turn on last-N so the bridge forwards only the recent speakers, make camera-off the default for attendees, and put the SFU somewhere with a transfer allowance that survives the arithmetic.
Above that, one VPS is the wrong shape. Either the event is really a broadcast, in which case Owncast plus a CDN costs a fraction of this, or you need more than one videobridge behind a single signalling layer, which is a different project from the one you started.
One last piece of routing. Most teams need chat for far more hours of the day than they need video, and chat is cheap to host and easy to keep running. Standing up a self-hosted Slack alternative for the everyday traffic, and keeping a conferencing server for scheduled calls only, is the arrangement that survives contact with a small VPS budget.
FAQ
Why can people join my Jitsi meeting but not see or hear each other?
Chat and the participant list travel over the signalling channel, which is TCP on 443, while audio and video use UDP 10000 to the videobridge. If the roster fills and every tile stays black, the media path is broken while the signalling path is fine. Check UDP 10000 in both firewalls, the one on the box and the separate network firewall in your provider's control panel. Then check that the bridge knows its public address: on a virtual machine with a private address and a mapped public one, add a static mapping under ice4j.harvest.mapping in /etc/jitsi/videobridge/jvb.conf and restart jitsi-videobridge2. Running sudo tcpdump -ni any udp port 10000 while somebody joins tells you which of the two it is, because no packets at all means the block is upstream of the operating system.
How much bandwidth does a 30 person video call use?
In the worst case, where everyone has a camera on and the SFU forwards a full quality layer to everybody, roughly 1,044 Mbps leaves the server, which is 469.8 GB per hour. That is arithmetic from N times (N minus 1) streams at 1.2 Mbps each, not a measurement of your setup. Simulcast and a last-N setting cut it a long way in normal use, because most participants are not on screen at any given moment. Size for something near the worst case anyway, since the worst case is the all-hands where everybody turns their camera on at once.
Can I run Jitsi Meet on a 1 GB VPS?
It will install, and a two person call will work, partly because Jitsi uses peer to peer mode for exactly two participants and skips the videobridge entirely. It is not a useful group call server. Prosody, the videobridge and the Java runtime all want memory, the handbook's own suggestion is 8 GB for a serious deployment, and the bandwidth arithmetic will bite before the memory does. If a 1 GB box is what you have, Galène is a better fit at that size than Jitsi.
Do I still need a TURN server if my VPS has a public IP address?
Yes. The problem TURN solves sits at the other end of the call. A participant on a corporate network that blocks outbound UDP, or behind a carrier-grade NAT that assigns a different source port per destination, cannot build a direct media path however public your server's address is. TURN over TCP on 443 or 5349 gives them a relay that looks like ordinary web traffic to their firewall. Jitsi's package install sets up coturn for this by default, which is why its documented firewall rules open UDP 3478 and TCP 5349.
Why does BigBlueButton need so much more hardware than Jitsi Meet?
Because it does much more than forward video. Its published production requirement is 16 GB of RAM and 8 cores, against 8 GB and 4 cores in Jitsi's handbook. BigBlueButton runs a full audio conferencing stack, a shared whiteboard and presentation layer, a recording and post-processing pipeline, and a web front end with user accounts, all on the same machine. It is also opinionated about its platform: as of August 2026 the supported install is version 3.0 on Ubuntu 22.04. Both sets of figures come from each project's own documentation and are starting points rather than measurements of your workload.