Skip to content

Reverse Proxy & Routing

La Suite Meet uses a two-layer nginx setup:

  1. nginx-proxy (outer) — terminates TLS, routes by hostname, issues Let's Encrypt certs
  2. frontend container nginx (inner) — routes by URL path between the backend API and the React SPA

Architecture

Browser
nginx-proxy (TLS, port 443)   ← routes by virtual host
  │  visio.example.com → frontend:8083
frontend container nginx (port 8083)   ← routes by path
  ├── /api, /admin, /static, /oidc, /healthz, /webhook  →  backend:8000
  └── everything else                                    →  frontend:8080 (SPA files)

How it works

The lasuite/meet-frontend Docker image contains: - An nginx serving the built React SPA on port 8080 (default) - A second nginx server block on port 8083 that handles routing

You provide the routing config by mounting a pre-rendered nginx config into /etc/nginx/conf.d/routing.conf. The frontend nginx picks it up on startup.

Note: The image supports a template mechanism (/etc/nginx/templates/) but it requires a writable /etc/nginx/conf.d/ directory at startup. On systems with Docker user namespace remapping, this fails with a permission error. Mounting a pre-rendered file directly bypasses this issue.

The routing nginx config

Create nginx-routing.conf in your meet directory:

upstream meet_backend {
    server backend:8000 fail_timeout=0;
}

upstream meet_frontend {
    server frontend:8080 fail_timeout=0;
}

server {
    listen 8083;
    server_name localhost;
    charset utf-8;

    server_tokens off;
    client_max_body_size 100M;

    location @proxy_to_meet_backend {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;  # must be hardcoded — Django uses this to skip SSL redirect

        proxy_redirect off;
        proxy_read_timeout 300s;
        proxy_pass http://meet_backend;
    }

    location @proxy_to_meet_frontend {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_redirect off;
        proxy_pass http://meet_frontend;
    }

    location / {
        try_files $uri @proxy_to_meet_frontend;
    }

    location /api {
        try_files $uri @proxy_to_meet_backend;
    }

    location /admin {
        try_files $uri @proxy_to_meet_backend;
    }

    location /static {
        try_files $uri @proxy_to_meet_backend;
    }

    location /oidc {
        try_files $uri @proxy_to_meet_backend;
    }

    location /healthz {
        try_files $uri @proxy_to_meet_backend;
    }

    location /webhook {
        try_files $uri @proxy_to_meet_backend;
    }
}

Compose configuration

The frontend service needs to be on the proxy network (for nginx-proxy) and the internal network (to reach backend:8000):

frontend:
  image: lasuite/meet-frontend:latest
  entrypoint:
    - /docker-entrypoint.sh
  command: ["nginx", "-g", "daemon off;"]
  environment:
    - VIRTUAL_HOST=visio.example.com
    - VIRTUAL_PORT=8083
    - LETSENCRYPT_HOST=visio.example.com
    - LETSENCRYPT_EMAIL=you@example.com
  env_file: .env
  volumes:
    - ./nginx-routing.conf:/etc/nginx/conf.d/routing.conf:ro
  depends_on:
    - backend
  networks:
    - proxy
    - internal

The backend service does not need to be on the proxy network — it is only accessed internally by the frontend nginx:

backend:
  image: lasuite/meet-backend:latest
  env_file: .env
  depends_on:
    - postgresql
    - redis
  networks:
    - internal   # internal only — reached by frontend via Docker network

Keycloak also needs a virtual host

Keycloak must be accessible by the browser for the OIDC login redirect, so it gets its own subdomain via nginx-proxy:

keycloak:
  environment:
    VIRTUAL_HOST: auth.example.com
    VIRTUAL_PORT: "8080"
    LETSENCRYPT_HOST: auth.example.com
  networks:
    - proxy
    - internal

The backend needs extra_hosts to resolve Keycloak and LiveKit by their public hostnames (for token exchange and API calls):

backend:
  extra_hosts:
    - "auth.example.com:host-gateway"
    - "livekit.example.com:host-gateway"

LiveKit also needs a virtual host

LiveKit's WebSocket must be served over WSS (TLS). Give it its own subdomain via nginx-proxy:

livekit:
  environment:
    - VIRTUAL_HOST=livekit.example.com
    - VIRTUAL_PORT=7880
    - LETSENCRYPT_HOST=livekit.example.com
  ports:
    - "7881:7881"
    - "7882:7882/udp"
  networks:
    - proxy
    - internal

Port 7882/UDP must remain directly exposed — nginx cannot proxy UDP.

Three DNS records required

visio.example.com    →  <server-IP>   (Meet)
auth.example.com     →  <server-IP>   (Keycloak)
livekit.example.com  →  <server-IP>   (LiveKit WebSocket)

Troubleshooting

502 on all routes: The frontend nginx is not listening on port 8083. Check docker logs <frontend-container> for errors. If you see /etc/nginx/conf.d is not writable, ensure you are mounting nginx-routing.conf directly to /etc/nginx/conf.d/routing.conf (not using the template path).

API returns 301 in a loop: X-Forwarded-Proto https is missing or wrong in nginx-routing.conf. It must be hardcoded to https, not $scheme.

Login redirects to Keycloak but fails: Check the extra_hosts entries on the backend container — it needs to resolve auth.example.com and livekit.example.com via host-gateway.