Skip to content
All articles
8 min read

CORS Error "No Access-Control-Allow-Origin": The Fix

MD Rakibul Islam RakibMD Rakibul Islam RakibFull-stack developer, DevOps & Linux engineer
CORS Error "No Access-Control-Allow-Origin": The Fix

A CORS error means your API didn't return an Access-Control-Allow-Origin header that matches your frontend. Fix it on the server, not in the browser.

Every web developer meets this red console line sooner or later: Access to fetch at 'https://api.example.com/...' from origin 'https://www.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. It looks like a frontend bug. It isn't. The browser is enforcing a rule, and only the server you're calling can change the answer. I run this exact split on my own site: a Next.js frontend on www.rakibulinux.com talking to a NestJS API on api.rakibulinux.com, so this guide is the checklist I use when the two stop talking.

Key takeaways

  • CORS is enforced by the browser, configured on the server. Changing your fetch() call or installing a browser extension doesn't fix it for your users.
  • The origin must match exactly: scheme, host and port. https://example.com and https://www.example.com are different origins.
  • Cookies or auth with credentials can't use *. You must echo the exact origin and send Access-Control-Allow-Credentials: true.
  • Most "CORS errors" on POST/PATCH are failed preflights: the OPTIONS request gets a 404, a redirect or a 401 before your route ever runs.
  • A 500 or 502 can look like CORS. Error pages from your app or Nginx often lack the CORS headers, so the browser reports CORS instead of the real failure.

Why does the browser block the request?

Browsers follow the same-origin policy: JavaScript on one origin can't read responses from another origin unless that server says it's allowed. CORS (Cross-Origin Resource Sharing) is how the server says so, through response headers. The request often reaches your server and runs; the browser just refuses to hand the response to your code. That's why the same URL works in curl, Postman or a server-side fetch and fails in the browser. Those tools don't enforce CORS.

For "simple" requests (GET, HEAD or POST with a plain content type and no custom headers) the browser sends the request and checks the response headers. For anything else, such as Content-Type: application/json, an Authorization header, or PUT, PATCH and DELETE, it first sends a preflight OPTIONS request and only sends the real request if the preflight answer allows it. The MDN CORS guide lists the exact rules.

Step 1: Read the exact error in DevTools

Open DevTools, go to the Network tab, and look for two rows for the failing call: an OPTIONS (the preflight) and the real request. The console message tells you which rule failed:

  • "No 'Access-Control-Allow-Origin' header is present": the server didn't send the header at all, or the response came from an error page or proxy that doesn't add it.
  • "The 'Access-Control-Allow-Origin' header has a value ... that is not equal to the supplied origin": the server allows a different origin, often the apex vs www, or http vs https.
  • "Response to preflight request doesn't pass access control check: It does not have HTTP ok status": the OPTIONS request got a 401, 404 or 500.
  • "Redirect is not allowed for a preflight request": the API URL redirects (http to https, adding a trailing slash, apex to www). Call the final URL directly.
  • "...must not be the wildcard '*' when the request's credentials mode is 'include'": you send cookies but the server answers *.

Step 2: Reproduce it with curl

Replay the preflight from your terminal, sending the same Origin your site uses, and look only at the headers:

curl -si -X OPTIONS https://api.example.com/api/v1/orders \
  -H 'Origin: https://www.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization' \
  | grep -i -E '^HTTP|^access-control|^location'

A working answer is a 200 or 204 with access-control-allow-origin: https://www.example.com and the methods and headers you asked for. A 301, 404 or 401 here is your bug, and you now know which layer to fix.

browserwww.example APIapi.example 1. OPTIONS (preflight) 2. Allow-Origin: www.example 3. POST /orders No matching header in step 2 = the browser never sends step 3.
For JSON or authenticated requests the browser asks first with an OPTIONS preflight. Only if the API's answer names your site's origin does the real request go out. Most CORS bugs live in that first round trip.

Step 3: Allow the right origin on the server

NestJS

