Can I self-host Zoom?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Jitsi Meet. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $84.95 a month you stop paying Zoom — $1,019.40 a year on the Pro plan, 5 seats assumed.

  • zoom.com
  • Comms & scheduling
  • prices checked 2026-08-06

Why people pay for Zoom

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Zoom sells a call that connects. Their bandwidth, their global relay network, their client on every laptop and phone and conference-room screen your guests already have, and a dial-in number for the person whose wifi has given up. The 40-minute limit on the free tier is the product working exactly as designed: it is short enough to interrupt a real meeting and long enough to prove the thing is good.

Zoom plans and list prices
PlanList priceWhat it buys
BasicfreeFree. Zoom's pricing page states 40 minutes per meeting and up to 100 participants.
Prothe plan this page prices against$16.99/mo per seatPer user per month billed monthly; about $13.33 per user per month billed annually. Zoom's page states 30-hour meetings and up to 100 participants. See pricing.note: the vendor page did not render a figure to us and one secondary source lists the monthly rate as $18.33.
Business$21.99/mo per seatPer user per month billed monthly; about $18.33 per user per month billed annually. Zoom's page states up to 300 participants.
Enterprisequote onlyQuote only. Zoom's page states up to 1000 participants.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: low

Replaced by Jitsi Meet

One project, named before the prompt, so you know what you are about to install.

Video meetings on your own hostname, with no participant tier and no 40-minute cut-off.

The only one here that a guest can join by clicking a link in a browser, with no account, no download and no explaining. That is the property Zoom actually sells, and it is the one that decides whether a self-hosted meeting server survives contact with people who did not choose it. What you take on in exchange is the bandwidth: your VPS relays every stream, so call quality stops being someone else's problem and becomes a line on your hosting bill.

The swap

You're paying

Zoom

$84.95/mo · $1,019.40/yr

is replaced by

You'd run

Jitsi Meet

ONE WEEKEND · ~240 min to running · 2048 MB RAM

Zoom Pro · 5 seats assumed · vendor list price · checked 2026-08-06 · source · confidence: low

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same Jitsi Meet: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

361 lines · 14,992 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Jitsi Meet stable-11146-1 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server: step 3 reads that record to tell the video
bridge where participants send media, so a wrong one fails silently later, not loudly now.

Jitsi Meet needs 2048 MB of RAM available and 10 GB free on /srv. All four images publish amd64
and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

## 2. Layout

Upstream states the containers run as uid 1000 and refuse to start when a directory they write
to is not writable by it, so these have two owners on purpose:

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/jitsi /srv/jitsi/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/jitsi/prosody
sudo install -d -m 755 -o 1000 -g 1000 /srv/jitsi/config /srv/jitsi/config/web /srv/jitsi/config/prosody /srv/jitsi/config/jicofo /srv/jitsi/config/jvb
ls -la /srv/jitsi
```

Assert: `backups` is owned by the login user and `prosody` is mode `750` owned by uid `1000`.
That directory is the entire persistent state here: the Prosody account file, which after step
7 is all that stands between a stranger and opening meetings on this hostname. The `config`
tree is rewritten at every start.

## 3. Secrets

Three: the XMPP password Jicofo signs in with, the one the video bridge signs in with, and the
moderator password step 7 registers. Generate all three on the server. Do not print them,
do not repeat them in your summary, do not put them in a log line.

```bash
umask 077
cat > /srv/jitsi/.env <<EOF
PUBLIC_URL=https://<DOMAIN>
JICOFO_AUTH_PASSWORD=$(openssl rand -hex 32)
JVB_AUTH_PASSWORD=$(openssl rand -hex 32)
MEET_HOST_PASSWORD=$(openssl rand -base64 24)
EOF
printf 'JVB_ADVERTISE_IPS=%s\n' "$(dig +short <DOMAIN> | tail -1)" >> /srv/jitsi/.env
chmod 600 /srv/jitsi/.env
umask 022
ls -l /srv/jitsi/.env
grep -c '^JVB_ADVERTISE_IPS=[0-9]' /srv/jitsi/.env
```

Assert: mode `-rw-------`, and the last command prints `1`. A `0` means the A record resolved to
nothing and the bridge would advertise no address, which is a meeting where everyone connects
and nobody hears anyone. Fix the DNS and rerun this block.

Tell the user the moderator password is in /srv/jitsi/.env, readable with
`grep MEET_HOST_PASSWORD /srv/jitsi/.env`, and that it belongs in their password manager now:
it is the only login here.

## 4. compose.yml

```bash
cat > /srv/jitsi/compose.yml <<'EOF'
# Jitsi Meet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting guide . https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   variable reference . https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   upstream compose ... https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Four services and no database. Prosody carries the signalling, Jicofo decides
# who is in which conference, the videobridge forwards media, the web container
# is nginx plus the browser app. Meetings are never stored, so the only state
# that outlives a restart is the Prosody account file under /srv/jitsi/prosody,
# which upstream requires to be writable by uid 1000.
#
# Tags and digests read from ghcr.io on 2026-08-06; all four publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: jitsi

services:
  prosody:
    image: ghcr.io/jitsi/prosody:stable-11146-1@sha256:0e3d9ada40c03e6eef151348e0872dce7b4b1c16c173ff4a67afeae60aba2404
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/prosody:/config
      - /srv/jitsi/prosody:/var/lib/prosody
    environment:
      # Rooms open only for an account registered in step 7; guests wait.
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      ENABLE_GUESTS: "1"
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Read by step 7's register command inside this container.
      MEET_HOST_PASSWORD: ${MEET_HOST_PASSWORD}
      PUBLIC_URL: ${PUBLIC_URL}
    # No `ports:`: 5222 and 5280 are reachable only from the other containers.

  jicofo:
    image: ghcr.io/jitsi/jicofo:stable-11146-1@sha256:a5da296923010dcc2daf6a02e6a183181906cb969a088ae90b97516bdeb9737f
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jicofo:/config
    environment:
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      # Upstream's default heap ceiling is 3072m, larger than the whole box.
      JICOFO_MAX_MEMORY: 512m
      XMPP_SERVER: prosody
    depends_on:
      - prosody

  jvb:
    image: ghcr.io/jitsi/jvb:stable-11146-1@sha256:6a7cec66c6a2fdd8ffd3a90101a0f8e3297aff29494f258caf1bcfbd418a17f3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jvb:/config
    environment:
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Behind Docker's NAT. Step 3 writes the address to advertise.
      JVB_ADVERTISE_IPS: ${JVB_ADVERTISE_IPS}
      VIDEOBRIDGE_MAX_MEMORY: 1024m
      XMPP_SERVER: prosody
    ports:
      # Media, not web: browsers send RTP straight here.
      - "10000:10000/udp"
    depends_on:
      - prosody

  web:
    image: ghcr.io/jitsi/web:stable-11146-1@sha256:ff81559621732d3dfc4815f261d41fd826566833016ea772f4d43a77aa88fe9a
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/web:/config
    environment:
      PUBLIC_URL: ${PUBLIC_URL}
      # Caddy holds the certificate, so nginx here serves plain http on 8000.
      DISABLE_HTTPS: "1"
      ENABLE_AUTH: "1"
      ENABLE_GUESTS: "1"
      XMPP_BOSH_URL_BASE: http://prosody:5280
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8114.
      - "127.0.0.1:8114:8000"
    depends_on:
      - jvb
