Skip to main content

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

TagBringsBring your own
deadair/deadair:latesta voicePostgreSQL, Redis
deadair/deadair:fulla voice, PostgreSQL, Redisnothing
deadair/deadair:slimPostgreSQL, Redis, a speech server

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 0.1.0 and 0.1 beside them, so pin deadair/deadair:0.1 if you want releases only. 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, generated once and kept:

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.

On Unraid

Install the template in unraid/deadair.xml, 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.

Anywhere else

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 and sign in. Then, in order of what actually stops the station being a station:

  1. A music provider. Plugins page: enter the provider's credentials and let the library sync. Nothing can be programmed until there are records to program.

  2. 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.

  3. The stream's own settings. Stream page: 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.

  4. The presenter. Personas page: pick who is on air. There are several to start from.

  5. A model, if you want one. Plugins page: 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.

  6. Where the station is. Settings page: 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 on the plugins page: the music provider's authorization, and — on Spotify — the station's own playback authorization above. Nothing else does.

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.

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 and trusts the forwarded headers.

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.

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 holdsWants
/datasettings, presenters, the schedule, pronunciations, ratings, installed plugins, the stream's credentials, audio you recorded yourself, logs, and on full the databasefast storage, and your backups
/mediarecords downloaded before they air, cover art, rendered speech, voice previews, the speech model's weightsbulk 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 0.1 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.

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.