NestJS has CORS built in. NestFactory.create(AppModule, { cors: true }) allows any origin, which is fine for a public, read-only API that uses bearer tokens instead of cookies. For anything with cookies or private data, list your origins:

app.enableCors({
  origin: ['https://www.example.com', 'http://localhost:3000'],
  credentials: true,                  // only if you send cookies
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
  maxAge: 86400,                      // cache the preflight for a day
});

On the Fastify adapter, the NestJS docs note that only GET, HEAD and POST are allowed by default, so a PATCH or DELETE fails the preflight until you list it in methods.

Express

import cors from 'cors';
app.use(cors({ origin: ['https://www.example.com'], credentials: true }));

Register it before your routes and before any auth middleware, so the OPTIONS request is answered without needing a token.

Nginx in front of the API

Prefer one layer. If both Nginx and the app add Access-Control-Allow-Origin, the browser sees two values and rejects it. If you must do it in Nginx, add always so the headers are also sent on 4xx and 5xx responses:

location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin "https://www.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 86400 always;
        return 204;
    }
    add_header Access-Control-Allow-Origin "https://www.example.com" always;
    add_header Vary Origin always;
    proxy_pass http://127.0.0.1:5000;
}

Then sudo nginx -t && sudo systemctl reload nginx and run the curl test again.

Step 4: Fix credentials and cookies

If your frontend uses fetch(url, { credentials: 'include' }) or axios withCredentials: true, three things must all be true: the server sends the exact origin (not *), it sends Access-Control-Allow-Credentials: true, and the cookie itself is set with SameSite=None; Secure if the API is on a different site. If you allow several origins dynamically, also send Vary: Origin so a CDN doesn't cache one origin's answer for another.

Step 5: Or skip CORS with a same-origin proxy

With Next.js you can avoid cross-origin calls from the browser entirely. Fetch from server components or route handlers (server-to-server requests have no CORS), or add a rewrite so the browser calls /api/... on your own domain and Next.js forwards it. Nginx can do the same with a location /api/ block on the main site. This is my usual fix when a third-party API doesn't support CORS at all. My Next.js 16 on a VPS guide shows the Nginx setup.

