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:
# ============================================================================
# Airwave — self-host stack configuration
# Copy to .env, fill in the values, then deploy (Dockge, or `docker compose up -d`).
# ============================================================================
# --- Image ------------------------------------------------------------------
# The published image to run. Pin a version (e.g. :0.6.30) or track :latest.
CG_IMAGE=ghcr.io/quixomatic/airwave:latest
# --- Where the apps are reachable FROM YOUR BROWSER / TV ---------------------
# CRITICAL: these are baked into the admin build and used for auth + CORS, so they
# must be the addresses your browser and TV actually use — your host's LAN IP or a
# domain, with the PUBLISHED ports below. Do NOT use "localhost" unless you only
# ever browse from the host machine itself.
#
# SERVER_PUBLIC_URL — where the API/server is reachable (also the TV's server URL)
# WEB_PUBLIC_URL — where the admin web is reachable
SERVER_PUBLIC_URL=http://192.168.1.10:36020
WEB_PUBLIC_URL=http://192.168.1.10:36021
# Extra admin origins to allow-list for CORS + auth, beyond WEB_PUBLIC_URL. Comma-separated exact
# origins (scheme + host + port, no trailing slash). Use it when the admin is reachable at more than
# one address — e.g. WEB_PUBLIC_URL is a public HTTPS domain but you also open the admin over the LAN:
# EXTRA_CORS_ORIGINS=http://192.168.1.10:36021
# Note: a LAN-IP origin calling an HTTPS API is genuinely cross-site, so it relies on third-party
# cookies (the auth cookie is already SameSite=None;Secure on an HTTPS server). Works today; the
# clean long-term path is to reach the admin at its own domain.
# EXTRA_CORS_ORIGINS=
# --- Published host ports (host side -> fixed container side) ----------------
SERVER_PORT=36020 # -> container 3000 (must match SERVER_PUBLIC_URL's port)
WEB_PORT=36021 # -> container 3001 (must match WEB_PUBLIC_URL's port)
# --- Postgres ---------------------------------------------------------------
POSTGRES_USER=channelguide
POSTGRES_PASSWORD=change-me-please
POSTGRES_DB=channelguide
# --- Auth / security --------------------------------------------------------
# 32+ character random secret. Generate one with: openssl rand -base64 48
BETTER_AUTH_SECRET=change-me-to-a-long-random-string-at-least-32-chars
# First admin account, seeded on first boot (optional but recommended).
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me
# --- Runtime user / timezone (TrueNAS datasets) -----------------------------
PUID=1000
PGID=1000
UMASK=022
TZ=UTC
# --- Optional: bumper ambient music -----------------------------------------
# The bumper-music library persists via a volume mounted at the FIXED container path /data/bumper-music
# (BUMPER_MUSIC_DIR in the server env — leave it alone). Choose only the HOST side here. Unset = a Docker
# named volume (channelguide_bumpermusic). To manage the files from your host — drop tracks in and hit
# "Scan folder" on the Bumpers page — set a bind path to your own folder/dataset instead:
# BUMPER_MUSIC_VOLUME=/mnt/tank/apps/airwave/bumper-music
# --- Optional: TV web player (browser) --------------------------------------
# The 10-foot TV app, served as an auth-gated browser web player. Off by default.
# To enable it: uncomment COMPOSE_PROFILES, and set the public URL + published port.
# TV_WEB_PUBLIC_URL is also allow-listed on the server (as TV_APP_ORIGIN) for the TV
# login flow — so it must be the address browsers actually reach the player at.
# (The installed webOS/Tizen app needs none of this — it's bearer-auth, origin-agnostic.)
# COMPOSE_PROFILES=tvweb
# TV_WEB_PUBLIC_URL=http://192.168.1.10:36022
# TV_WEB_PORT=36022 # -> container 3002
# By default the player talks to the same server URL as the admin (SERVER_PUBLIC_URL). If you
# reverse-proxy the player at its own public domain with /api + /img forwarded to the server, set
# the player's server URL to that same domain — then the server never needs to be exposed:
# TV_SERVER_URL=https://airwave-tv.turboforge.io
# --- Optional: social OAuth (set BOTH id + secret to enable a provider) -----
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# --- Optional: stable Plex client identifier --------------------------------
# PLEX_CLIENT_IDENTIFIER=
# --- Optional: durable AI-lineup workflow engine ----------------------------
# Off by default. Uncomment to enable the AI lineup builder's workflow engine.
# (Its Postgres connection is derived from the Postgres settings above.)
# WORKFLOW_ENABLED=1
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=UTCGenerate 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 -dWatch the logs on first boot — the sequence is:
- Postgres starts and passes its healthcheck.
serverrunsprisma migrate deploy(builds the schema), seeds the first admin fromADMIN_EMAIL/ADMIN_PASSWORD, then starts the API on container port3000. Its healthcheck hits/api/health.webrunsvite buildwithSERVER_PUBLIC_URLbaked in — this takes a minute or two on first start (the healthcheck has a generous start period) — then serves the SPA on container port3001.
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
- Open the admin at your
WEB_PUBLIC_URL(e.g.http://192.168.1.50:36021). - Sign in with the
ADMIN_EMAIL/ADMIN_PASSWORDyou set. That account is seeded on first boot and given theadminrole; it's your way in. Public sign-up is disabled — every other account is admin-provisioned. - 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_URLis baked at thewebcontainer's build, which runs on every start. If you fix a typo inSERVER_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 thepostgresservice 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 theuser:line so Postgres runs as its default and chowns itself. The stock compose doesn't setuser: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 issuedSameSite=Lax(notNone;Secure) — derived from theBETTER_AUTH_URLscheme — because admin and server share a host. So LAN-IP-over-HTTP login works without any extra config. (An HTTPS server keepsSameSite=None;Secure.) - The
tvwebservice stays dormant by default. It's gated behindprofiles: ["tvweb"], sodocker compose upwon't start it unless you setCOMPOSE_PROFILES=tvweb. That's intentional — see Roles.
Source map
| Concern | File |
|---|---|
| Compose stack | docker-compose.yml |
| Env reference | .env.example |
| Entrypoint (migrations, seed, role dispatch) | docker/entrypoint.sh |
| First-admin seed | packages/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
Self-hosting (Docker)
Run Airwave on your own hardware — one prebuilt image, a Postgres database, and docker compose. The deploy model, prerequisites, and where each piece lives.
Desktop app
Run the whole Airwave server on your own Windows, macOS, or Linux machine with a one-click installer: no Docker, no NAS. Embedded Postgres, a tray app, and the browser as the UI.
