Skip to content

Reverse Proxy: nginx-proxy

This guide covers the nginx-proxy + acme-companion setup. If you prefer Traefik, see Reverse Proxy: Traefik instead.

LaSuite Meet uses a two-layer routing setup when using nginx-proxy:

  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

The inner routing nginx (nginx-routing.conf) is configured in Deployment Guide, Step 5 and applies regardless of which outer proxy you use. This page covers the outer nginx-proxy layer only.

Architecture

flowchart LR
    B(["🌐 Browser"])

    subgraph proxy_net["proxy network"]
        NP["nginx-proxy\n:443 HTTPS"]
    end

    subgraph internal_net["internal network"]
        FN["frontend nginx\n:8083"]
        LK["LiveKit\n:7880"]
        KC["Keycloak\n:8080"]
        BE["Backend\n:8000"]
        MI["MinIO\n:9000"]
        SPA["React SPA\n:8080"]
    end

    B -->|HTTPS| NP
    NP -->|meet.example.com| FN
    NP -->|livekit.example.com| LK
    NP -->|auth.example.com| KC
    FN -->|"/api /admin /oidc …"| BE
    FN -->|"/media/"| MI
    FN -->|"everything else"| SPA

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=meet.example.com
    - VIRTUAL_PORT=8083
    - LETSENCRYPT_HOST=meet.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:
      - proxy
      - internal   # must be on proxy to reach Keycloak and LiveKit by public hostname

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

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

meet.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. Verify nginx-routing.conf is mounted to /etc/nginx/conf.d/routing.conf:ro.

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

Login redirects to Keycloak but fails: Verify the backend container is on the proxy network so it can resolve auth.example.com and livekit.example.com.