# Can I self-host Zoom?

**YES, BUT** — it's called Jitsi Meet. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $84.95/mo you stop paying ($1,019.40/yr on the Pro plan, 5 seats assumed).

Jitsi Meet authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/zoom/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

The page this mirrors: https://caniselfhostit.com/self-host/zoom/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
