Airwave

Docker quick start

Grab the stack files, fill in .env, and bring Airwave up with docker compose — first boot, the seeded admin, and reaching the panel.

This is the hands-on Docker deploy, and the recommended way to run Airwave: grab docker-compose.yml and .env.example, fill in a few values, and bring the stack up. It works the same in a terminal (docker compose) or a UI like Dockge / Portainer: paste the compose and the env, then deploy.

Just want it running?

There's a one-line installer that does everything on this page for you. Setting it up by hand (below) is worth it if you want to understand your deployment, tweak volumes and ports, or drop it into Dockge / Portainer.

For what every variable means, see Configuration. For why the same image runs as more than one service, see Roles & the single image.

Grab the stack files

You need two files from the repo root: docker-compose.yml and .env.example. Both are shown in full below (switch tabs, and use the copy button or the GitHub link):

name: airwave
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      TZ: ${TZ:-UTC}
    volumes:
      - channelguide_pgdata:/var/lib/postgresql/data
      # TrueNAS dataset instead of a named volume? Replace the line above with a
      # bind mount to your dataset, e.g.:
      #   - /mnt/tank/apps/channelguide/pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: ${CG_IMAGE:-ghcr.io/quixomatic/airwave:latest}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      CG_ROLE: server
      PORT: 3000
      # Built from the Postgres settings — points at the postgres service by name.
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
      BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
      # Where browsers/TV reach the SERVER, and where the admin web is loaded FROM.
      BETTER_AUTH_URL: ${SERVER_PUBLIC_URL}
      CORS_ORIGIN: ${WEB_PUBLIC_URL}
      # The TV web player's origin, allow-listed for its login flow (empty unless the tvweb
      # service is enabled — see COMPOSE_PROFILES / TV_WEB_PUBLIC_URL in .env).
      TV_APP_ORIGIN: ${TV_WEB_PUBLIC_URL:-}
      # Extra admin origins allow-listed for CORS + auth, beyond CORS_ORIGIN — a comma-separated list
      # (e.g. reach the admin at a LAN IP too when CORS_ORIGIN is a public domain). See .env.example.
      EXTRA_CORS_ORIGINS: ${EXTRA_CORS_ORIGINS:-}
      ADMIN_EMAIL: ${ADMIN_EMAIL:-}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
      GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
      GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
      GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
      PLEX_CLIENT_IDENTIFIER: ${PLEX_CLIENT_IDENTIFIER:-}
      # Durable AI-lineup workflow engine — off unless WORKFLOW_ENABLED=1 in .env.
      WORKFLOW_ENABLED: ${WORKFLOW_ENABLED:-}
      WORKFLOW_TARGET_WORLD: ${WORKFLOW_TARGET_WORLD:-@workflow/world-postgres}
      WORKFLOW_LOCAL_BASE_URL: ${WORKFLOW_LOCAL_BASE_URL:-http://127.0.0.1:3152}
      WORKFLOW_POSTGRES_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      # Ambient bumper-music library — FIXED container path. Don't change this; pick the HOST side with
      # BUMPER_MUSIC_VOLUME in the volume mount below.
      BUMPER_MUSIC_DIR: /data/bumper-music
      PUID: ${PUID:-1000}
      PGID: ${PGID:-1000}
      UMASK: ${UMASK:-022}
      TZ: ${TZ:-UTC}
    volumes:
      # Bumper-music audio (persists uploads across updates). The CONTAINER path /data/bumper-music is fixed;
      # choose the HOST side with BUMPER_MUSIC_VOLUME in .env — a Docker named volume (default), or a bind
      # path to your OWN folder/dataset so you can drop tracks in directly (then "Scan folder" on the Bumpers
      # page), e.g.  BUMPER_MUSIC_VOLUME=/mnt/tank/apps/airwave/bumper-music
      - ${BUMPER_MUSIC_VOLUME:-channelguide_bumpermusic}:/data/bumper-music
    ports:
      - "${SERVER_PORT:-36020}:3000"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 60s

  web:
    image: ${CG_IMAGE:-ghcr.io/quixomatic/airwave:latest}
    restart: unless-stopped
    depends_on:
      server:
        condition: service_started
    environment:
      CG_ROLE: web
      WEB_PORT: 3001
      # Baked into the admin build — the address browsers use to reach the server.
      VITE_SERVER_URL: ${SERVER_PUBLIC_URL}
      PUID: ${PUID:-1000}
      PGID: ${PGID:-1000}
      UMASK: ${UMASK:-022}
      TZ: ${TZ:-UTC}
    ports:
      - "${WEB_PORT:-36021}:3001"
    healthcheck:
      # First start compiles the SPA (vite) before serving — allow generous start_period.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3001/ || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 180s

  # OPTIONAL — the 10-foot TV app as a browser web player (auth-gated). Off by default; enable it
  # by adding `tvweb` to COMPOSE_PROFILES in .env, and set TV_WEB_PUBLIC_URL (that address is also
  # allow-listed on the server as TV_APP_ORIGIN for the TV login flow).
  tvweb:
    image: ${CG_IMAGE:-ghcr.io/quixomatic/airwave:latest}
    restart: unless-stopped
    profiles: ["tvweb"]
    depends_on:
      server:
        condition: service_started
    environment:
      CG_ROLE: tvweb
      TV_WEB_PORT: 3002
      # Baked into the player build — the address the visitor's BROWSER uses to reach the server.
      # Defaults to the admin's server URL. Override with TV_SERVER_URL to point the player at its
      # OWN public domain (e.g. reverse-proxied at https://airwave-tv…/ with /api forwarded to the
      # server) so the server itself can stay unexposed on the LAN.
      VITE_SERVER_URL: ${TV_SERVER_URL:-${SERVER_PUBLIC_URL}}
      PUID: ${PUID:-1000}
      PGID: ${PGID:-1000}
      UMASK: ${UMASK:-022}
      TZ: ${TZ:-UTC}
    ports:
      - "${TV_WEB_PORT:-36022}:3002"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://localhost:3002/ || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 180s

volumes:
  channelguide_pgdata:
  channelguide_bumpermusic:

In Dockge, create a stack and paste the compose in; in a terminal, drop both in a directory and copy .env.example to .env. The compose already points at the published image (ghcr.io/quixomatic/airwave:latest, override with CG_IMAGE), so you don't build anything. Postgres, server, and web all run from it; the optional tvweb browser player stays off unless you enable its profile (see Roles).

Fill in .env

Copy .env.example to .env and set, at minimum:

# The addresses your BROWSER and TV actually use — LAN IP or domain + published ports.
# NOT localhost (unless you only browse from the host). Baked into the admin build.
SERVER_PUBLIC_URL=http://192.168.1.50:36020
WEB_PUBLIC_URL=http://192.168.1.50:36021

# Published host ports — must match the ports in the URLs above.
SERVER_PORT=36020
WEB_PORT=36021

# Postgres + auth
POSTGRES_PASSWORD=a-strong-password
BETTER_AUTH_SECRET=change-me-to-a-long-random-string-at-least-32-chars

# A STABLE id for this Airwave instance when it talks to Plex. Set it once to any UUID and never change
# it — Plex ties your sign-in and device to this value, so a new id on each boot means re-authenticating.
# The one-line installer generates this for you; on a manual deploy, set it yourself.
PLEX_CLIENT_IDENTIFIER=paste-a-uuid-here

# First admin, seeded on first boot
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me

# Match your host — important on TrueNAS datasets
PUID=1000
PGID=1000
TZ=UTC

Generate the two secrets with:

openssl rand -base64 48   # BETTER_AUTH_SECRET
uuidgen                   # PLEX_CLIENT_IDENTIFIER (any stable UUID; on Windows use [guid]::NewGuid())

Get the public URLs right

SERVER_PUBLIC_URL and WEB_PUBLIC_URL are baked into the admin SPA at build time and drive auth and CORS, so they must be the addresses your browser and TV really reach: a LAN IP or a domain, with the published ports. localhost only works from the host machine. Getting these wrong is the most common first-deploy snag (see Gotchas).

Bring it up

docker compose up -d

Watch the logs on first boot — the sequence is:

  1. Postgres starts and passes its healthcheck.
  2. server runs prisma migrate deploy (builds the schema), seeds the first admin from ADMIN_EMAIL / ADMIN_PASSWORD, then starts the API on container port 3000. Its healthcheck hits /api/health.
  3. web runs vite build with SERVER_PUBLIC_URL baked in — this takes a minute or two on first start (the healthcheck has a generous start period) — then serves the SPA on container port 3001.

The build-on-start for web is by design: each self-host lives at a different address, so the admin SPA is compiled for your server URL rather than shipped pre-baked.

Sign in and connect Plex

  1. Open the admin at your WEB_PUBLIC_URL (e.g. http://192.168.1.50:36021).
  2. Sign in with the ADMIN_EMAIL / ADMIN_PASSWORD you set. That account is seeded on first boot and given the admin role; it's your way in. Public sign-up is disabled — every other account is admin-provisioned.
  3. From there, follow the Quick Start: connect your Plex source, run a metadata sync, and build your first channel.

The admin seed is optional but recommended

If you leave ADMIN_EMAIL / ADMIN_PASSWORD unset (e.g. a pure Plex/OAuth deployment), no admin is created and the seed is a no-op. The seed is also idempotent: on later boots it just re-asserts the admin role, it doesn't reset the password.

Watch

Open a TV app and point it at the server. The native apps scan your LAN automatically; if that misses, enter SERVER_PUBLIC_URL by hand. Want a browser instead of a native app? Enable the optional tvweb role — see Roles & the single image.

Gotchas that actually bite

These are the real snags from deploying on TrueNAS SCALE and plain-HTTP LANs:

  • Wrong IP baked into the admin. VITE_SERVER_URL is baked at the web container's build, which runs on every start. If you fix a typo in SERVER_PUBLIC_URL, a plain restart isn't enough — force a rebuild: docker compose up -d --force-recreate web, then hard-refresh the browser.
  • TrueNAS Postgres permissions. A user: "1000:1000" on the postgres service can fail on a TrueNAS dataset (mkdir: can't create '…/pgdata': Permission denied — a dataset ACL overrides POSIX). The fix that works is to drop the user: line so Postgres runs as its default and chowns itself. The stock compose doesn't set user: on Postgres, so this only bites if you added one.
  • Plain-HTTP LAN login already handled. Over http:// on a LAN IP, the auth cookie is automatically issued SameSite=Lax (not None;Secure) — derived from the BETTER_AUTH_URL scheme — because admin and server share a host. So LAN-IP-over-HTTP login works without any extra config. (An HTTPS server keeps SameSite=None;Secure.)
  • The tvweb service stays dormant by default. It's gated behind profiles: ["tvweb"], so docker compose up won't start it unless you set COMPOSE_PROFILES=tvweb. That's intentional — see Roles.

Source map

ConcernFile
Compose stackdocker-compose.yml
Env reference.env.example
Entrypoint (migrations, seed, role dispatch)docker/entrypoint.sh
First-admin seedpackages/auth/src/lib/seed-admin.ts
Static SPA server (web / tvweb roles)docker/serve-web.ts

See also: Configuration · Roles & the single image · Updating · Quick Start

On this page