Common mistakes that keep the error coming back

  • Origin typos: a trailing slash (https://www.example.com/) never matches; origins have no path.
  • Forgetting localhost: development runs on http://localhost:3000, a separate origin you must allow (ideally only outside production).
  • The real error is a crash. If the API returns a 502 because the app is down, the Nginx error page has no CORS headers. Fix the crash first; see my Nginx 502 guide.
  • Turning CORS off in the browser with flags or extensions "works" for you only, and hides the real fix.
  • Reflecting any origin with credentials. Echoing back whatever Origin arrives while allowing cookies lets any website make logged-in requests as your users. Use an allowlist.

Frequently asked questions

Can I fix a CORS error from the frontend?

No. The browser enforces CORS based on headers the server sends, so the fix must be on the API or a proxy you control. From the frontend you can only avoid the cross-origin call, for example by routing it through your own server.

Why does my API work in Postman but not in the browser?

Postman, curl and server-side code don't enforce the same-origin policy, only browsers do. The request works everywhere; the browser just refuses to give your JavaScript the response without the right CORS headers.

Is Access-Control-Allow-Origin: * safe?

It's fine for public data that doesn't depend on cookies, such as a public read-only API or fonts. Browsers reject a wildcard answer to a request sent with credentials anyway. For anything tied to a logged-in user, list your exact origins instead.

Why do I get a CORS error only on POST, not GET?

A JSON POST triggers a preflight OPTIONS request and a simple GET doesn't. Your server or proxy is probably rejecting the OPTIONS request with a 404, 401 or redirect. Test it with the curl command above.

Why did CORS break after I moved to a new domain?

The origin changed, so your allowlist no longer matches. Add the new origin (with and without www if both serve the app, though a single canonical host is better) and make sure the API URL doesn't redirect.

Still blocked by CORS?

I build and fix Next.js, NestJS and Node.js apps, including the API, Nginx and auth setup that CORS problems usually hide in. See my web development services, or contact me with the console error and your API URL and I'll tell you what's wrong.

MD Rakibul Islam Rakib

Written by

MD Rakibul Islam Rakib

Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.

  • CORS error
  • No Access-Control-Allow-Origin header
  • CORS preflight
  • NestJS CORS
  • Express CORS
  • Nginx CORS
  • Next.js

Keep reading

Self-Host LiveKit on a VPS: Setup, TURN and Tokens
DevOpsOct 7, 2026

Self-Host LiveKit on a VPS: Setup, TURN and Tokens

To self-host LiveKit, point two domains at a VPS, run the livekit/generate Docker image, install what it creates and open the WebRTC UDP port range. LiveKit is the media server I reach for when a client wants video, audio or voice AI inside their own product rather than a ready-made meeting app. It is open source (Apache 2.0), it scales well, and its SDKs cover web, iOS, Android, Flutter and React Native. LiveKit Cloud is the easy hosted option; self-hosting makes sense when you need data on your own servers, a fixed monthly bill, or a region the cloud doesn't serve. This is how I deploy it on a single VPS, plus how it compares with Jitsi, which I have run for clients for years. Key takeaways LiveKit is infrastructure, not an app. You build your own interface with its SDKs, or start from the open-source example meeting app. The generator does most of the work: livekit/generate writes the LiveKit config, Caddy for TLS, Redis and a Docker Compose file. You need two domains: one for LiveKit itself and one for its built-in TURN server, both pointing at the server. Open the UDP range. Media flows over 50000-60000/udp; without it, calls fall back to TCP or TURN and quality suffers. Your backend signs access tokens with the API key and secret. Never put the secret in a browser or mobile app. LiveKit or Jitsi? I get this question on almost every video project, so here is the short version: Pick Jitsi Meet when you want a complete meeting product today: rooms, chat, screen sharing, recording with Jibri and phone dial-in with Jigasi, all in a ready web app. See my Jitsi Meet with JWT and Jibri guide . Pick LiveKit when video is a feature inside your own product: telehealth, online classes in your LMS, live shopping, support calls, or voice AI agents that talk to users in real time. You design the interface; LiveKit moves the media. Both are SFUs: each participant uploads one stream and the server forwards it to the others. Both self-host on Linux with Docker. What you need A VPS with a public IP and no other web server on ports 80 and 443 (Caddy takes them). LiveKit's docs recommend compute-optimised instances, because capacity is limited by CPU and bandwidth, not memory. Two DNS A records: livekit.example.com and livekit-turn.example.com , both pointing to the server IP. Docker on your own machine to run the config generator. A hardened Ubuntu 24.04 server. My hardening checklist covers SSH and firewall basics. Step 1: Generate the configuration Run the generator on your laptop or on the server, in an empty folder: docker pull livekit/generate docker run --rm -it -v$PWD:/output livekit/generate It asks for your two domains and a few options, then writes a folder with: livekit.yaml : the LiveKit server config, including your generated API key and secret, caddy.yaml : Caddy, which gets Let's Encrypt certificates and terminates TLS, redis.conf and docker-compose.yaml , init_script.sh or a cloud_init file that installs everything on a fresh server. Save the API key and secret in your password manager. Your backend needs them to create tokens. Step 2: Install on the server If your provider supports cloud-init, paste the generated cloud-init file into the server's user data when you create it. Otherwise copy the init script to the server and run it: scp init_script.sh root@203.0.113.40: ssh root@203.0.113.40 'chmod +x init_script.sh && ./init_script.sh' The script installs Docker, puts the configuration in /opt/livekit and registers a systemd service called livekit-docker : systemctl status livekit-docker --no-pager cd /opt/livekit && docker compose logs -f livekit LiveKit runs with host networking, which its docs recommend for performance. Keep that in mind for the firewall: the ports are on the host directly. Step 3: Open the firewall sudo ufw allow 80/tcp # certificate issuance sudo ufw allow 443/tcp # HTTPS, WebSocket signalling, TURN/TLS sudo ufw allow 7881/tcp # WebRTC over TCP sudo ufw allow 3478/udp # TURN/UDP sudo ufw allow 50000:60000/udp # WebRTC media Check your cloud provider's firewall or security group too. A closed UDP range is the most common reason a self-hosted LiveKit "connects but shows no video". Each participant uploads one stream and the SFU forwards it to everyone else. Users on strict networks reach it through TURN over port 443, and nobody joins without a token signed by your backend. Step 4: Test it with the LiveKit CLI curl -sSL https://get.livekit.io/cli | bash export LIVEKIT_URL=wss://livekit.example.com export LIVEKIT_API_KEY=APIxxxxxxxx export LIVEKIT_API_SECRET=your-secret lk token create --join --room test-room --identity rakib lk load-test --room test-room --video-publishers 8 The token command prints a JWT you can paste into LiveKit's example web app to join from a browser. The load test adds simulated publishers so you can watch CPU and bandwidth on the server ( htop , nload ) before real users arrive. Raise the publisher count until the server struggles, then size your production server with headroom. Step 5: Issue tokens from your backend In your app, users log in to your backend, which returns a short-lived LiveKit token. With the official Node.js server SDK: import { AccessToken } from "livekit-server-sdk"; export async function livekitToken(userId: string, room: string) { const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, { identity: userId, ttl: "1h", }); at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true }); return at.toJwt(); } The grant decides what a user can do. For a webinar, give viewers canPublish: false . Check that the user is allowed in that room before you sign anything; the token is the only lock on the door. Recording, streaming and voice AI Egress records rooms or single tracks to MP4 and object storage, or streams them to RTMP. It is a separate service that needs Redis and plenty of CPU, so I run it on its own server. If you need S3-compatible storage on your own hardware, see my self-hosted S3 alternatives . Ingress brings RTMP or WHIP streams (for example from OBS) into a room. SIP connects phone calls to rooms. Agents: LiveKit's agents framework joins rooms as an AI participant that listens and talks back. Your self-hosted server works with it the same way as LiveKit Cloud. Scaling beyond one server One node handles a lot, but when you need more, add nodes that share the same Redis. LiveKit uses Redis to know which node hosts each room, so a room stays on one node and new rooms spread across the cluster. Put the nodes in the same region as your users; latency matters more than raw server size for real-time media. Troubleshooting Connects, but no audio or video: the UDP range or 7881/tcp is blocked, at the host or at the cloud firewall. Certificate errors: one of the two DNS records doesn't point to the server, or port 80 is closed, so Caddy can't get a certificate. Check docker compose logs caddy . "invalid token" / 401: the token was signed with a different key pair than the one in livekit.yaml , or it expired. Choppy video at scale: CPU or bandwidth is maxed. Use the load test numbers to size up, or add a second node. Frequently asked questions Is LiveKit free to self-host? Yes. The LiveKit server is open source under the Apache 2.0 licence. You pay for your servers and bandwidth; LiveKit Cloud is the paid managed option. Do I need a TURN server for LiveKit? LiveKit has a TURN server built in, and the generated config turns it on with its own domain. It lets users behind strict corporate firewalls connect over port 443. Can LiveKit replace Zoom or Google Meet? It can power your own Zoom-like product, but you build the interface. If you want a finished meeting app without development, self-hosted Jitsi Meet is the faster route. Does self-hosted LiveKit work with voice AI agents? Yes. The agents framework connects to any LiveKit server with a URL, API key and secret, so you can run AI voice assistants against your own deployment. How big a server do I need? It depends on how many people publish video and at what quality. Start with a compute-optimised VPS, run lk load-test with numbers close to your real rooms, and keep headroom. Want LiveKit running for your product? I deploy and maintain self-hosted LiveKit with TURN, Egress recording, token services in your backend, monitoring and scaling, and I build the web app around it. See my DevOps services or tell me what you are building .

Read article →
Install ERPNext 16 on Ubuntu 24.04 for Production
Web DevelopmentOct 7, 2026

Install ERPNext 16 on Ubuntu 24.04 for Production

Install ERPNext 16 on Ubuntu 24.04: add MariaDB 11.8, Python 3.14 (uv) and Node 24, run bench init on version-16, install erpnext, then bench setup production. ERPNext is the open-source ERP I install and customise most for small and mid-size companies, and I have built ERPNext backends for custom mobile apps, including a real-time RFID inventory system with a handheld UHF reader. The most common request I get is not a new feature; it is "our install broke during an update" or "the server is slow". Almost every time, the root cause is the install itself: wrong database settings, the wrong Python, or a development setup running in production. Version 16 (final release on 12 January 2026) raised the requirements again, so here is the clean production install I use now. Key takeaways v16 needs newer tools than Ubuntu 24.04 ships: Python 3.14, Node 24 and MariaDB 11.8. Install them from uv, nvm and the MariaDB repository. Run bench as a normal user (for example frappe ), never as root. Set MariaDB to utf8mb4 before you create the first site, or you will fight encoding errors later. bench start is for development. Production uses bench setup production with Nginx and Supervisor. Back up before every update and test updates on a copy of the site first. What do you need before installing? Server: Ubuntu 24.04 LTS, 4 GB RAM as the minimum. I give real companies 8 GB and 4 vCPU, because background jobs, reports and PDF printing all need memory. Disk: 40 GB or more on SSD. Attachments and backups grow fast. Domain: erp.example.com pointing to the server, for HTTPS. Hardening first: SSH keys, firewall with only 22, 80 and 443 open. My Ubuntu 24.04 hardening checklist covers it. The official requirements for version 16 are Python 3.14, Node.js 24, MariaDB 11.8, Redis 6 or newer, Yarn 1.22+ and wkhtmltopdf 0.12.6 with patched Qt. Check the Frappe installation docs before you start, because they change between major versions. Step 1: Create the frappe user and install packages sudo adduser frappe sudo usermod -aG sudo frappe sudo apt update sudo apt install -y git curl redis-server libmariadb-dev pkg-config \ xvfb libfontconfig nginx supervisor Step 2: Install MariaDB 11.8 and set utf8mb4 Ubuntu 24.04's own MariaDB is the 10.11 series, older than v16 wants, so add MariaDB's official repository for the 11.8 series: curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | \ sudo bash -s -- --mariadb-server-version="mariadb-11.8" sudo apt install -y mariadb-server mariadb-client sudo mariadb-secure-installation Then create /etc/mysql/mariadb.conf.d/99-frappe.cnf : [mysqld] character-set-client-handshake = FALSE character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci [mysql] default-character-set = utf8mb4 sudo systemctl restart mariadb Remember the MariaDB root password. Bench asks for it every time it creates a site. Step 3: Install wkhtmltopdf with patched Qt ERPNext prints invoices and reports to PDF with wkhtmltopdf, and the version in Ubuntu's repository is not the patched-Qt build, which breaks headers and footers. Download the 0.12.6.1 "with patched qt" package for your system from the wkhtmltopdf packaging releases and install it with sudo apt install ./wkhtmltox_*.deb . Confirm with wkhtmltopdf --version ; it should say "with patched qt". Step 4: Python 3.14, Node 24 and bench (as frappe) sudo su - frappe curl -LsSf https://astral.sh/uv/install.sh | sh source ~/.bashrc uv python install 3.14 --default curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash source ~/.bashrc nvm install 24 npm install -g yarn uv tool install frappe-bench bench --version uv keeps Python 3.14 separate from the system Python that Ubuntu's own tools depend on. Never replace /usr/bin/python3 . Step 5: Create the bench, the site and install ERPNext bench init --frappe-branch version-16 frappe-bench cd frappe-bench chmod -R o+rx /home/frappe bench new-site erp.example.com bench get-app --branch version-16 erpnext bench --site erp.example.com install-app erpnext bench --site erp.example.com enable-scheduler bench use erp.example.com Name the site after its domain. With DNS-based multitenancy ( bench config dns_multitenant on ) one bench can serve several companies, each on its own domain and database. Need HR and payroll too? Install the hrms app the same way with --branch version-16 . In production, Nginx serves files and proxies to Gunicorn, Supervisor keeps every Frappe process alive, Redis queues background jobs, and MariaDB holds the data. Step 6: Switch to production (Nginx + Supervisor) sudo env "PATH=$PATH" bench setup production frappe bench setup nginx sudo supervisorctl restart all sudo nginx -t && sudo systemctl reload nginx This writes Nginx and Supervisor configs that run Gunicorn, Socket.IO, the background workers and the scheduler as services that start at boot. If bench setup production complains about a missing tool, install it and rerun; on some setups it needs ansible . Check everything is running with sudo supervisorctl status . Step 7: HTTPS with Let's Encrypt sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d erp.example.com Rerunning bench setup nginx regenerates the Nginx config and can drop the certificate lines certbot added. If HTTPS breaks after a bench change, run certbot again. If renewal ever fails, my guide to fixing Certbot renewal errors walks through the causes. Step 8: Backups and safe updates bench --site erp.example.com backup --with-files ls sites/erp.example.com/private/backups/ Backups on the same server are not backups. Copy the database dump and file archives off-site every night (S3, another server, or rclone to cloud storage), and test a restore on a spare server every few months. My Linux backup guide shows the automation. Before bench update : Take a fresh backup with files. Restore it to a staging copy and run the update there first. Check custom apps, print formats and integrations on staging. Update production in a quiet hour. Common ERPNext install problems Site loads without CSS: assets were not built or Nginx can't read them. Run bench build and check that the home folder permissions allow Nginx to read sites/assets . Emails and reports never run: the scheduler is disabled, or workers are down. Check bench --site erp.example.com doctor and supervisorctl status . PDFs have no header or footer: wkhtmltopdf is not the patched-Qt build. 502 Bad Gateway after a reboot or update: Gunicorn didn't start. Read the Supervisor logs in frappe-bench/logs/ ; my Nginx 502 guide covers the general debugging path. Docker instead? Frappe also ships Docker images (the frappe_docker project and the easy-install.py script). Docker is a good choice when you run many sites or want identical staging and production. For one company on one server, I still prefer the bench install above: fewer layers, simpler debugging, and the community guides match what you see. Frequently asked questions Can ERPNext 16 run on Ubuntu 24.04? Yes. Ubuntu 24.04 works well, but install Python 3.14 with uv, Node 24 with nvm and MariaDB 11.8 from MariaDB's repository, because the Ubuntu packages are older than version 16 requires. How much RAM does ERPNext need? 4 GB is the minimum for a small setup. For a company with daily users, background jobs and PDF printing, I recommend 8 GB, and more if you run several sites on one bench. Is ERPNext free? Yes. ERPNext and the Frappe Framework are open source, so you can self-host without licence fees. You pay for the server, and for setup, customisation and support if you hire help. Should I upgrade from ERPNext 15 to 16? Yes, but on a staging copy first. Check that your custom apps support version 16, update the server tools (Python, Node, MariaDB), then migrate production after testing. Self-hosted ERPNext or Frappe Cloud? Frappe Cloud is managed hosting run by the ERPNext makers, with updates handled for you. Self-hosting gives you full control, custom server-side integrations and data on your own server. Want ERPNext installed, fixed or customised? I install ERPNext in production, upgrade old versions, fix broken benches and build custom apps and mobile integrations on top of it. See my web development services or tell me about your ERPNext setup .

Read article →
Self-Host Jitsi Meet with JWT Auth and Jibri Recording
Linux System AdminOct 7, 2026

Self-Host Jitsi Meet with JWT Auth and Jibri Recording

To self-host Jitsi Meet with JWT and recording, run docker-jitsi-meet on Ubuntu 24.04, set AUTH_TYPE=jwt in .env, open UDP 10000 and add jibri.yml for Jibri. Jitsi Meet is the video conferencing setup I get asked about most after SMS gateways. Schools want private classrooms, clinics want consultations that never touch a third-party cloud, and SaaS teams want video calls inside their own app where only logged-in users can join. My Jitsi install videos (JWT, Jibri, Jigasi and Etherpad on a Linux server) still bring in requests every month. This is the setup I use today: the official Docker images, JWT so your app decides who gets in, and recording that actually works. Key takeaways Use docker-jitsi-meet when you need JWT, Jibri, Etherpad or Jigasi. Each add-on is one extra compose file instead of a manual install. Open UDP 10000 and set JVB_ADVERTISE_IPS to the public IP. If calls work with two people and break with three, this is why. JWT means your app issues the tickets. No token, no meeting, and the token can make someone a moderator. One Jibri records one meeting at a time. Plan Jibri instances for the number of parallel recordings you need. Jigasi needs a SIP account from a VoIP provider for phone dial-in and dial-out. Docker or Debian packages? The Jitsi handbook supports both. The Debian packages ( apt install jitsi-meet , plus jitsi-meet-tokens for JWT) are fine for a plain meeting server on Ubuntu 24.04. Once you add JWT, recording, shared notes and phone dial-in, the Docker setup is easier to reproduce and upgrade: everything lives in one .env file, and each component is a separate container you can restart on its own. I use Docker for client projects for that reason. Server requirements Ubuntu 24.04 with Docker Engine and the Compose plugin. A domain like meet.example.com pointing to the server, for the Let's Encrypt certificate. CPU and bandwidth matter more than RAM for the video bridge. Jibri is heavier: every recording runs a full Chrome browser and ffmpeg, so put it on its own server once you record often. Firewall ports: 80/tcp and 443/tcp for the web and certificates, 10000/udp for media. Step 1: Download the release and generate passwords wget $(wget -q -O - https://api.github.com/repos/jitsi/docker-jitsi-meet/releases/latest | grep zip | cut -d\" -f4) unzip stable-* && cd jitsi-docker-jitsi-meet-* cp env.example .env ./gen-passwords.sh mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri} Use a release zip, not a git clone of the main branch. gen-passwords.sh writes strong internal passwords into .env ; the containers refuse to start without them. Step 2: Set the domain, ports and public IP # .env CONFIG=~/.jitsi-meet-cfg HTTP_PORT=80 HTTPS_PORT=443 TZ=Asia/Dhaka PUBLIC_URL=https://meet.example.com ENABLE_LETSENCRYPT=1 LETSENCRYPT_DOMAIN=meet.example.com LETSENCRYPT_EMAIL=admin@example.com JVB_ADVERTISE_IPS=203.0.113.25 JVB_ADVERTISE_IPS is the public IP the video bridge tells browsers to send media to. Get it wrong and the third participant never connects. Then start the base stack and test a meeting: docker compose up -d docker compose ps Step 3: Turn on JWT authentication # .env ENABLE_AUTH=1 AUTH_TYPE=jwt JWT_APP_ID=my_app JWT_APP_SECRET=a-long-random-secret-shared-with-your-backend Restart with docker compose up -d . From now on, a meeting URL only works with a valid token: https://meet.example.com/project-review?jwt=<token> . Your backend signs that token with the shared secret after the user logs in to your app. A minimal Node.js example with the jsonwebtoken package: import jwt from "jsonwebtoken"; const token = jwt.sign( { aud: "jitsi", iss: "my_app", // = JWT_APP_ID sub: "*", // domain or tenant, * for all room: "project-review", // or * for every room context: { user: { name: "Rakib", email: "rakib@example.com", moderator: true } }, }, process.env.JWT_APP_SECRET, { algorithm: "HS256", expiresIn: "2h" }, ); Keep tokens short-lived and per room, so a leaked link stops working. The moderator flag only takes effect when the token_affiliation Prosody module is enabled (for example XMPP_MUC_MODULES=token_affiliation ) and Jicofo's auto-owner is turned off; otherwise the first person to join becomes moderator. Test this with two browsers before you go live, because it is the part that differs most between Jitsi versions. Prosody lets in only users with a valid token from your app. Media goes straight to the video bridge over UDP 10000, and Jibri joins as a hidden participant to record. Step 4: Add recording with Jibri Jibri records by joining the meeting as a hidden participant in Chrome and capturing the screen and audio with ffmpeg. On the host it needs the ALSA loopback kernel module: sudo apt install -y linux-modules-extra-$(uname -r) sudo modprobe snd-aloop echo snd-aloop | sudo tee -a /etc/modules lsmod | grep snd_aloop Some cloud kernels ship without snd-aloop . If modprobe fails, switch the server to the generic kernel, or pick a provider whose image includes it, before you go further. Then enable recording and start the extra container: # .env ENABLE_RECORDING=1 docker compose -f docker-compose.yml -f jibri.yml up -d Recordings land in the Jibri storage folder under your CONFIG directory as MP4 files. Move them to object storage on a schedule, or the disk fills up; I cover cleanup in fixing "No space left on device" . One Jibri container handles one recording or live stream at a time, so add more Jibri instances (ideally on separate servers) for parallel recordings. Step 5: Shared notes with Etherpad # .env ETHERPAD_URL_BASE=http://etherpad.meet.jitsi:9001 docker compose -f docker-compose.yml -f etherpad.yml up -d A "Shared document" button appears in meetings, and everyone in the room edits the same pad. The pad URL is internal to the Docker network, so you don't open any new ports. Step 6: Phone dial-in and dial-out with Jigasi Jigasi is the SIP gateway that lets people join by phone, or lets the meeting call a phone number. You need a SIP account from a VoIP provider: # .env JIGASI_SIP_URI=meet@sip.provider.example JIGASI_SIP_PASSWORD=your-sip-password JIGASI_SIP_SERVER=sip.provider.example JIGASI_SIP_PORT=5060 docker compose -f docker-compose.yml -f jibri.yml -f etherpad.yml -f jigasi.yml up -d That last command is the full stack. Save it as a script, because you need the same file list for every pull , up and down . Step 7: Firewall and Docker sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 10000/udp Docker publishes its ports through iptables directly, so UFW rules alone don't protect containers. Read why Docker bypasses UFW before you assume an internal port is closed. Troubleshooting Two people fine, the third breaks everything: with two participants Jitsi uses a direct peer-to-peer call. From three it goes through the video bridge, so UDP 10000 is blocked or JVB_ADVERTISE_IPS is wrong. "You have been disconnected" right after joining with a token: the token is expired, signed with the wrong secret, or iss doesn't match JWT_APP_ID . Decode it at jwt.io and compare. Recording button missing: ENABLE_RECORDING=1 isn't set, or the Jibri container isn't running. Check docker compose logs jibri . Jibri starts then fails: usually snd-aloop isn't loaded, or the server is out of CPU. Frequently asked questions Is self-hosted Jitsi Meet free? Yes. Jitsi Meet is open source and free to self-host. You pay for the servers and bandwidth, plus a SIP account if you use phone dial-in. How many participants can one Jitsi server handle? It depends on CPU, bandwidth and how many people send video. Test with your real meeting sizes, then scale out by adding video bridges rather than buying one huge server. Can I embed Jitsi in my own app? Yes. The Jitsi Meet IFrame API embeds meetings in any website, and with JWT your backend decides who can join each room and who is moderator. Jitsi or LiveKit? Jitsi is a complete meeting app you can run today. LiveKit is a media server and SDKs for building your own video or voice AI product. I compare them in my LiveKit self-hosting guide . Can Jibri live stream to YouTube? Yes. Jibri can stream a meeting to an RTMP endpoint such as YouTube Live instead of recording to a file. Like recording, each stream uses one Jibri. Need a private Jitsi server for your team or app? I install and maintain Jitsi Meet with JWT login from your app, Jibri recording, Etherpad, Jigasi phone dial-in, HTTPS, backups and monitoring. See my Linux system admin services or tell me how many people join your meetings .

Read article →