EOF
cd /srv/jitsi && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No database anywhere, so nothing to migrate or dump. Both heap ceilings are deliberate cuts from upstream's 3072m defaults.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-jitsi
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Jitsi Meet · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also PUBLIC_URL in .env, and the browser app builds its signalling URL from
# it, so the two have to agree.

<DOMAIN> {
	# The signalling WebSocket is upgraded by reverse_proxy on its own.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8114 is the loopback port compose publishes. No media comes through here.
	reverse_proxy 127.0.0.1:8114
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-jitsi, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own. It carries the page and the
signalling WebSocket, and no media.

## 6. Firewall

Four ports, and one of them is not Caddy's:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 10000/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp carries the page and the
signalling, 443/udp is HTTP/3. 10000/udp is the different one: upstream lists it as the RTP
media port, and media goes from each browser straight to the video bridge without touching
Caddy, so no reverse proxy can stand in for it the way one does for 8114, which stays closed on
127.0.0.1.

Assert: `Status: active`, rules for 80, 443/tcp, 443/udp and 10000/udp, none for 8114. Tell the
user one thing about that fourth rule: Docker writes its own iptables rules for a published port
ahead of ufw's, so deleting it would not close 10000/udp. `docker compose down` does.

## 7. Start and verify

```bash
cd /srv/jitsi
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -o '<title>Jitsi Meet</title>'
curl -sS https://<DOMAIN>/config.js | grep -E "config.hosts.(auth|anonymous)domain"
ss -lun | grep ':10000'
```

Assert all four and print what you received. The loop ends on `200`. The second prints
`<title>Jitsi Meet</title>`. The third prints
`config.hosts.anonymousdomain = 'guest.meet.jitsi';` and
`config.hosts.authdomain = 'meet.jitsi';`, two lines that appear only when authenticated room
creation is on. The fourth prints a UDP listener on 10000. If any miss, stop, run
`docker compose logs --tail 40 web` and `docker compose logs --tail 40 prosody`, and name the
likely step: a `502` means Caddy reaches nothing on 8114; missing `config.hosts` lines mean step
4 was edited. A running container is not success.

Register the account that can open meetings, and prove nobody else can:

```bash
cd /srv/jitsi
docker compose exec -T prosody sh -c 'prosodyctl --config /run/prosody/config/prosody.cfg.lua register moderator meet.jitsi "$MEET_HOST_PASSWORD"'
docker compose exec -T prosody find /var/lib/prosody -name 'moderator.dat'
docker compose exec -T prosody awk '/^VirtualHost "meet.jitsi"$/,/^VirtualHost /' /run/prosody/config/conf.d/jitsi-meet.cfg.lua | grep authentication
```

Assert both. The `find` prints one path ending in `moderator.dat`. The `awk` prints
`authentication = "internal_hashed"`, the security assert here: that domain takes registered
accounts only, so a visitor without one can wait in a room but cannot open one. The password
reached prosodyctl from the container's own environment, so it is in neither the host process
list nor the shell history. If the `find` prints nothing, Prosody was likely still
starting: wait 30 seconds and rerun the register line. On any other miss, stop.

The first screen at https://<DOMAIN> is the Jitsi welcome page, with a room-name box and a
`Start meeting` button.

STOP: tell the user to open https://<DOMAIN>, type a room name, start the meeting, and sign in
as `moderator` with the password from `grep MEET_HOST_PASSWORD /srv/jitsi/.env`. Then have them
open the same room in a private window without signing in and confirm it is told to wait for a
host. Wait for both. Do not continue until they confirm. Only a browser proves a camera and a
microphone reach the bridge.

## 8. First backup and restore

One archive. There is no database and meetings never existed as data, so what is worth
keeping is small: the account file, the two config files, the Caddy site block.

```bash
cd /srv/jitsi
sudo tar -czf /srv/jitsi/backups/jitsi-config-$(date +%F).tar.gz -C /srv/jitsi compose.yml .env prosody -C /etc/caddy Caddyfile
ls -lh /srv/jitsi/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped.

A backup on the same disk as the data is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/jitsi
scp vps:/srv/jitsi/backups/*.tar.gz ~/backups/jitsi/
```

To restore: `docker compose down`, `sudo rm -rf /srv/jitsi/prosody`,
`sudo tar -xzf <archive> -C /srv/jitsi compose.yml .env prosody`,
`sudo chown -R 1000:1000 /srv/jitsi/prosody` because the container user has to own it again,
then `docker compose up -d`. The Caddy site block is in the same archive if /etc/caddy ever
needs rebuilding. Losing this costs no history, there is none, but it costs the moderator
account and the two service passwords: a stack that starts and refuses every login until steps
3 and 7 are redone.

## 9. Updating later

New versions are listed at https://github.com/jitsi/docker-jitsi-meet/releases. Take the backup
first, then edit all four image lines in /srv/jitsi/compose.yml to the new tag and its digest.
The four ship together and upstream does not support mixing them.

```bash
cd /srv/jitsi
docker compose pull
docker compose up -d
docker compose logs --tail 30 jicofo
```

Watch that log until it settles, then re-run the four checks from step 7.

## 10. What will probably go wrong

The install will look finished and the first call will have picture and no sound. I lost most of
an evening to that. The page comes through Caddy and the media does not: it goes to 10000/udp at
the bridge, using the address in `JVB_ADVERTISE_IPS`, and all three can be right on their own
while the call stays silent. Check in that order: `grep JVB_ADVERTISE_IPS /srv/jitsi/.env`
against `dig +short <DOMAIN>`, then `ss -lun | grep ':10000'`, then whether the hosting provider
runs a firewall of its own in front of the box, because several do and UDP is what gets forgotten
there.

## 11. Out of scope

- Do not install Jibri or configure recording. That is a separate container running a headless
  browser with a CPU budget of its own.
- Do not install Jigasi or set up dial-in numbers. That needs a SIP provider and a phone number,
  which is a purchase, not a configuration step.
- Do not enable `ENABLE_LETSENCRYPT`. Caddy holds the certificate for this hostname and a second
  ACME client on the same name collides with it.
- Do not switch `AUTH_TYPE` to `jwt` or add an identity provider. Internal accounts are the
  choice here.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Jitsi Meet stable-11146-1 on a VPS where Prompt Zero is done: `ssh vps`
works, Docker and Caddy are installed, the firewall is default-deny. Run everything over
`ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record
already points at the box.

Read this before step 1. Jitsi is two networks, not one. The page and the signalling come
through Caddy on 443; the audio and video go from every participant's browser straight to the
video bridge on 10000/udp, and that port has to be open on the box and reachable from outside
it. Most installs that "work but have no sound" are that second network.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. That record does two jobs here, not one: Caddy needs it to get
a certificate, and step 3 copies the address out of it so the video bridge knows where to tell
browsers to send media. An IP that is not your server's means a proxying CDN is in front of the
record; turn that off for this hostname, because a CDN will not carry UDP media and the address
step 3 records would be the wrong one.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/jitsi /srv/jitsi/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/jitsi/prosody
sudo install -d -m 755 -o 1000 -g 1000 /srv/jitsi/config /srv/jitsi/config/web /srv/jitsi/config/prosody /srv/jitsi/config/jicofo /srv/jitsi/config/jvb
ls -la /srv/jitsi
```

You should see: `backups` owned by you, and `prosody` and `config` owned by `1000`, which on
many servers is also you.

If you do not: leave the `1000` there even if it is not your own id. Upstream states these
containers run as uid 1000 and check on startup that the directory they write to is writable by
it, so a directory owned by anyone else produces a Prosody container that exits immediately with
a message about a volume that is not writable. `/srv/jitsi/prosody` is the only directory in
this install that holds anything you would miss.

## 3. Secrets

Three secrets, all generated on the server: the XMPP password Jicofo signs in with, the one the
video bridge signs in with, and the password for the moderator account step 7 creates.

```bash
umask 077
cat > /srv/jitsi/.env <<EOF
PUBLIC_URL=https://<DOMAIN>
JICOFO_AUTH_PASSWORD=$(openssl rand -hex 32)
JVB_AUTH_PASSWORD=$(openssl rand -hex 32)
MEET_HOST_PASSWORD=$(openssl rand -base64 24)
EOF
printf 'JVB_ADVERTISE_IPS=%s\n' "$(dig +short <DOMAIN> | tail -1)" >> /srv/jitsi/.env
chmod 600 /srv/jitsi/.env
umask 022
ls -l /srv/jitsi/.env
grep -c '^JVB_ADVERTISE_IPS=[0-9]' /srv/jitsi/.env
```

You should see: mode `-rw-------`, your own username twice, and then `1` on the last line.
Replace `<DOMAIN>` on the first line with your real hostname before you paste.

If you do not: a `0` on the last line means `dig` returned nothing and the bridge would come up
advertising no address, which is a meeting where everybody connects and nobody can hear anyone.
Fix the DNS and paste this block again. A mode of `-rw-r--r--` means `umask 077` did not take
effect, which happens when the lines are pasted separately into different shells; run
`chmod 600 /srv/jitsi/.env` and carry on.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. Read your moderator password once, later, with
`grep MEET_HOST_PASSWORD /srv/jitsi/.env`, and put it straight into your password manager. It is
the only login this install has.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/jitsi/compose.yml <<'EOF'
# Jitsi Meet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting guide . https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   variable reference . https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   upstream compose ... https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Four services and no database. Prosody carries the signalling, Jicofo decides
# who is in which conference, the videobridge forwards media, the web container
# is nginx plus the browser app. Meetings are never stored, so the only state
# that outlives a restart is the Prosody account file under /srv/jitsi/prosody,
# which upstream requires to be writable by uid 1000.
#
# Tags and digests read from ghcr.io on 2026-08-06; all four publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: jitsi

services:
  prosody:
    image: ghcr.io/jitsi/prosody:stable-11146-1@sha256:0e3d9ada40c03e6eef151348e0872dce7b4b1c16c173ff4a67afeae60aba2404
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/prosody:/config
      - /srv/jitsi/prosody:/var/lib/prosody
    environment:
      # Rooms open only for an account registered in step 7; guests wait.
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      ENABLE_GUESTS: "1"
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Read by step 7's register command inside this container.
      MEET_HOST_PASSWORD: ${MEET_HOST_PASSWORD}
      PUBLIC_URL: ${PUBLIC_URL}
    # No `ports:`: 5222 and 5280 are reachable only from the other containers.

  jicofo:
    image: ghcr.io/jitsi/jicofo:stable-11146-1@sha256:a5da296923010dcc2daf6a02e6a183181906cb969a088ae90b97516bdeb9737f
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jicofo:/config
    environment:
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      # Upstream's default heap ceiling is 3072m, larger than the whole box.
      JICOFO_MAX_MEMORY: 512m
      XMPP_SERVER: prosody
    depends_on:
      - prosody

  jvb:
    image: ghcr.io/jitsi/jvb:stable-11146-1@sha256:6a7cec66c6a2fdd8ffd3a90101a0f8e3297aff29494f258caf1bcfbd418a17f3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jvb:/config
    environment:
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Behind Docker's NAT. Step 3 writes the address to advertise.
      JVB_ADVERTISE_IPS: ${JVB_ADVERTISE_IPS}
      VIDEOBRIDGE_MAX_MEMORY: 1024m
      XMPP_SERVER: prosody
    ports:
      # Media, not web: browsers send RTP straight here.
      - "10000:10000/udp"
    depends_on:
      - prosody

  web:
    image: ghcr.io/jitsi/web:stable-11146-1@sha256:ff81559621732d3dfc4815f261d41fd826566833016ea772f4d43a77aa88fe9a
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/web:/config
    environment:
      PUBLIC_URL: ${PUBLIC_URL}
      # Caddy holds the certificate, so nginx here serves plain http on 8000.
      DISABLE_HTTPS: "1"
      ENABLE_AUTH: "1"
      ENABLE_GUESTS: "1"
      XMPP_BOSH_URL_BASE: http://prosody:5280
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8114.
      - "127.0.0.1:8114:8000"
    depends_on:
      - jvb
EOF
cd /srv/jitsi && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal; run `rm /srv/jitsi/compose.yml` and paste again in one go. A warning about
`JVB_ADVERTISE_IPS` being unset means step 3 did not finish. There is no database in this file
and that is not an omission: Jitsi stores no meetings, so there is nothing to migrate and
nothing to dump.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-jitsi
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Jitsi Meet · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also PUBLIC_URL in .env, and the browser app builds its signalling URL from
# it, so the two have to agree.

<DOMAIN> {
	# The signalling WebSocket is upgraded by reverse_proxy on its own.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8114 is the loopback port compose publishes. No media comes through here.
	reverse_proxy 127.0.0.1:8114
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-jitsi /etc/caddy/Caddyfile`, reload, and
paste again. Caddy holds the certificate for this hostname, which is why `DISABLE_HTTPS` is set
in the compose file: the web container serves plain http on 8114 and generates no certificate of
its own. Nothing about media passes through this block.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 10000/udp
sudo ufw status verbose
```

You should see: `Status: active`, and rules for `80/tcp`, `443/tcp`, `443/udp` and `10000/udp`,
with no rule mentioning `8114`.

If you do not: delete anything for `8114` with `sudo ufw delete allow 8114`; that port is bound
to 127.0.0.1 by the compose file and only Caddy reaches it. The rule that is different from
every other install on this site is `10000/udp`: upstream lists it as the RTP media port, and
media never passes through a reverse proxy, so this is the one place where "the proxy handles
it" is not true. One honest note about that rule: Docker installs its own iptables rules for a
published port ahead of ufw's, so removing this rule would not actually close 10000/udp.
`docker compose down` is what closes it. `Status: inactive` is a different problem, because
Prompt Zero left this firewall on: `sudo ufw enable` puts it back.

## 7. Start and verify

```bash
cd /srv/jitsi
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -o '<title>Jitsi Meet</title>'
curl -sS https://<DOMAIN>/config.js | grep -E "config.hosts.(auth|anonymous)domain"
ss -lun | grep ':10000'
```

You should see, in order: the loop reaching `200`, then `<title>Jitsi Meet</title>`, then two
lines reading `config.hosts.anonymousdomain = 'guest.meet.jitsi';` and
`config.hosts.authdomain = 'meet.jitsi';`, then a line showing something listening on UDP
port 10000.

If you do not: a `502` from the loop means Caddy is reaching nothing on 8114, so check
`docker compose ps` and then `docker compose logs --tail 40 web`. If the loop reaches `200` but
the two `config.hosts` lines are missing, the authentication settings did not reach the web
container, so re-check step 4 and run `docker compose up -d --force-recreate web`. Those two
lines are what tell the browser to ask for a login, so an install without them is one anybody
can open meetings on. If the last command prints nothing, the bridge did not start; read
`docker compose logs --tail 40 jvb`. Connection-refused lines in `docker compose logs jicofo`
during the first minute are normal: it retries until Prosody is up.

Now create the account that is allowed to open meetings, and confirm that nobody else can:

```bash
cd /srv/jitsi
docker compose exec -T prosody sh -c 'prosodyctl --config /run/prosody/config/prosody.cfg.lua register moderator meet.jitsi "$MEET_HOST_PASSWORD"'
docker compose exec -T prosody find /var/lib/prosody -name 'moderator.dat'
docker compose exec -T prosody awk '/^VirtualHost "meet.jitsi"$/,/^VirtualHost /' /run/prosody/config/conf.d/jitsi-meet.cfg.lua | grep authentication
```

You should see: one path ending in `moderator.dat`, then `authentication = "internal_hashed"`.

If you do not: an empty `find` means the register command failed, most often because Prosody was
still starting; wait thirty seconds and paste the first line again. If the last line says
`authentication = "jitsi-anonymous"` instead, authentication is off and anyone who finds your
hostname can open a meeting on your bandwidth: stop, fix step 4, recreate the stack, and do not
leave it running in between. Note that your password never appears in either command, because
Prosody reads it from the container's own environment.

The first screen at https://<DOMAIN> is the Jitsi welcome page, with a room-name box and a
`Start meeting` button.

Now the part curl cannot do. Open https://<DOMAIN>, type a room name, start the meeting, and
sign in as `moderator` with the password from `grep MEET_HOST_PASSWORD /srv/jitsi/.env`. Allow
the camera and microphone when the browser asks. Then open the same room address in a private
window without signing in, and confirm that window is told to wait for a host rather than being
let straight in.

You should see: your own camera in the first window, and a waiting message in the second.

If you do not: a call that connects but has no audio or video is the media path, not the login.
Compare `grep JVB_ADVERTISE_IPS /srv/jitsi/.env` with `dig +short <DOMAIN>`, confirm
`ss -lun | grep ':10000'` still prints a listener, and then check whether your hosting provider
runs a firewall of its own in front of the box. A running container is not success, and neither
is a page that loads.

## 8. First backup and restore

One archive: the account file, the two config files, and the Caddy site block.

```bash
cd /srv/jitsi
sudo tar -czf /srv/jitsi/backups/jitsi-config-$(date +%F).tar.gz -C /srv/jitsi compose.yml .env prosody -C /etc/caddy Caddyfile
ls -lh /srv/jitsi/backups/
```

You should see: one file, a few kilobytes. Nothing goes offline while it runs.

If you do not: an archive of about 100 bytes means the `prosody` directory was empty, so step 7
never created the account. Go back and check the `find` output before you rely on this.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/jitsi
scp vps:/srv/jitsi/backups/*.tar.gz ~/backups/jitsi/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/jitsi/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is a test account:

```bash
cd /srv/jitsi
docker compose down
sudo rm -rf /srv/jitsi/prosody
sudo tar -xzf /srv/jitsi/backups/jitsi-config-$(date +%F).tar.gz -C /srv/jitsi compose.yml .env prosody
sudo chown -R 1000:1000 /srv/jitsi/prosody
docker compose up -d
sleep 30
docker compose exec -T prosody find /var/lib/prosody -name 'moderator.dat'
```

You should see: the same path ending in `moderator.dat`, from a directory you deleted and
rebuilt.

If you do not: the `chown` line is the one people skip. Without it the directory belongs to root
and the Prosody container exits on startup rather than reading anything. The Caddy site block is
in that same archive at the top level, if `/etc/caddy` ever needs rebuilding too.

## 9. Updating later

New versions are listed at https://github.com/jitsi/docker-jitsi-meet/releases. Take the backup
first, then edit all four `image:` lines in /srv/jitsi/compose.yml to the new tag and its digest.
The four ship together and upstream does not support mixing them.

```bash
cd /srv/jitsi
docker compose pull
docker compose up -d
docker compose logs --tail 30 jicofo
```

You should see: Jicofo connecting to Prosody and finding a bridge, then no repeating restart.

If you do not: put the old tag and digest back on all four lines and run the same three commands.
Then re-run the four checks from step 7, and make one real call, because a page that loads
proves nothing about the media path.

## 10. What will probably go wrong

The install will look finished and the first call will have picture and no sound. I lost most of
an evening to that. The page comes through Caddy and the media does not: it goes to 10000/udp at
the bridge, using the address in `JVB_ADVERTISE_IPS`, and all three can be right on their own
while the call stays silent. Check in that order: `grep JVB_ADVERTISE_IPS /srv/jitsi/.env`
against `dig +short <DOMAIN>`, then `ss -lun | grep ':10000'`, then whether the hosting provider
runs a firewall of its own in front of the box, because several do and UDP is what gets
forgotten there.

## 11. Out of scope

- Do not install Jibri or configure recording. That is a separate container running a headless
  browser with a CPU budget of its own.
- Do not install Jigasi or set up dial-in numbers. That needs a SIP provider and a phone number,
  which is a purchase, not a configuration step.
- Do not enable `ENABLE_LETSENCRYPT`. Caddy holds the certificate for this hostname and a second
  ACME client on the same name collides with it.
- Do not switch `AUTH_TYPE` to `jwt` or add an identity provider. Internal accounts are the
  choice here.

365 lines · 16,048 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Jitsi Meet stable-11146-1, all four of its services, under ~/selfhost/jitsi, answering
at http://localhost:8114.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. A meeting room here is reachable from this computer and nowhere else: no colleague and not
even their own phone can join. What they get is a real Jitsi to learn and test with two browser
windows, not a room they can invite anyone into.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Jitsi Meet needs 2048 MB of RAM available
and 10 GB free on the home disk, and all four images publish amd64 and arm64. On macOS and
Windows that memory is the host's, out of which Docker Desktop's virtual machine takes its
allocation. If either number is under its floor, print both and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$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-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/jitsi/backups ~/selfhost/jitsi/config/web ~/selfhost/jitsi/config/prosody ~/selfhost/jitsi/config/jicofo ~/selfhost/jitsi/config/jvb
ls -la ~/selfhost/jitsi
```

Assert: `ls -la` shows `backups` and `config`, owned by the user. No ownership fix runs here:
the containers only read the `config` tree, and the one directory they write to is a Docker
volume in step 5 rather than a folder.

## 4. Secrets

Three: the XMPP password Jicofo signs in with, the one the video bridge signs in with, and the
moderator password step 7 registers. Generate all three here, print none, and keep them out of
your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/jitsi/.env <<EOF
JICOFO_AUTH_PASSWORD=$(openssl rand -hex 32)
JVB_AUTH_PASSWORD=$(openssl rand -hex 32)
MEET_HOST_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/selfhost/jitsi/.env
umask 022
ls -l ~/selfhost/jitsi/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere. On
Windows those mode bits are advisory: NTFS does not enforce them and the real boundary is the
user's own account.

Tell the user the moderator password is in ~/selfhost/jitsi/.env, readable with
`grep MEET_HOST_PASSWORD ~/selfhost/jitsi/.env`, and that it belongs in their password manager
now. It is the only login here.

## 5. compose.yml

```bash
cat > ~/selfhost/jitsi/compose.yml <<'EOF'
# Jitsi Meet · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting guide . https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   variable reference . https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   upstream compose ... https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Four services and no database, on the computer you are sitting at. Paths are
# relative to ~/selfhost/jitsi/, so one file works on macOS, Linux and Windows.
# The Prosody account store is a named volume rather than a bind mount: that
# image runs as uid 1000 and refuses to start when its data directory is not
# writable by it, which Docker Desktop on Windows cannot arrange for a folder
# under the home directory.
#
# Tags and digests read from ghcr.io on 2026-08-06; all four publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: jitsi

services:
  prosody:
    image: ghcr.io/jitsi/prosody:stable-11146-1@sha256:0e3d9ada40c03e6eef151348e0872dce7b4b1c16c173ff4a67afeae60aba2404
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/prosody:/config
      - jitsi-prosody:/var/lib/prosody
    environment:
      # Rooms open only for an account registered in step 7; guests wait.
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      ENABLE_GUESTS: "1"
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Read by step 7's register command inside this container.
      MEET_HOST_PASSWORD: ${MEET_HOST_PASSWORD}
      PUBLIC_URL: http://localhost:8114
    # No `ports:`: 5222 and 5280 are reachable only from the other containers.

  jicofo:
    image: ghcr.io/jitsi/jicofo:stable-11146-1@sha256:a5da296923010dcc2daf6a02e6a183181906cb969a088ae90b97516bdeb9737f
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/jicofo:/config
    environment:
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      # Upstream's default heap ceiling is 3072m, more than a laptop wants.
      JICOFO_MAX_MEMORY: 512m
      XMPP_SERVER: prosody
    depends_on:
      - prosody

  jvb:
    image: ghcr.io/jitsi/jvb:stable-11146-1@sha256:6a7cec66c6a2fdd8ffd3a90101a0f8e3297aff29494f258caf1bcfbd418a17f3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/jvb:/config
    environment:
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # The only address a browser on this computer can reach the bridge on.
      JVB_ADVERTISE_IPS: 127.0.0.1
      VIDEOBRIDGE_MAX_MEMORY: 1024m
      XMPP_SERVER: prosody
    ports:
      # Media, not web: browsers send RTP straight here.
      - "10000:10000/udp"
    depends_on:
      - prosody

  web:
    image: ghcr.io/jitsi/web:stable-11146-1@sha256:ff81559621732d3dfc4815f261d41fd826566833016ea772f4d43a77aa88fe9a
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/web:/config
    environment:
      PUBLIC_URL: http://localhost:8114
      DISABLE_HTTPS: "1"
      ENABLE_AUTH: "1"
      ENABLE_GUESTS: "1"
      # No certificate here, so signalling rides a relative BOSH path.
      BOSH_RELATIVE: "1"
      ENABLE_XMPP_WEBSOCKET: "0"
      XMPP_BOSH_URL_BASE: http://prosody:5280
    ports:
      # Loopback only: no other device on the wifi can reach 8114.
      - "127.0.0.1:8114:8000"
    depends_on:
      - jvb

volumes:
  jitsi-prosody:
EOF
cd ~/selfhost/jitsi && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Four services, no database, one named volume, two ports.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve, and a
certificate attests a public name that nothing here has. Browsers treat http://localhost as a
secure context, so camera and microphone permissions still work without one.

8114 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi. One port is not on loopback and the user should hear it plainly. 10000/udp, the media
port, is published the way it is on a server. A stranger on the same network gets nothing from
it, because the bridge accepts media only for a session the web interface issued credentials
for, and that interface is on 127.0.0.1. On a network they do not trust, `docker compose down`
closes it.

```bash
grep -n '127.0.0.1' ~/selfhost/jitsi/compose.yml
```

Assert: one line, `- "127.0.0.1:8114:8000"`. Prosody publishes no host port at all.

## 7. Start and verify

```bash
cd ~/selfhost/jitsi
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8114/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8114/ | grep -o '<title>Jitsi Meet</title>'
curl -sS http://localhost:8114/config.js | grep -E "config.hosts.(auth|anonymous)domain"
```

Assert all three and print what you received. The loop ends on `200`. The second prints
`<title>Jitsi Meet</title>`. The third prints
`config.hosts.anonymousdomain = 'guest.meet.jitsi';` and
`config.hosts.authdomain = 'meet.jitsi';`, two lines that appear only when authenticated room
creation is on. If any miss, stop and run `docker compose logs --tail 40 web` and
`docker compose logs --tail 40 prosody`. `port is already allocated` means something else holds
8114 or 10000; find it with `lsof -nP -iTCP:8114 -sTCP:LISTEN`. A running container is not
success.

Now register the account that can open meetings, and prove the room-creating domain takes
nobody else:

```bash
cd ~/selfhost/jitsi
docker compose exec -T prosody sh -c 'prosodyctl --config /run/prosody/config/prosody.cfg.lua register moderator meet.jitsi "$MEET_HOST_PASSWORD"'
docker compose exec -T prosody find /var/lib/prosody -name 'moderator.dat'
docker compose exec -T prosody awk '/^VirtualHost "meet.jitsi"$/,/^VirtualHost /' /run/prosody/config/conf.d/jitsi-meet.cfg.lua | grep authentication
```

Assert both. The `find` prints one path ending in `moderator.dat`. The `awk` prints
`authentication = "internal_hashed"`, the security assert here: that domain takes registered
accounts only, so nothing opens a room without the password, which came from the container's
own environment and is in neither the process list nor the history. If the `find` prints
nothing, Prosody was likely still starting: wait 30 seconds and rerun the register line. On
any other miss, stop.

The first screen at http://localhost:8114 is the Jitsi welcome page, with a room-name box and a
`Start meeting` button.

STOP: tell the user to open http://localhost:8114, type a room name, start the meeting, and
sign in as `moderator` with the password from `grep MEET_HOST_PASSWORD ~/selfhost/jitsi/.env`.
Then have them open the same room in a second window and confirm both windows see each other.
Wait for both. Do not continue until they confirm. Only a browser proves a camera and a
microphone reach the bridge.

## 8. First backup and restore

Two archives: the account store, which lives in a Docker volume, and the two files that rebuild
the service.

```bash
cd ~/selfhost/jitsi
docker compose exec -T prosody tar -cz -C /var/lib/prosody . > ~/selfhost/jitsi/backups/jitsi-accounts-$(date +%F).tar.gz
tar -C ~/selfhost/jitsi -czf ~/selfhost/jitsi/backups/jitsi-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/jitsi/backups/
```

Assert: both files exist and both are non-empty. Print both sizes.

Both sit on the same disk as the data, which is not a backup, and on a laptop the disk and the
machine fail together. Ask the user for a destination that leaves this computer, a sync folder
or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both filenames are listed there.

To restore, in this order. `cd ~/selfhost/jitsi`, untar the config archive there first so
compose.yml and .env are back before any container starts. Then `docker compose down -v`, the
one place `-v` belongs because it drops the old account volume on purpose,
`docker compose up -d prosody`, wait about 30 seconds,
`docker compose exec -T prosody tar -xz -C /var/lib/prosody < backups/jitsi-accounts-<date>.tar.gz`,
then `docker compose up -d`. Sign in once as `moderator` to prove it took.

## 9. Updating later

New versions are listed at https://github.com/jitsi/docker-jitsi-meet/releases. Take both
backups first, then edit all four image lines in ~/selfhost/jitsi/compose.yml to the new tag and
digest. The four ship together; upstream does not support mixing them.

```bash
cd ~/selfhost/jitsi
docker compose pull
docker compose up -d
docker compose logs --tail 30 jicofo
```

Watch that log until it settles, then re-run the three checks from step 7.

## 10. What will probably go wrong

I rebooted, opened http://localhost:8114 out of habit, and got a connection refused that reads
like a broken install. It was not: Docker Desktop had not started with the session, so nothing
was listening on 8114. `restart: unless-stopped` only acts once the Docker daemon is up. Turn on
its start-at-login setting, and after a reboot run `cd ~/selfhost/jitsi && docker compose up -d`
first.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8114 to 0.0.0.0 or point `JVB_ADVERTISE_IPS` at this machine's wifi address so
  a phone can join. That puts a meeting server on every network the user walks into.
- Do not install Jibri or Jigasi. Recording is a headless browser in a container of its own,
  and dial-in needs a SIP provider and a phone number.
compose.local.ymlthe services, pinned · local layout109 lines

authored from upstream docs, never pasted · 3,941 bytes

# Jitsi Meet · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting guide . https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   variable reference . https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   upstream compose ... https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Four services and no database, on the computer you are sitting at. Paths are
# relative to ~/selfhost/jitsi/, so one file works on macOS, Linux and Windows.
# The Prosody account store is a named volume rather than a bind mount: that
# image runs as uid 1000 and refuses to start when its data directory is not
# writable by it, which Docker Desktop on Windows cannot arrange for a folder
# under the home directory.
#
# Tags and digests read from ghcr.io on 2026-08-06; all four publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: jitsi

services:
  prosody:
    image: ghcr.io/jitsi/prosody:stable-11146-1@sha256:0e3d9ada40c03e6eef151348e0872dce7b4b1c16c173ff4a67afeae60aba2404
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/prosody:/config
      - jitsi-prosody:/var/lib/prosody
    environment:
      # Rooms open only for an account registered in step 7; guests wait.
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      ENABLE_GUESTS: "1"
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Read by step 7's register command inside this container.
      MEET_HOST_PASSWORD: ${MEET_HOST_PASSWORD}
      PUBLIC_URL: http://localhost:8114
    # No `ports:`: 5222 and 5280 are reachable only from the other containers.

  jicofo:
    image: ghcr.io/jitsi/jicofo:stable-11146-1@sha256:a5da296923010dcc2daf6a02e6a183181906cb969a088ae90b97516bdeb9737f
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/jicofo:/config
    environment:
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      # Upstream's default heap ceiling is 3072m, more than a laptop wants.
      JICOFO_MAX_MEMORY: 512m
      XMPP_SERVER: prosody
    depends_on:
      - prosody

  jvb:
    image: ghcr.io/jitsi/jvb:stable-11146-1@sha256:6a7cec66c6a2fdd8ffd3a90101a0f8e3297aff29494f258caf1bcfbd418a17f3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/jvb:/config
    environment:
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # The only address a browser on this computer can reach the bridge on.
      JVB_ADVERTISE_IPS: 127.0.0.1
      VIDEOBRIDGE_MAX_MEMORY: 1024m
      XMPP_SERVER: prosody
    ports:
      # Media, not web: browsers send RTP straight here.
      - "10000:10000/udp"
    depends_on:
      - prosody

  web:
    image: ghcr.io/jitsi/web:stable-11146-1@sha256:ff81559621732d3dfc4815f261d41fd826566833016ea772f4d43a77aa88fe9a
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - ./config/web:/config
    environment:
      PUBLIC_URL: http://localhost:8114
      DISABLE_HTTPS: "1"
      ENABLE_AUTH: "1"
      ENABLE_GUESTS: "1"
      # No certificate here, so signalling rides a relative BOSH path.
      BOSH_RELATIVE: "1"
      ENABLE_XMPP_WEBSOCKET: "0"
      XMPP_BOSH_URL_BASE: http://prosody:5280
    ports:
      # Loopback only: no other device on the wifi can reach 8114.
      - "127.0.0.1:8114:8000"
    depends_on:
      - jvb

volumes:
  jitsi-prosody:

agent-readable mirror: /self-host/zoom.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned103 lines

authored from upstream docs, never pasted · 3,836 bytes

# Jitsi Meet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting guide . https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   variable reference . https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   upstream compose ... https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Four services and no database. Prosody carries the signalling, Jicofo decides
# who is in which conference, the videobridge forwards media, the web container
# is nginx plus the browser app. Meetings are never stored, so the only state
# that outlives a restart is the Prosody account file under /srv/jitsi/prosody,
# which upstream requires to be writable by uid 1000.
#
# Tags and digests read from ghcr.io on 2026-08-06; all four publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: jitsi

services:
  prosody:
    image: ghcr.io/jitsi/prosody:stable-11146-1@sha256:0e3d9ada40c03e6eef151348e0872dce7b4b1c16c173ff4a67afeae60aba2404
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/prosody:/config
      - /srv/jitsi/prosody:/var/lib/prosody
    environment:
      # Rooms open only for an account registered in step 7; guests wait.
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      ENABLE_GUESTS: "1"
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Read by step 7's register command inside this container.
      MEET_HOST_PASSWORD: ${MEET_HOST_PASSWORD}
      PUBLIC_URL: ${PUBLIC_URL}
    # No `ports:`: 5222 and 5280 are reachable only from the other containers.

  jicofo:
    image: ghcr.io/jitsi/jicofo:stable-11146-1@sha256:a5da296923010dcc2daf6a02e6a183181906cb969a088ae90b97516bdeb9737f
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jicofo:/config
    environment:
      ENABLE_AUTH: "1"
      AUTH_TYPE: internal
      JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD}
      # Upstream's default heap ceiling is 3072m, larger than the whole box.
      JICOFO_MAX_MEMORY: 512m
      XMPP_SERVER: prosody
    depends_on:
      - prosody

  jvb:
    image: ghcr.io/jitsi/jvb:stable-11146-1@sha256:6a7cec66c6a2fdd8ffd3a90101a0f8e3297aff29494f258caf1bcfbd418a17f3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/jvb:/config
    environment:
      JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD}
      # Behind Docker's NAT. Step 3 writes the address to advertise.
      JVB_ADVERTISE_IPS: ${JVB_ADVERTISE_IPS}
      VIDEOBRIDGE_MAX_MEMORY: 1024m
      XMPP_SERVER: prosody
    ports:
      # Media, not web: browsers send RTP straight here.
      - "10000:10000/udp"
    depends_on:
      - prosody

  web:
    image: ghcr.io/jitsi/web:stable-11146-1@sha256:ff81559621732d3dfc4815f261d41fd826566833016ea772f4d43a77aa88fe9a
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /run:size=16M,mode=1750,exec
      - /tmp:size=16M,mode=1777,noexec
    volumes:
      - /srv/jitsi/config/web:/config
    environment:
      PUBLIC_URL: ${PUBLIC_URL}
      # Caddy holds the certificate, so nginx here serves plain http on 8000.
      DISABLE_HTTPS: "1"
      ENABLE_AUTH: "1"
      ENABLE_GUESTS: "1"
      XMPP_BOSH_URL_BASE: http://prosody:5280
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8114.
      - "127.0.0.1:8114:8000"
    depends_on:
      - jvb
Caddyfilethe hostname and TLS25 lines

authored from upstream docs, never pasted · 850 bytes

# Jitsi Meet · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also PUBLIC_URL in .env, and the browser app builds its signalling URL from
# it, so the two have to agree.

<DOMAIN> {
	# The signalling WebSocket is upgraded by reverse_proxy on its own.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8114 is the loopback port compose publishes. No media comes through here.
	reverse_proxy 127.0.0.1:8114
}
install.shthe same install, no agent163 lines

authored from upstream docs, never pasted · 7,616 bytes

#!/usr/bin/env bash
# Jitsi Meet · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=meet.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/
#   https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/env.example
#   https://github.com/jitsi/docker-jitsi-meet/blob/stable-11146-1/docker-compose.yml
#
# Three secrets are generated here, on this machine: the two XMPP service
# passwords and the password for the moderator account. All three go into
# /srv/jitsi/.env with mode 600 and none is ever printed.
#
# Two things about this install that are unlike the rest of the catalogue.
# Media does not pass through Caddy: it goes to 10000/udp, at the videobridge,
# so that port is opened and the address in the A record is written into
# JVB_ADVERTISE_IPS. And a fresh Jitsi with no authentication is an open
# conference server, so ENABLE_AUTH is on and only the account registered
# below can open a room.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/jitsi}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. meet.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; two JVMs plus Prosody and nginx want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# Upstream states the containers run as uid 1000 and check on startup that the
# directories they write to are writable by it, so those are owned by 1000
# rather than by the login user.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/prosody"
sudo install -d -m 755 -o 1000 -g 1000 "$APP_DIR/config" "$APP_DIR/config/web" \
	"$APP_DIR/config/prosody" "$APP_DIR/config/jicofo" "$APP_DIR/config/jvb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Read the moderator password later with
#   grep MEET_HOST_PASSWORD /srv/jitsi/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PUBLIC_URL=https://${DOMAIN_HOST}
		JICOFO_AUTH_PASSWORD=$(openssl rand -hex 32)
		JVB_AUTH_PASSWORD=$(openssl rand -hex 32)
		MEET_HOST_PASSWORD=$(openssl rand -base64 24)
		JVB_ADVERTISE_IPS=${resolved}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-jitsi"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: four open, and 8114 is not one of them ------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3, 10000/udp for media; 8114 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw allow 10000/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the site answered ${code:-nothing}. Check: docker compose logs --tail 40 web"

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>Jitsi Meet</title>' \
	|| die "the page came back without the Jitsi Meet title. Check: docker compose logs --tail 40 web"

# Authenticated room creation must have reached the browser config. These two
# lines are generated only when ENABLE_AUTH and ENABLE_GUESTS are both on.
curl -sS "https://${DOMAIN_HOST}/config.js" | grep -q 'config.hosts.authdomain' \
	|| die "config.js has no authdomain, so room creation is open. Stop and fix step 4."

ss -lun | grep -q ':10000' || die "nothing is listening on 10000/udp, so no call will carry media"

# --- 7. The moderator account, and the assert that nobody else can open a room

docker compose exec -T prosody sh -c \
	'prosodyctl --config /run/prosody/config/prosody.cfg.lua register moderator meet.jitsi "$MEET_HOST_PASSWORD"' >/dev/null
docker compose exec -T prosody find /var/lib/prosody -name 'moderator.dat' | grep -q 'moderator.dat' \
	|| die "the moderator account was not created. Check: docker compose logs --tail 40 prosody"
docker compose exec -T prosody awk '/^VirtualHost "meet.jitsi"$/,/^VirtualHost /' \
	/run/prosody/config/conf.d/jitsi-meet.cfg.lua | grep -q 'authentication = "internal_hashed"' \
	|| die "the meeting domain is not on internal authentication. Anyone could open a room. Stop."

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/jitsi-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env prosody -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/jitsi-config-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Jitsi Meet is answering at https://${DOMAIN_HOST}/

	  1. Only the account "moderator" can open a meeting. Its password is in
	     $APP_DIR/.env, mode 600. Read it with
	       grep MEET_HOST_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. Open https://${DOMAIN_HOST}/, type a room name, and sign in as
	     moderator when asked. Guests can join a room you have opened; they
	     cannot open one. Add more hosts with
	       docker compose exec -T prosody prosodyctl --config /run/prosody/config/prosody.cfg.lua register NAME meet.jitsi
	  3. Media does not travel through Caddy. It goes to 10000/udp, at the
	     videobridge, advertised on the address in JVB_ADVERTISE_IPS. If a call
	     connects with no sound, that line and that port are where to look, in
	     that order, before anything else.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Zoom.

  • Call quality is now your server's uplink. Every participant's audio and video is relayed by the videobridge on your box, so a four-person call is four streams in and up to twelve out, and a cheap VPS with a shared network interface is where that shows up first. This is the number Zoom is really selling.
  • Media rides UDP straight to the machine, on port 10000, not through the reverse proxy. That means one firewall rule that no other install on this site needs, and a class of failure where the page loads perfectly and the call is silent.
  • End-to-end encryption is not what most people assume. Calls are encrypted in transit, but the videobridge decrypts and forwards them, so your server sees the media. Jitsi ships an end-to-end mode for multi-party calls, and it is browser-dependent and turns off several features.
  • No cloud recording and no dial-in numbers. Recording means running Jibri, another container with a headless browser in it, and dial-in means buying a phone number from a SIP provider. Neither is in this install.
  • You own the moderator account. There is no database and no user directory here: the account file under /srv/jitsi/prosody is the whole authentication system, and the day it is gone nobody can open a meeting until you register one again.

Where this came from

“If guest access is enabled, unauthenticated users will need to wait until a user authenticates before they can join a room. If guest access is not enabled, every user will need to authenticate before they can join.”

  • Upstream lists 80/tcp, 443/tcp and 10000/udp as the ports that must be open, with 10000/udp carrying RTP media, which is why media never passes through the reverse proxy. source
  • JVB_ADVERTISE_IPS replaces the older DOCKER_HOST_ADDRESS and is the setting that tells the videobridge which address to advertise when it runs behind NAT, which every Docker install does. source
  • Only JICOFO_AUTH_PASSWORD and JVB_AUTH_PASSWORD are required for a web, Prosody, Jicofo and videobridge stack; upstream states the Jigasi and Jibri passwords may be skipped when those components are not used. source
  • The containers run as an unprivileged user with uid 1000 and fail fast when a host volume they write to is not writable by that uid. source
  • With ENABLE_AUTH and ENABLE_GUESTS both on, the generated browser config declares an authenticated domain and a separate anonymous guest domain, which is what makes the browser ask for a login before a room can be opened. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Zoom?

    Not Zoom itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Jitsi Meet. Video meetings on your own hostname, with no participant tier and no 40-minute cut-off. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Zoom?

    Jitsi Meet. Video meetings on your own hostname, with no participant tier and no 40-minute cut-off. The only one here that a guest can join by clicking a link in a browser, with no account, no download and no explaining. That is the property Zoom actually sells, and it is the one that decides whether a self-hosted meeting server survives contact with people who did not choose it. What you take on in exchange is the bandwidth: your VPS relays every stream, so call quality stops being someone else's problem and becomes a line on your hosting bill. Jitsi Meet is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Zoom?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Jitsi Meet itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Zoom Pro, $84.95/mo — $1,019.40 a year, 5 seats assumed.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Jitsi Meet install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Jitsi Meet on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Jitsi Meet on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: A meeting room at http://localhost:8114 is reachable from this computer and nowhere else, so the local path is a place to learn and test Jitsi rather than a room anyone else can join. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.