Nojoin Deployment & Configuration Guide

This guide is for operators deploying and running Nojoin.

If you just want the fastest path to a working instance, start with GETTING_STARTED.md and return here when you need deeper hosting, networking, or upgrade guidance.

Core Requirements

Compose Files

The repository does not ship a separate Docker Compose development override.

Quick Deployment

  1. Clone the repository.
  2. Create your local deployment files:

     cp docker-compose.example.yml docker-compose.yml
     cp .env.example .env
    
  3. Set FIRST_RUN_PASSWORD in .env.
  4. Set DATA_ENCRYPTION_KEY in .env before first production use.
  5. Adjust WEB_APP_URL if the deployment is not local-only.
  6. Review docker-compose.yml and apply any private or machine-specific changes.
  7. Start the stack:

    docker compose up -d
    
  8. Open https://localhost:14443/setup and unlock the first-run wizard with your FIRST_RUN_PASSWORD.
  9. Use Chrome on Windows, Linux, or macOS for shared-audio live recording, another Chromium-family browser on Windows or Linux, or Chrome on Android/iOS for microphone-only live recording. Other Chromium-family browsers on macOS are best-effort. Other browsers can still review and administer Nojoin.

Nojoin refuses first initialisation if FIRST_RUN_PASSWORD is missing. If you add or change it, redeploy the stack before using the setup wizard. The sign-in page does not link to the setup wizard, and every anonymous setup denial returns the same generic response regardless of cause, so the service never discloses whether it has been initialised. The specific denial reason (wrong password, unset FIRST_RUN_PASSWORD, or already-initialised system) appears in the API logs, and the API startup log prints the /setup address while the system is uninitialised. Setup requests are rate limited per client address. If FIRST_RUN_PASSWORD, DATA_ENCRYPTION_KEY, REDIS_PASSWORD, or the tracked PostgreSQL password placeholder are left at their example values, Nojoin now emits startup log warnings and an authenticated frontend warning toast. Those warnings are advisory only; operators are still responsible for replacing the placeholder secrets in .env.

The compose template is already configured for GPU inference.

The compose files now health-gate the web stack so frontend waits for a healthy api, and nginx waits for healthy api plus frontend before it is considered ready.

When doing targeted starts from a fully stopped stack, remember that Docker Compose does not auto-start an omitted dependent service. If you want the proxy back as part of a partial startup, include nginx explicitly:

docker compose up -d api frontend nginx

DATA_ENCRYPTION_KEY is strongly recommended for every non-ephemeral deployment. Earlier releases relied on the auto-generated data/.data_encryption_key fallback alone, which meant encrypted calendar secrets and tokens could become unreadable if the app data directory was replaced while the database volume was preserved. Setting a stable DATA_ENCRYPTION_KEY avoids that class of failure.

If you are developing from local source instead of operating a deployment, read DEVELOPMENT.md.

GPU Support

Linux

  1. Install the proprietary NVIDIA drivers.
  2. Verify GPU visibility with:

    nvidia-smi
    
  3. Install the NVIDIA Container Toolkit.
  4. Configure Docker for NVIDIA runtime support:

    sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
    

The default .env.example enables NVIDIA_VISIBLE_DEVICES=all and NVIDIA_DRIVER_CAPABILITIES=compute,utility.

Windows

Worker Container Startup

The worker container starts Celery without preloading inference models. Nojoin keeps GPU memory idle at startup, then queues worker-side model preparation for the configured Whisper model, Pyannote diarisation, and voice embeddings. The worker validates those assets on CPU where possible, caches them on disk, and releases model objects and CUDA memory before returning to idle.

One worker lane (worker-io) runs the embedded Celery Beat scheduler (celery worker -B) that drives Nojoin’s periodic jobs: calendar sync every 15 minutes, calendar push-channel renewal every 30 minutes, and temporary-recording cleanup daily. Beat runs on that single lane only, so it cannot double-schedule, and the beat schedule state lives on the persistent data volume so the cadence survives restarts. Optional calendar live sync (push notifications) additionally requires the instance to be reachable from the public internet over HTTPS at WEB_APP_URL; see CALENDAR.md.

If an administrator switches transcription to Parakeet or Canary, Nojoin queues preparation for the selected ONNX ASR model after the setting is saved. Live and final processing still load inference models only for active work. After each worker task, Nojoin releases model caches and clears CUDA memory when keep_models_loaded is unset or false — except while a recording is actively uploading (live capture), where the live ASR model is kept resident so consecutive segments are not forced to reload it (a reload costs several seconds per segment). When capture goes idle the caches are released as normal, and a recording finalise clears cached models before loading its heavier diarisation stack so the two never exceed VRAM. Set keep_models_loaded=true only if you deliberately prefer warmer repeated processing over idle VRAM across the board.

