# Building a Private Meeting Server with Galène

> A reliable, self-hosted video meeting server in a 10MB Go binary

By Zsolt Bizderi · Published 2026-10-31
Canonical: https://ambientnode.uk/building-a-private-meeting-server-with-gal-ne

If you make regular video calls across countries, ISPs, NAT, you've probably cycled through WhatsApp, Signal, Meet, Teams, Zoom, Jitsi, and found various issues with each. Meet kicks you out after an hour, WhatsApp and Signal throttle screen share, Jitsi's public instance can be a memory hog.

I recently came across [Galène](https://galene.org), a minimalist videoconference server written in Go, built for lightweight and easy deployments. Galène is encrypted in transit but not end-to-end, which is fine as long as you trust whoever controls the server. If you host this yourself on a VPS, server hardening is a must.

Out of the box you get group video and audio calls, screen sharing, and a text chat sidebar. Rooms support different permission levels (operators who can moderate and record, presenters who can share camera and mic, and passive participants who can only watch and listen). It handles everything from one-to-one calls up to lecture-style rooms with many viewers, records calls server-side when you need it, and runs entirely in the browser with no app or plugin to install.

## Prerequisites

* A VPS with a static public IPv4 and the ability to open UDP ports. I used Debian 13 for simplicity.
* A domain, mine's on Cloudflare.

## Part 1: Prep Debian and build Galène

Galène ships no prebuilt binaries; you compile it with Go. Install git and the basics:

```
sudo apt update && sudo apt install -y git ca-certificates curl
```

Get the official Go tarball:

```
cd /tmp
curl -LO https://go.dev/dl/go1.24.4.linux-amd64.tar.gz   # check go.dev/dl for the current version
sudo tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version
```

Clone and build. `CGO_ENABLED=0` forces a fully static binary with no dependency on the host's libc, so it'll run on anything and pack into a single \~10MB file:

```
cd /tmp
git clone https://github.com/jech/galene && cd galene
CGO_ENABLED=0 go build -ldflags='-s -w'   # produces ./galene
```

You now have a `galene` binary and a `static/` directory for the web client.

## Part 2: Install it under a service user

Create a locked-down system user for the app:

```
sudo useradd -r -d /var/lib/galene -s /usr/sbin/nologin galene
sudo mkdir -p /var/lib/galene/{data,groups}
sudo cp galene -t /var/lib/galene/
sudo cp -r static -t /var/lib/galene/
sudo chown -R galene:galene /var/lib/galene
```

`data/` will hold the TLS cert and global config and `groups/` holds room definitions.

### Reclaim the disk

You can remove the build tree now that the build has been transferred:

```
cd /root
sudo rm -rf /usr/local/go /tmp/galene /tmp/go1.24.4.linux-amd64.tar.gz
rm -rf ~/go ~/.cache/go-build
```

## Part 3: Cert via Cloudflare DNS-01

Galène serves HTTPS when it finds `cert.pem` and `key.pem` in its `data/` directory. We'll get those from Let's Encrypt. Because the domain's on Cloudflare, the DNS-01 challenge is nice and simple as it does not need an inbound port 80.

Install certbot and the Cloudflare plugin:

```
sudo apt install -y certbot python3-certbot-dns-cloudflare
```

Create a scoped API token in the Cloudflare dashboard (My Profile > API Tokens > Create Token > "Edit zone DNS" template, restricted to your specific zone, don't use a global key). Add the token to a credentials file and lock the permissions:

```
cd /root
cat > /root/cf.ini <<'EOF'
dns_cloudflare_api_token = PASTE_YOUR_SCOPED_TOKEN
EOF
chmod 600 /root/cf.ini
```

Issue the certificate:

```
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/cf.ini \
  -d galene.example.com
```

### Add the cert to Galène

Galène reads its cert from `data/`, but Let's Encrypt writes it under `/etc/letsencrypt/live/`. Create a deploy-hook that copies it across on every issuance and renewal:

```
sudo tee /usr/local/bin/galene-cert >/dev/null <<'EOF'
#!/bin/sh
D=/etc/letsencrypt/live/galene.example.com
install -m640 -o galene -g galene "$D/fullchain.pem" /var/lib/galene/data/cert.pem
install -m640 -o galene -g galene "$D/privkey.pem"  /var/lib/galene/data/key.pem
systemctl restart galene
EOF
sudo chmod +x /usr/local/bin/galene-cert
```

Run it once now to populate `data/`:

```
sudo /usr/local/bin/galene-cert
ls -l /var/lib/galene/data/   # expect: cert.pem  key.pem
```

systemctl will complain that there's no such unit. This is expected, we will create it in Part 5.

## Part 4: The room

In Galène, each file in `groups/` is a room, and the filename is the room name. The file defines who's allowed in and what they can do. There is no separate auth model, this is essentially your security layer.

Create `groups/private.json`:

```
sudo tee /var/lib/galene/groups/private.json >/dev/null <<'EOF'
{
  "op":        [{"username": "admin", "password": "a-real-strong-password"}],
  "presenter": [{"username": "guest",  "password": "another-strong-password"}]
}
EOF
sudo chown galene:galene /var/lib/galene/groups/private.json
```

The roles:

* **`op`**: operator who joins, uses cam/mic, and can moderate (lock the room, remove people, record).
* **`presenter`**: joins and uses cam/mic, no moderation.
* **`other`** (unused): watch and chat only, no camera.

There's no wildcard/anonymous user, so you have to create a role for everyone you want to join.

## Part 5: Run it as a systemd service

Now we bind it to port 443 as the service user:

```
sudo tee /etc/systemd/system/galene.service >/dev/null <<'EOF'
[Unit]
Description=Galene
After=network.target

[Service]
User=galene
Group=galene
WorkingDirectory=/var/lib/galene
ExecStart=/var/lib/galene/galene -http :443 -udp-range 49152-49252
AmbientCapabilities=CAP_NET_BIND_SERVICE
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now galene
sudo systemctl status galene --no-pager
```

The `-udp-range` flag restricts the WebRTC ports to a window so we can firewall them. Systemctl status should look like this:

```
Active: active (running)
Memory: 4.8M (peak: 5.2M)
galene[3619]: Starting built-in TURN server on :1194
```

Galène has its own, built-in TURN server so it does not need a separate relay to install or configure. On a public IP you mostly won't need it anyway as you communicate directly with the VPS, but it's there as a fallback if a client's network won't cooperate.

## Part 6: Firewall

Ensure the following ports are allowed

```
sudo apt install -y ufw
sudo ufw allow 22/tcp                 # or your real SSH port
sudo ufw allow 443/tcp                # signalling + web
sudo ufw allow 49152:49252/udp        # WebRTC media (defined above in the service)
sudo ufw allow 1194                   # TURN
sudo ufw enable
```

## Part 7: DNS + Cert Renewal

Point a subdomain straight at the VPS. You'd want this **DNS-only, not proxied, as G**alène does its own TLS and its own UDP media already.

```
galene.example.com.   A   YOUR_VPS_IP
```

And to make sure your cert auto-renews every 60 days:

```
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/cf.ini \
  -d meet.example.com --deploy-hook /usr/local/bin/galene-cert

sudo certbot renew --dry-run
```

## Part 8: Small modifications

### Land straight in a room

If you want to be redirected to a specific room automatically, you can replace the root page under `static/index.html`:

```
sudo tee /var/lib/galene/static/index.html >/dev/null <<'EOF'
<!doctype html>
<meta charset="utf-8">
<title>private meet</title>
<script>location.replace('/group/private/')</script>
<meta http-equiv="refresh" content="0; url=/group/private/">
EOF
sudo chown galene:galene /var/lib/galene/static/index.html
```

### Branding

The whole client is the `static/` directory, so customisation is just editing assets. You can swap the favicon/logo, and to rename the visible brand (you may want to commit static changes to a repo, as it will reset after app updates):

```
sudo sed -i 's/Galène/your_brand/g; s/Galene/your_brand/g' /var/lib/galene/static/galene.html
```

## Wrapping up

Galene is not the most powerful option on the videoconferencing landscape, but it's very simple and reliable. Thanks to [Juliusz Chroboczek (jech)](https://github.com/jech) and the Galène contributors for the project.
