Your library, always on.
Surf it like live TV.

Airwave turns your own media into always-on, channel-surfable live TV with a real guide, DVR, and bumpers, streamed straight from your Plex to native apps on every big screen you own.

Surf the guide·1/8

Your library, always on. Surf it like live TV.

Airwave turns your own media into always-on, channel-surfable live TV with a real guide, DVR, and bumpers, streamed straight from your Plex to native apps on every big screen you own.

Surf the guide·1/8

Airwave is a self-hostable service that turns your own Plex library into curated, always-on live TV channels, the broadcast-style guide you leave on, not another grid of posters to scroll. You own the server, the content, and the data.

Self-host

Self-host it in minutes.

One image, two roles, a Postgres. Drop this compose.yaml, point it at your database, and pull updates by re-pulling the tag. No transcoder to babysit. Airwave is the channel brain, your Plex does the streaming.

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:-}
      # Cloud Service (Airwave Cloud remote access) is hidden + inert unless this is 1. Off by default.
      AIRWAVE_CLOUD_SERVICE_ENABLED: ${AIRWAVE_CLOUD_SERVICE_ENABLED:-}
      # Where the connector serves the admin + tv-web over the tunnel (compose service names). Defaults are fine.
      AIRWAVE_WEB_ORIGIN: ${AIRWAVE_WEB_ORIGIN:-http://web:3001}
      AIRWAVE_TVWEB_ORIGIN: ${AIRWAVE_TVWEB_ORIGIN:-http://tvweb:3002}
      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:
Airwave: GuideAirwave: PlayingAirwave: Bumper

A real 10-foot experience.

The viewer app is a proper couch-and-remote TV app: an Aurora channel-guide grid, a glass player with a DVR scrubber, channel up/down, and the “Up Next” bumper card. The same app across platforms, delivered as a native binary or a browser player.

Built for a big screen and a remote, deliberately not a phone UI.

Features

Everything a channel needs.

Not a media browser, a channel you leave on. All of it runs from your own Plex, on your own hardware.

A real channel guide

A grid guide you surf like cable, always-on channels on one continuous, deterministic timeline everyone sees in sync.

Bumpers & music

“Up Next” cards and an optional ambient bed between programs.

Direct-play first

Plays your files natively; transcodes only when a device needs it.

Live offset + DVR

Join what's on now, scrub back through the buffer, restart, or roll into an earlier program. You just can't skip ahead of live.

Self-hosted & private

Runs on your hardware. No telemetry, nothing phones home.

Per-user access

Share whole packages or specific channels, enforced per viewer.

Build channels fast

Author from metadata filters, auto-generate a whole lineup, or let a bring-your-own-key AI assistant draft one.

& much more

Build entire lineups with AI, a built-in AI assistant, AI run observability, live session tracking, one synced guide every viewer shares, remote and relay playback, and more shipping regularly.

See all features →

Platforms

Built for the living room.

One app on every big screen, and a three-step path from your library to a channel you leave on.

Works on most platforms.

10-foot native apps for the living room, plus a browser player you serve from the same stack, the same app everywhere.

ReadyApple TV
ReadyiPad
ReadymacOS
ReadyWindows
ReadyLG webOS
ReadyRoku
ReadyAny browser
ReadyFire TV
ReadySamsung (Tizen)
ReadyLinux
ReadyAndroid TV

Three steps to live TV.

  1. Connect Plex

    Sign in with Plex once, enable your libraries, and sync metadata into Airwave's cache.

  2. 2

    Build channels

    Filter your library into channels (“90s comedies”, “all Studio Ghibli”), laid onto a continuous timeline.

  3. 3

    Tune in

    Open a TV app, sign in, and channel-surf your library like it's live cable, at home or on the road.

Design your lineup, then forget about it.

Build channels from filters, group them into packages, share them per-viewer, and let the scheduler keep every channel running deterministically, no babysitting.

The Airwave admin: channels

Turn your library into a channel you leave on.

Free, self-hosted, and yours. Deploy the server, connect Plex, and start surfing.