Worker Concurrency Lanes

To stop a long recording finalise from blocking every other user’s live transcription, notes, chat, and calendar sync, worker tasks are split across three Celery queues, each drained by its own container:

Task-to-queue routing is defined in backend/celery_app.py (TASK_ROUTES); anything unrouted falls back to the GPU lane. Tune the --concurrency values in docker-compose.yml to your host: the CPU and IO lanes are cheap (no model memory), while the GPU lane should stay at --concurrency=1 unless you have multiple GPUs, or a single card large enough to hold two concurrent pipelines.

With three worker containers plus the API, several worker processes each keep a small database connection pool. The default PostgreSQL max_connections (100) comfortably covers the reference 3 / 4 lane sizing; if you scale the lanes much wider, raise max_connections to match.

GPU Acceleration

The worker image installs Triton in its virtual environment so Whisper word-level timestamps use GPU-accelerated kernels. Without Triton, whisper/timing.py falls back to slower CPU-based implementations for word alignment.

Text embedding (used during AI-generated meeting intelligence) uses the ONNX Runtime CUDA execution provider when available, with an automatic CPU fallback.

The Parakeet and Canary ASR engines also use ONNX Runtime CUDA. Some ONNX graph operations are inherently CPU-pinned; the resulting memcpy overhead is expected and does not indicate a configuration problem.

CPU-Only Deployment

If you do not have a compatible NVIDIA GPU:

  1. Open docker-compose.yml.
  2. Remove the deploy section under the worker-gpu service.
  3. Start the stack normally with docker compose up -d.

Processing will be slower, but the application remains usable. All three worker lanes then run on CPU.

Configure .env

Create .env from .env.example and treat it as the canonical operator configuration file. The compose stack derives internal service URLs for PostgreSQL, Redis, and Celery automatically, so those values are intentionally not part of .env.example. Keep any secrets, private mounts, or machine-specific overrides in your local docker-compose.yml, not in the tracked template. Nojoin auto-generates and persists its JWT signing keyring under data/.secret_keys.json in the default deployment, migrating any legacy data/.secret_key file on startup, so no .env setting is required for that. Nojoin can also auto-generate data/.data_encryption_key, but operators should treat that as a fallback rather than the primary persistence strategy.

Always Set

Change for Remote or Reverse-Proxy Deployments

Common Optional Values

DATA_ENCRYPTION_KEY Guidance

Custom Frontend Build Value

For calendar-specific registration detail, read CALENDAR.md.

Configuration Model

Nojoin splits configuration between:

The first-run setup wizard can pre-fill many values from environment variables to speed up deployment. On uninitialised systems, that prefill flow is itself locked behind FIRST_RUN_PASSWORD.

CLI OAuth (worker-io image)

The per-user CLI OAuth AI mode (routing inference through a user’s own Claude or ChatGPT subscription) needs Node.js plus the Claude Code CLI and the OpenAI Codex CLI, which ship only in the worker-io image (docker/Dockerfile.worker-io, layered on the shared worker image). Point the worker-io service at that image via the image:/build: override in docker-compose.example.yml; worker-gpu and worker-cpu stay on the base image. No new .env is required — the encrypted credential reuses DATA_ENCRYPTION_KEY. Note the Codex CLI adds a large (~336 MB) native binary to this image only; NOJOIN_CODEX_PATH overrides the codex binary path if needed (default /usr/local/bin/codex). See ADR-0002.

Remote Access and Trusted Public Origin

If you expose Nojoin beyond localhost:

For publicly reachable deployments, use a VPN or a secure reverse proxy rather than exposing the service casually. For internet-exposed deployments, treat FIRST_RUN_PASSWORD as a deployment secret and avoid logging request headers that could capture it during the setup flow.

Reverse Proxy Requirements

When fronting Nojoin with Nginx, Caddy, Traefik, or another reverse proxy:

Loopback Port Binding (DEP-001)

By default, the bundled Nginx proxy publishes ports 14141 and 14443 bound to the loopback interface (127.0.0.1) rather than all host interfaces (0.0.0.0). This ensures that if you place Nojoin behind an edge reverse proxy (such as Caddy, Traefik, or a tunnel) on the same host, the bundled proxy is not exposed directly to the public internet, preventing bypass of the edge proxy’s authentication, rate limiting, or filtering.

  1. Proxy to the HTTPS endpoint, not the plain HTTP port.
  2. By default that means the host-facing port 14443.
  3. Disable upstream certificate verification because Nojoin uses a self-signed internal certificate by default.
  4. Keep WEB_APP_URL aligned with the public origin.
  5. Preserve the public browser host when forwarding requests. The upstream Host and X-Forwarded-Host values should match the hostname in WEB_APP_URL.
  6. Forward X-Forwarded-Proto: https so Nojoin can recognise secure browser requests through the proxy chain.
  7. Keep the public HTTPS origin stable so browser capture, session cookies, invitation links, and OAuth callbacks all target the same Nojoin site.
  8. Forward the whole site through one origin, including /mcp and /.well-known/oauth-*. Those paths serve the built-in MCP connector and its OAuth discovery documents (see MCP.md); the bundled Nginx proxy already routes them to the API service, so an edge proxy that forwards everything to port 14443 needs no extra rules.

