Running deadair
One container: the station, its console, the audio chain, the stream server and the measurement sidecar. One volume. The stream comes out of the same port the console does, so whatever you put in front of that port carries the station too.
Which tag
| Tag | Brings | Bring your own |
|---|---|---|
deadair/deadair:latest | a voice | PostgreSQL, Redis |
deadair/deadair:full | a voice, PostgreSQL, Redis | nothing |
deadair/deadair:slim | — | PostgreSQL, Redis, a speech server |
Each of the three has an Apple Music twin, deadair/deadair:apple, full-apple and slim-apple, which
is the same image plus a player that plays Apple Music records in Google Chrome and records them for the
station. It is most of a gigabyte bigger, so take it only if you have an Apple Music subscription to play
from. It needs shm_size: 1gb on the service (the compose file here already sets it) and a one-time sign-in, which
stream/apple-player/README.md walks through.
Apple's terms are between you and Apple, as every provider's are; see
docs/licensing.md.
Those three follow main and move on every push that changes the station, not on one that only
touches the docs, the website or a listener app. A release publishes its version beside them, for
each tag: 0.50.2 and 0.50 for latest, full-0.50.2 and full-0.50 for full, and the same
with slim- for slim, and apple-, full-apple- and slim-apple- for the twins. Pin one if you want releases only: under Compose that is
VARIANT=full-0.50 in .env, because the compose file takes its tag from VARIANT. Images are built for linux/amd64;
there is no arm64 build yet.
full is the one to start with if the machine has nothing on it. latest is the one to run if
the machine already has a database you keep backups of, which is the better place for this
station's to live. slim is for pointing the speech plugin at a machine with a graphics card in
it; the voice is the one part of this that genuinely wants one.
Before the first start
Two secrets, and the station makes both itself on its first boot when neither is set. It writes
them to secrets/ in the data directory and reads them back from there on every boot after, so
that directory is as much the station as anything else in /data: back it up with the rest, because
losing KMS_LOCAL_ROOT_KEY loses every credential it encrypted.
Set them yourself only if you want the root key somewhere other than the volume it protects, such as a secrets manager that injects the environment. A variable always wins over the file, and the station says so at boot if the two disagree. To generate your own:
openssl rand -hex 32
That is KMS_LOCAL_ROOT_KEY, and it encrypts every credential the station stores: your music
provider's tokens, your mail password, the stream's own passwords. Losing it means entering the ones
you typed again. The stream's passwords need nothing from you, because the station made them and
both ends read the same stored value. It has to be hex: the station reads it as hex bytes, so a
base64 key of the same length is quietly a different and much weaker key than you meant to generate.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | base64 -w0
That is AUTHENTICATION_SESSION_JWT_PRIVATE_KEY, and it signs sessions. It must be RSA, because
sessions are signed RS256. Losing it signs everybody out and costs nothing else.
The base64 is there because a key is several lines and nearly every field you might paste one
into holds a single line — a container template's variable box, an environment file, a secrets
form. The station accepts the key three ways (the PEM as it comes out, the PEM with its line
breaks written as \n, or base64-ed onto one line), so if you already have one in another shape
it will work; base64 is simply the shape that survives everything. On macOS the flag is base64
with no -w0.
If you are not using the full tag, you also need a PostgreSQL database that already exists (the
station creates its own schema inside it, not the database itself) and a Redis to put sessions in.
The next two sections are what each of those has to be.
Bringing your own database
PostgreSQL 13 or newer, and no extensions. 13 is the job queue's floor and nothing in the
schema asks for more. The one extension it uses is plpgsql, which every database already has. The
station is developed and tested on 17, which is what the full tag bundles, and its migrations have
been walked from zero on 13, 15 and 18.
The user the station connects as should own the database. Three things are created inside it:
the station's deadair schema, the job queue's pgboss schema when it starts, and the migration
bookkeeping table in public. On PostgreSQL 15 and later only the database's owner may create
anything in public by default, so a user that was merely granted access fails there.
It also needs CREATEROLE, or an app_user role made for it beforehand. The first migration
creates a role named app_user, which the station's default grants are written to for the optional
runtime user (DATABASE_APP_USER), and it creates it whether or not you ever set one. Without
CREATEROLE the first start stops with permission denied to create role. As a superuser, once:
CREATE ROLE deadair LOGIN PASSWORD 'choose-one';
CREATE DATABASE deadair OWNER deadair;
CREATE ROLE app_user NOLOGIN;
If you would rather the station made app_user itself, run ALTER ROLE deadair CREATEROLE in
place of the last line.
Then tell the station where it is. On latest and slim, DATABASE_HOST, DATABASE_USER and
DATABASE_NAME are required, and the container stops at boot with a message naming them when one is
missing. On full leave them all empty: it fills in its own database on loopback, and only a value
you set yourself overrides that.
| Variable | Default | What it is |
|---|---|---|
DATABASE_HOST | 127.0.0.1 on full | where PostgreSQL is |
DATABASE_PORT | 5432 on full | its port. The compose .env and the Unraid template both fill in 5432; keep it set |
DATABASE_NAME | deadair on full | the database, which must already exist; the station makes its schema inside it |
DATABASE_USER | deadair on full | the owner described above. Migrations run as this user at every start |
DATABASE_PASSWORD | deadair on full | its password |
DATABASE_APP_USER | empty | optional: a second, non-owner role for everything the station does while running, holding read and write grants only, so a request cannot alter the schema. Empty runs everything as the owner |
DATABASE_APP_PASSWORD | empty | that role's password, read only when DATABASE_APP_USER is set |
MIGRATE_ON_BOOT | true | brings the schema up to date before the station starts. Turn it off only if you apply migrations yourself: an out-of-date schema degrades quietly rather than failing |
The migrations wait about a minute for the database to answer rather than giving up at once, so a database that starts a few seconds after the station is not a problem.
Bringing your own cache
Any Redis, and it may be one you are already using. Sessions and refresh-token families live here rather than in the database, so losing it signs everybody out and costs nothing else. The station holds one connection and every part of it shares that connection.
It may ask for a password. Five variables describe it:
| Variable | What it is |
|---|---|
REDIS_HOST | where it is |
REDIS_PORT | 6379 unless you moved it |
REDIS_USERNAME | only on Redis 6 or newer with ACLs turned on; leave empty otherwise |
REDIS_PASSWORD | what requirepass or the ACL is set to; leave empty for a Redis with no AUTH |
REDIS_TLS | true if the connection itself is TLS |
A Redis with only requirepass set wants the password and no username, which is the ordinary case
on a server you run yourself. REDIS_TLS is about the connection rather than the password: without
it the password travels in the clear, which is fine on your own network and is not fine across the
internet.
Or give the whole thing as one URL, which is the shape a hosted Redis hands you:
REDIS_URL=rediss://default:the-password@cache.example.com:6380/0
Set REDIS_URL and it is the whole answer: the five variables above are not read at all, so the
two cannot half-disagree. It is also the only way to name a database index, if you are sharing one
Redis between applications and want the station in its own. rediss:// is TLS and redis:// is
not. Anything in the password that is not a plain letter or digit has to be percent-encoded, so an
@ is written %40.
A URL the station cannot read stops it at boot, naming the variable and not quoting it. That is deliberate: falling back to a local Redis would start the station and put its sessions somewhere nobody chose.
The station's address
APP_BASE_URL and SPA_BASE_URL are both the address you type into a browser to reach the
station, and in this image they are the same address, because one port serves the console and the
API. Write all of it: the scheme, the host, and the port unless it is 80 or 443, with nothing after
the port.
| Reached | Both are |
|---|---|
| on your own network, at the port you published | http://192.168.1.10:8080 |
| by a name your network resolves | http://tower.local:8080 |
| through a proxy or a tunnel that terminates TLS | https://radio.example.com |
Use whichever of those you actually type. If you reach it more than one way, pick the one you will open a sign-in link on.
They do not decide whether the console loads. It talks to /api on whatever address it was loaded
from, so a wrong value here leaves it working. What they address is everywhere the station sends a
browser rather than answering it: the link in a sign-in email (SPA_BASE_URL), and the return from
a music provider's authorization (APP_BASE_URL). Those are what land nowhere when the value is
wrong. The stream's public URL, under the stream settings in the console, is derived from
SPA_BASE_URL when it is left empty: it is what Icecast advertises as its hostname and where a
listener's player is told to fetch artwork from, so a wrong value there is a player showing no cover.
Plain http:// is fine on your own network. A browser treats a page served that way from anything
but localhost as not secure and withholds a few functions from it. The console works without
them, Copy buttons included: they fall back to the browser's older way of copying, and if a browser
refuses that too, the button says "Copy failed" and shows the text already selected, so your
keyboard's copy shortcut finishes the job. An image from before that was true showed "Can't reach
the station" on every page when opened that way; pulling a current one is the cure.
On Unraid
Search for deadair under Apps, install the template, fill in the fields, start it. The data
path defaults to /mnt/user/appdata/deadair and everything the station keeps goes there.
There is no ownership step: the container runs as 99:100, which is what an appdata share is owned by, so it can write to the volume as it stands.
On Unraid is that form field by field: which of the three
tags to pick, the six fields you have to fill in, which ones to leave empty on the full tag, and
what the Advanced view holds. The template itself is unraid/deadair.xml, and every field's
description there is the same text.
Anywhere else
One command, on any machine with Docker and the Compose plugin:
curl -fsSL https://deadair.radio/install.sh | sh
It asks where to put the station, which tag, which port, and the address you will type to reach it.
Then it writes docker-compose.yml and .env from this directory, hands the data directory to the
station's user, starts the station, waits for the first boot, and runs deadair-doctor inside it.
Running it again keeps the .env it finds. install.sh is short enough to read
before you pipe it into a shell.
By hand instead: docker-compose.yml here is the same container described for Compose. Copy
.env.example to .env, fill it in, and:
docker compose up -d
On a host whose data directory is not already owned by 99:100, give it to the station once:
sudo chown -R 99:100 ./data
The first boot
The schema is applied before the station starts, every time, so there is no migration step to run.
Open the console on the port you published. The first page is Set up deadair: create the administrator there with an email address and a password, which signs you in. Then, in order of what actually stops the station being a station:
-
A music provider. Settings → Plugins: enter the provider's credentials and let the library sync. Nothing can be programmed until there are records to program.
-
Playback authorization, if that provider is Spotify. Same page, the card below the connection. This is a second credential and it is easy to think it is the same one: the connection lets the plugin read your library, and this lets the station fetch the audio. With only the first, the console lists your playlists perfectly and every record is dropped for want of audio. It takes one browser visit and holds indefinitely.
The page Spotify sends your browser to will not load, and that is expected rather than the step failing. It is an address on the station itself, which your browser cannot reach; copy it out of the address bar and paste it back into the console, which finishes the job. The card walks you through it.
-
The stream's own settings. Settings → Stream: a station name, and the public address listeners reach it at. Saving these is what renders the stream server's configuration for the first time and takes the audio chain off its built-in defaults.
-
The presenter. Voice → Characters: press Make station host on whoever should be on air. There are several to start from.
-
A model, if you want one. Settings → Plugins: the station writes what the presenter says with a local or hosted model when one is configured, and from its own phrasings when none is. It is not required, and a station with none still talks.
-
Where the station is. Settings → Station: its name, the town it is in (the weather comes from there), and its timezone as an IANA zone name. It is what the presenter reads the clock in, and — less obviously — what decides whether it is morning or evening where a listener is. Left unanswered the station falls back to the container's
TZ, which is UTC unless you set it, and a presenter four hours out says "tonight" through your afternoon. Set the setting rather than the variable where the two disagree: a station is a place and its listeners are in it, which is not necessarily where the server is.
Two things need a browser visit rather than a setting, and both are in Settings → Plugins: the music
provider's authorization, and — on Spotify — the station's own playback authorization above. Nothing
else does. Then, on the Desk, press Plan, start a show and press Go on air, and open /live.mp3.
Nothing here needs a URL pointing at another container. The parts of the station address each other inside the container, and the speech and measurement plugins are already looking at the right place.
Checking the install
docker exec deadair deadair-doctor
One line per check, and a non-zero exit when one fails:
- the data volume is mounted and the station can write to it;
- both keys are there, and where each came from;
- the database answers and its schema is current;
- the cache answers;
- the two addresses are set and agree;
- the speech model's weights are downloaded;
- the API is answering.
It answers whether the container is set up right. Why a running station is quiet is a different question, and the console's Check-up page answers that one.
Putting it on the internet
Publish the one port through whatever you already use — a reverse proxy or a tunnel. It carries
the console, the API and /live.mp3, which is the stream itself. Terminate TLS out there; the
container speaks plain HTTP.
Tell it what is in front of it. Out of the box the container believes no forwarded address at all, so every caller is whatever connected to the port. Behind a proxy or a tunnel that is the proxy, identically for everyone: the whole internet shares one rate limit, and the listener count collapses to however many different players are tuned in. Naming the proxy lets the station read the real address out of a header instead:
| Variable | Default | What it is |
|---|---|---|
REAL_IP_FROM | empty | the proxy's address or CIDR, or several separated by commas or spaces; 172.16.0.0/12 covers another container on the same box. Empty trusts nobody |
REAL_IP_HEADER | X-Forwarded-For | the header that proxy puts the caller's address in. A Cloudflare tunnel also sends CF-Connecting-IP. Ignored while REAL_IP_FROM is empty |
It is opt-in because a forwarded address is only a header, and believing one from something that is
not your proxy lets anybody who can reach the port be a different caller on every request, which is
no rate limit at all. So name only something you put there yourself. With X-Forwarded-For the
station takes the rightmost address that is not itself a trusted proxy, which resolves a chain of two
(a CDN in front of a reverse proxy) to the listener. A value nginx cannot parse is dropped at boot
with a line in the container log saying so, rather than stopping the station, and the log says
trusting … for the caller's address when it took.
The mount is not authenticated. Anybody who can reach that address can listen, which for a radio station is usually the point, but it is worth knowing before you publish it.
Finding speakers on your network
The Speakers plugin can find Chromecasts, BluOS players and UPnP renderers by itself, but only when the container can see your network's multicast, which a Docker bridge network (the default here, and on Unraid) does not carry. On a bridge network, add each speaker by its address in the plugin's settings instead; that works on any network, and the desk's speaker menu says when the station has never found anything on its own.
To let it look, run the container on the host's network: replace ports: with
network_mode: host in docker-compose.yml. The container then answers on the host's port 80
directly, since it always listens on 80, so that port has to be free on the host, and HTTP_PORT
no longer applies.
Two kinds of thing, and two disks
Everything above keeps one directory, which is right until the library gets big. But the station keeps two kinds of thing and they want different treatment:
| What it holds | Wants | |
|---|---|---|
/data | settings, presenters, the schedule, pronunciations, ratings, installed plugins, the stream's credentials, audio you recorded yourself, logs, and on full the database | fast storage, and your backups |
/media | records downloaded before they air, cover art, rendered speech, voice previews, the speech model's weights | bulk storage, and no backup |
The second is entirely derived: every file in it can be produced again by running the station, and it grows to roughly the size of the library you actually play. The first is what somebody authored, and it stays small.
Mount something at /media and the derived half goes there:
-v /mnt/user/media/deadair:/media
On Unraid that is the Media field in the template; leave it blank and everything stays under
/data, exactly as before.
The station decides by whether /media is really mounted, not by a setting you also have to
change. That is deliberate: a path and a variable that must agree can disagree, and the way they
would disagree here is the expensive one — the station writing a whole library into the
container's own filesystem, which on a home server means quietly filling the disk image every
container shares.
playout.trackCacheMaxBytes puts a ceiling on the record cache if the disk it lives on has one.
Two things sit on the authored side that look like they belong with the media, and do not: the
audio an operator drops in for the station to take in (/data/inbox), and any voice clip added by
hand (/data/voices). Nothing regenerates those.
/data/streamhls is the opposite of all of it and wants no thought at all: it holds the last few
seconds of HLS segments while stream.hlsEnabled is on, is rewritten continuously, and is bounded
by the segment settings. Losing it costs a listener one reconnect. It is in /data because
Liquidsoap writes it and nginx serves it, and both of those live here.
Upgrading
Pull the new image and bring the stack back up:
docker compose pull
docker compose up -d
On Unraid, the template checks for a new image and the update is a button.
The schema looks after itself: migrations are applied before the station starts, so a new one is picked up on the next boot and there is nothing to run by hand.
Which tag you are on decides what you get. latest, slim and full follow main, so pulling
one gets you whatever was last merged. Pinning full-0.50 gets you releases on that line and nothing
else, and the release notes for each are in the repository's CHANGELOG.md. If you want a station
that only changes when you decide it does, pin the exact version, full-0.50.2.
After any upgrade, docker exec deadair deadair-doctor says whether the container came back as it
should.
Rolling back
Write down the version you are on before you upgrade. It is the newest release under On this station on
the console's What's new page, and docker exec deadair printenv BUILD_VERSION prints it. Take the backup below as well.
Going back is the version in the tag and a recreate. Under Compose, set VARIANT to the earlier
release (full-0.50.1) and:
docker compose pull
docker compose up -d
On Unraid, edit Repository to the same tag and apply.
The schema does not go back with the image. Migrations only ever move forward, and an older
station started against a database a newer one migrated may meet columns and tables it does not
know. Going back one release is usually fine. Going back across a release whose changelog mentions a
migration means restoring the backup you took before upgrading: the data directory, and on latest
or slim the database dump beside it.
From 0.1.0 onward a migration is added, never edited. Before the first release this project edited them in place, which is right when the only database in the world is the author's and wrong the moment somebody else has one: dbmate tracks versions rather than checksums, so an edited file is silently not re-applied on a database that already ran it. If a release ever does need a step run by hand, that step is in the changelog entry for that version, and it says so there rather than living here forever.
Backing it up
The data directory, and — if your database is elsewhere — a dump of it. Not the media directory. Between them they hold everything that was authored rather than fetched: the settings, the presenters, the schedule, the pronunciations, your ratings, and any audio you dropped in yourself. The rest (the library's metadata, its cached audio, artwork, measurements, rendered speech) is regenerated by running the station again.
Do this before every upgrade, not only on a schedule. latest follows main, so pulling it is not a
numbered release with a changelog entry to weigh first: copy /data and dump the database before you
pull, and the backup taken just before is the way back if something in it does not fit your station.