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

VariableDefaultWhat it is
DATABASE_HOST127.0.0.1 on fullwhere PostgreSQL is
DATABASE_PORT5432 on fullits port. The compose .env and the Unraid template both fill in 5432; keep it set
DATABASE_NAMEdeadair on fullthe database, which must already exist; the station makes its schema inside it
DATABASE_USERdeadair on fullthe owner described above. Migrations run as this user at every start
DATABASE_PASSWORDdeadair on fullits password
DATABASE_APP_USERemptyoptional: 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_PASSWORDemptythat role's password, read only when DATABASE_APP_USER is set
MIGRATE_ON_BOOTtruebrings 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:

VariableWhat it is
REDIS_HOSTwhere it is
REDIS_PORT6379 unless you moved it
REDIS_USERNAMEonly on Redis 6 or newer with ACLs turned on; leave empty otherwise
REDIS_PASSWORDwhat requirepass or the ACL is set to; leave empty for a Redis with no AUTH
REDIS_TLStrue 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.

ReachedBoth are
on your own network, at the port you publishedhttp://192.168.1.10:8080
by a name your network resolveshttp://tower.local:8080
through a proxy or a tunnel that terminates TLShttps://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:

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

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

  4. The presenter. Voice → Characters: press Make station host on whoever should be on air. There are several to start from.

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

  6. 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:

VariableDefaultWhat it is
REAL_IP_FROMemptythe 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_HEADERX-Forwarded-Forthe 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 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 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.