Workload · Running a radio station with LibreTime

LibreTime hosting on a server of your own, set up for a team of presenters

LibreTime is free, open-source software that runs a radio station on a timetable. You put shows on a calendar, give each presenter a login, and it plays them on time, handing over to DJs who connect from home. Here is a safe setup for one evening on a server of your own.

The short version
  1. A shared calendar of shows that repeat, fill themselves and play on time
  2. Separate logins for guests, DJs, program managers and admins
  3. DJs broadcast live from home on port 8002
  4. Set up in Docker on current Ubuntu, private until you have replaced the default login
  5. Nightly backups of the database, the music and the settings

Is LibreTime right for your station?

It is when several people share the airtime. Per the scheduling guide, you upload audio, add a show to the calendar and place tracks in it. A weekly programme can repeat with its playlist linked, and an autoloading playlist tops a show up an hour before air, so a forgotten slot need not mean silence.

  • Guests see the calendar and the log of what played, and can listen in.
  • DJs add audio to a library of their own and place it in the shows you give them.
  • Program Managers build and assign shows, manage all libraries, see listener numbers and export the playout log.
  • Admins look after every account and the station's settings.

Rather not run a server? Our hosted radio plans are on air in ten minutes, with Auto DJ on the paid plans. Music licensing is on royalty fees.

What your server has to give it

Very little. The install guide asks for 1 GHz, 1 GB of memory (2 GB recommended) and a fixed IP; allow about twice your library in disk, our rule. Images are amd64 only.

PortWho connects to itOpen it?
80 and 443Your team, in the browser, through the proxyYes, once the default login is gone
8000Listeners, through IcecastYes
8001The station's main live inputOpen; Docker publishes it either way
8002DJs presenting their own showsOpen; Docker publishes it either way
8080The web interface inside DockerNever; it stays on 127.0.0.1

Why Docker? LibreTime's installer script turns away everything except Ubuntu 20.04 and Debian 11, both past end of life on its releases page; the containers run fine on Ubuntu 24.04.

The server that fits

Our dedicated streaming servers are whole machines with root access on a port nobody meters. Cartridge lines have one to four drive bays (NVMe on the streaming line); rack lines have twelve to twenty-four with hardware RAID, better for a large library. A remote iLO or iDRAC console comes with the blade, rack and larger cartridge lines. Choose Intel or AMD, not ARM.

Our streaming page puts 1 Gbps at 6,250 listeners on 128 kbps audio with 20% spare; the default 256 kbps Ogg halves that to about 3,100 by our sum, and Icecast lets in 100 until you raise its cap (Icecast 2.4.4's sample configuration). A racked machine comes online about 30 minutes after payment is approved, while a made-to-order machine arrives after 4 to 24 hours.

Up and running in an evening

  1. Install Ubuntu 24.04, add Docker (Engine 28 at least), and point your station's domain at the server.
  2. As root (sudo -i), run apt-get update && apt-get -y install ufw, then sed -i '/^IPV6=/s/=.*/=yes/' /etc/default/ufw, ufw limit OpenSSH && ufw --force enable (a rate-limited allow) and ufw allow proto tcp from any to any port 8000:8002. The OpenSSH profile opens only port 22; if your SSH listens elsewhere, run ufw limit on that port before enabling.
  3. Put your domain and IANA time zone in the block's first line and paste it. It does nothing if Docker is older than 28, the domain is the example, or /opt/station/.env exists, so pasting again can never touch your database passwords.
SITE=radio.example.org; ZONE=Europe/Paris; REL=4.5.0
v=$(docker version -f '{{.Server.Version}}') && [ "${v%%.*}" -ge 28 ] \
 && [ "$SITE" != radio.example.org ] && [ ! -e /opt/station/.env ] \
 && type envsubst >/dev/null && install -d -m 750 /opt/station && cd /opt/station \
 && S=https://raw.githubusercontent.com/libretime/libretime/$REL \
 && wget -qO docker-compose.yml "$S/docker-compose.yml" \
 && wget -qO config.template.yml "$S/docker/config.template.yml" \
 && sed -i 's/- 8080:8080/- 127.0.0.1:8080:8080/' docker-compose.yml \
 && grep -q 127.0.0.1:8080:8080 docker-compose.yml \
 && install -m 600 /dev/null .env.tmp && { echo LIBRETIME_VERSION=$REL
 for n in POSTGRES_PASSWORD RABBITMQ_DEFAULT_PASS ICECAST_SOURCE_PASSWORD \
 ICECAST_ADMIN_PASSWORD ICECAST_RELAY_PASSWORD; do echo "$n=$(openssl rand -hex 16)"; done
 } > .env.tmp && ! grep -q '=$' .env.tmp && mv .env.tmp .env \
 && install -m 640 -g 1000 /dev/null config.yml \
 && bash -a -c 'source .env; envsubst < config.template.yml > config.yml' \
 && sed -i -e "s|^  public_url:\$|  public_url: https://$SITE|" \
 -e "s|^  timezone: UTC\$|  timezone: $ZONE|" \
 -e "s|^  api_key:\$|  api_key: $(openssl rand -hex 32)|" \
 -e "s|^  secret_key:\$|  secret_key: $(openssl rand -hex 32)|" config.yml \
 && [ "$(grep -Ec '^  ((api|secret)_key: [0-9a-f]{64}|public_url: https://.+)$' config.yml)" = 3 ] \
 && docker compose run --rm api libretime-api migrate && docker compose up -d \
 && echo running || echo "STOP - see below"

A STOP means one check said no; envsubst comes from gettext-base, and docker compose --file /opt/station/docker-compose.yml logs explains a failed migration or start (if Postgres was just slow, rerun the last two compose commands). Once .env is there, the block declines every new run, so to begin anew before uploading anything use F=/opt/station/docker-compose.yml; { [ ! -f $F ] || docker compose --file $F down --volumes; } && rm -r /opt/station.

Everything we changed from LibreTime's Docker guide is this. The interface port is published on 127.0.0.1 only; .env is written at mode 600 in one go and refused if a value is empty; config.yml is mode 640 with group 1000, because LibreTime's images (and only those) run as 1000:1000; timezone, secret_key, api_key and public_url are filled in; files sit in /opt/station, root-owned at mode 750. Later we add an ICECAST_MAX_CLIENTS line and a boot-time start, as no restart policy is set in the compose file.

With no address in front of it, a Docker port listens on 0.0.0.0 and [::], and IPv4 traffic to it sidesteps ufw; 127.0.0.1 keeps the interface on the server, reliably from Engine 28 (moby issue 45610).

Make it yours before anyone else can see it

  1. Give it a few seconds, then curl -fsS http://127.0.0.1:8080/ -o /dev/null && echo answering || echo "STOP - no answer yet; read the compose logs".
  2. From your computer, ssh -N you@SERVER -L 9080:127.0.0.1:8080, then visit http://localhost:9080; CORSHelper.php lets localhost requests through.
  3. The starting login is admin with password admin. Open Settings > Manage Users and create your own account of type Admin with a strong password.
  4. Sign in with it and delete admin (the x at the end of its row), since the users guide warns against keeping the default admin on a server the internet can reach. Then name your station in Settings > General.

Only now go public. LibreTime documents nginx and Apache; we use Caddy, our choice, because it handles the certificate itself (Ubuntu 24.04 has caddy in universe). Run `SITE=radio.example.org; [ "$SITE" != radio.example.org ] && apt-get update && apt-get -y install caddy && ufw allow 80,443/tcp && cd /etc/caddy && printf '%s {\n reverse_proxy 127.0.0.1:8080\n}\n' "$SITE" > Caddyfile && caddy validate && systemctl restart caddy && sleep 20 && curl -fsS -o /dev/null "https://$SITE/" && echo https-ok || echo "STOP - check the domain, apt, the Caddyfile or the certificate in journalctl -u caddy"`.

Letting DJs go live from home

Each DJ uses a streaming app (BUTT and Mixxx both work) with your domain, port 8002, mount /show and their own LibreTime login (live broadcast guide); the Show Source then takes over, by itself if auto connect is on. Port 8001 is the main input.

Two catches. Those inputs are unencrypted by default, so DJs need passwords they use nowhere else. LibreTime's harbor TLS settings can encrypt them, but the certificate would have to be mounted into the liquidsoap container, which this setup does not do. And browsers will not play the plain http:// stream inside an https page; our hosted stations come with an https:// link.

Listener cap, restarts and backups

The first lines below lift Icecast's listener cap (ICECAST_MAX_CLIENTS, listed in the image's README) to CAP, 3,000 unless you change it, adding the line once. Recreating Icecast cuts listeners off for a few seconds and they must press play again; pick a quiet hour for it, now and when you repeat it after updating. The block also adds a start after reboots (its log is overwritten each boot). The backup saves settings, database and stored audio, as LibreTime's backup guide asks, nightly while you stay on air, and prunes sets over two weeks old only after a good one. A track uploaded mid-run may be in one part only. Copy /var/backups/station to another machine too.

CAP=3000; y=/opt/station/docker-compose.yml
grep -q ICECAST_MAX_CLIENTS $y || sed -i "/ICECAST_RELAY_PASSWORD:/a\      ICECAST_MAX_CLIENTS: \"$CAP\"" $y
grep -Eq 'ICECAST_MAX_CLIENTS: "[0-9]+"' $y && docker compose -f $y up -d icecast \
 && echo cap-ok || echo "STOP - cap not set"
printf '@reboot root sleep 30; docker compose --file %s up -d > /var/log/station-start.log 2>&1\n' $y \
  > /etc/cron.d/station-start
install -d -m 700 /var/backups/station
tee /usr/local/sbin/station-backup.sh >/dev/null <<'EOF'
set -euo pipefail
umask 077
f=/opt/station/docker-compose.yml; d=/var/backups/station
rm -rf $d/set-*.part
o=$d/set-$(date +%F-%H%M%S); p=$o.part
mkdir "$p"
docker compose -f "$f" exec -T postgres pg_dump -U libretime --no-owner   --no-privileges libretime | gzip > "$p/db.sql.gz"
zcat "$p/db.sql.gz" | tail -n 5 | grep -q 'dump complete'
vol=$(docker volume inspect -f '{{.Mountpoint}}' station_libretime_storage)
tar --numeric-owner -czf "$p/storage.tar.gz" -C "$vol" . || [ $? -eq 1 ]
tar --numeric-owner -czf "$p/config.tar.gz" -C /opt station
tar -tzf "$p/config.tar.gz" station/.env station/config.yml >/dev/null
tar -tzf "$p/storage.tar.gz" >/dev/null
mv "$p" "$o"
[ "${KEEPALL:-0}" = 1 ] || find $d -maxdepth 1 -name 'set-*' -mtime +14 -exec rm -r {} +
EOF
printf '40 3 * * * root umask 077; bash /usr/local/sbin/station-backup.sh > /var/backups/station/last-run.log 2>&1\n' \
  > /etc/cron.d/station-backup
bash /usr/local/sbin/station-backup.sh && ls -l /var/backups/station

The restore script checks the set, starts Postgres without recreating anything, finds the audio volume and takes a fresh unpruned backup as your way back, all before it stops or drops anything; then it replaces the database and the audio and restarts.

tee /usr/local/sbin/station-restore.sh >/dev/null <<'EOF'
set -euo pipefail
R=${1:?name the set folder}
f=/opt/station/docker-compose.yml
gzip -t "$R/db.sql.gz"
tar -tzf "$R/storage.tar.gz" >/dev/null
tar -tzf "$R/config.tar.gz" station/.env >/dev/null
docker compose -f "$f" up -d --no-recreate postgres
docker compose -f "$f" create --no-recreate
for i in $(seq 30); do
  docker compose -f "$f" exec -T postgres pg_isready -U libretime -q && break; sleep 2
done
docker compose -f "$f" exec -T postgres pg_isready -U libretime -q
vol=$(docker volume inspect -f '{{.Mountpoint}}' station_libretime_storage)
test -d "$vol"
KEEPALL=1 bash /usr/local/sbin/station-backup.sh
echo "way-back set is $(ls -d /var/backups/station/set-* | tail -n 1)"
docker compose -f "$f" stop nginx legacy api worker analyzer liquidsoap playout
pg() { docker compose -f "$f" exec -T postgres "$@"; }
pg dropdb -U libretime libretime
pg createdb -U libretime -O libretime libretime
gunzip -c "$R/db.sql.gz" | pg psql -q -U libretime -v ON_ERROR_STOP=1 libretime
find "$vol" -mindepth 1 -delete
tar --numeric-owner -xzf "$R/storage.tar.gz" -C "$vol"
chown -R 1000:1000 "$vol"
docker compose -f "$f" run --rm api libretime-api migrate
docker compose -f "$f" up -d
EOF
chmod 700 /usr/local/sbin/station-restore.sh && echo "restore script saved"

Run it as root with `R=/var/backups/station/set-STAMP; test -d "$R" && bash /usr/local/sbin/station-restore.sh "$R" && echo restored || echo "STOP - if a way-back set was printed, the database or audio may be half back, so run it again with that set; if none was printed, nothing was changed"`.

On a new server, install Docker, set up the firewall, copy a set across, then `R=/root/set-STAMP; f=/opt/station/docker-compose.yml; [ ! -e /opt/station ] && tar --numeric-owner -xzf $R/config.tar.gz -C /opt && docker compose --file $f up --no-recreate -d postgres && docker compose --file $f create --no-recreate && echo unpacked || echo "STOP - folder already there, or unpacking or starting went wrong". When docker compose --file $f exec -T postgres pg_isready -U libretime` reports accepting connections, paste the two script blocks and run the restore, then the proxy step.

Updating without a long silence

  1. Read each release note between your version and the new one; some ask for config.yml edits.
  2. Run the backup, put the new release's tag in T (T= and the tag), and swap in its compose file with `cd /opt/station; if [ -z "$T" ]; then echo "STOP - T is empty, nothing changed"; elif grep -Fqx "LIBRETIME_VERSION=$T" .env; then echo "STOP - this tag is already in place; nothing changed, do not undo"; else cp -p .env .env.pre && cp -p config.yml config.yml.pre && cp -p docker-compose.yml docker-compose.yml.pre && curl -fsSLo docker-compose.yml.next "https://raw.githubusercontent.com/libretime/libretime/$T/docker-compose.yml" && sed -i '/8080:8080/s/- /- 127.0.0.1:/' docker-compose.yml.next && grep -Fq 127.0.0.1:8080:8080 docker-compose.yml.next && mv docker-compose.yml.next docker-compose.yml && sed -i "/^LIBRETIME_VERSION=/c LIBRETIME_VERSION=$T" .env && echo ready || echo "STOP - not ready; run the undo line"; fi`. Then run the listener-cap lines again, because the new compose file lacks that line.
  3. With f=/opt/station/docker-compose.yml, run `if docker compose --file $f pull; then docker compose --file $f stop nginx legacy api worker analyzer liquidsoap playout && docker compose --file $f run --rm --no-deps api libretime-api migrate && echo migrated && docker compose --file $f up -d || echo "STOP - run the undo line, then the restore script on the set taken before updating, which brings the station back up"; else echo "STOP - download incomplete, still on air unchanged; run the undo line"; fi`. Images download while you are still on air, so the gap is just the migration.
  4. The undo line is `cd /opt/station && test -f .env.pre && test -f config.yml.pre && grep -Fq 127.0.0.1:8080:8080 docker-compose.yml.pre && cp -p .env.pre .env && cp -p config.yml.pre config.yml && cp -p docker-compose.yml.pre docker-compose.yml && echo undone || echo "STOP - a .pre copy is absent; if preparing stopped before copying, there is nothing to undo". Once the update looks right, remove the .pre` files.

The licence

LibreTime ships under the GNU AGPL, version 3, per its LICENSE file. In our own general reading, which is no legal advice, your station may use it freely; hand copies to others, changed or not, and the source travels with them; let people use a changed version over the internet, and you owe them its source.

Questions people ask

Is LibreTime really free to use?
Yes, it is open source under the AGPL version 3 with no licence fee; you pay for the server and any music royalties.
I still run Airtime. Can I move to LibreTime?
Yes. LibreTime's docs call it a fork of Airtime, and a migration guide moves Airtime's configuration, database and files across; it assumes the native installer, so adapt its restore commands for Docker.
Does LibreTime need a powerful server?
No. Its install guide asks for a 1 GHz processor and 1 GB of memory, with 2 GB recommended; the library's size and a console for bad nights matter more.

Give your station a server of its own

Pick a machine with the drive bays your library needs and an unmetered port, then follow the steps above.

Get a server for your station

Read next