If API requests fail with 400 Invalid host header, the edge proxy is usually forwarding an internal upstream host such as nojoin-nginx:443 instead of the public WEB_APP_URL host.

Caddy Example

nojoin.yourdomain.com {
    reverse_proxy localhost:14443 {
        header_up Host nojoin.yourdomain.com
        header_up X-Forwarded-Host nojoin.yourdomain.com
        header_up X-Forwarded-Proto https

        transport http {
            tls_insecure_skip_verify
        }
    }
}

Nginx Example

location / {
    proxy_pass https://localhost:14443;
    proxy_ssl_verify off;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Trusted Proxy IPs for Rate Limiting (DEP-002)

Nojoin derives each client’s IP from the X-Forwarded-For chain by walking back through the proxies listed in NOJOIN_TRUSTED_PROXIES (see Change for Remote or Reverse-Proxy Deployments). Only the bundled Nginx proxy is trusted by default (127.0.0.1,::1,nginx).

When your edge proxy runs as a container on a different Docker network than nojoin-api, the API cannot resolve it by container name - Docker DNS only resolves names on networks the API container is itself attached to. Trust such a proxy by its IP or subnet CIDR instead. For example, if Caddy sits on a shared proxy_net in the 172.18.0.0/16 range:

NOJOIN_TRUSTED_PROXIES=127.0.0.1,::1,nginx,172.18.0.0/16

Using the bare container name (...,nginx,caddy) instead silently collapses every remote client into a single shared rate-limit bucket: caddy does not resolve from the API’s network, so that hop is treated as untrusted and the walk stops on the proxy’s own IP. Per-IP throttles (login, invitation, /setup, MCP registration) then key on one address for all external users. Nojoin logs a warning at API startup naming any configured trusted-proxy hostname it cannot resolve.

Image Trust and Supply Chain

Published Nojoin images are built by a hardened, gated release pipeline. Operators with stricter assurance requirements can rely on the following properties.

Verifying an Image Before Deploying

Verify the cosign signature (replace the tag as needed):

cosign verify ghcr.io/valtora/nojoin-api:latest \
  --certificate-identity-regexp "^https://github.com/Valtora/Nojoin/.github/workflows/release.yml@.*$" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Inspect the provenance and SBOM attestations:

# Provenance attestation
cosign verify-attestation --type slsaprovenance ghcr.io/valtora/nojoin-api:latest \
  --certificate-identity-regexp "^https://github.com/Valtora/Nojoin/.*$" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# SBOM and image index (digests, platforms, attestations)
docker buildx imagetools inspect ghcr.io/valtora/nojoin-api:latest

Pinning a deployment to an exact image digest (ghcr.io/valtora/nojoin-api@sha256:...) rather than a rolling tag guarantees you run the precise image you verified.

Upgrading and Migration

The notes below describe one-time migrations that run automatically when you first upgrade across the relevant cutover. They apply only if your database or installation predates that cutover. On a clean install, or on any installation already past these cutovers, they require no action and can be treated as historical context.

Live Pipeline Readiness Notes

Canonical Cutover Notes

Database Migrations

Useful Alembic commands:

alembic upgrade head
alembic revision --autogenerate -m "message"

Updating a Deployment

Pull-First Installations

docker compose down
docker compose pull
docker compose up -d

Local Custom Builds

docker compose down
docker compose build
docker compose up -d

Use this only if your local docker-compose.yml includes custom build directives.

worker-io is built FROM the shared worker image, so the base must be built before worker-io; otherwise Compose builds them in parallel and worker-io can ship stale code layered on the previous base. The compose files wire this ordering explicitly: worker-io’s build declares the base as a named additional_contexts entry (worker_base), so Compose builds the base first and rebuilds worker-io whenever it changes. docker-compose.example.yml pins that context to the published image (docker-image://…/nojoin-worker:latest); a full source build points it at the base service instead (service:worker-gpu, which also needs a build: stanza on the worker services). No manual build ordering is required.

Nojoin also exposes installed and latest published version information in Settings > Updates. The installed version is read from build metadata embedded into the API image, with local source builds falling back to docs/VERSION.

Release Model

Nojoin uses a unified lock-step release model: