Use case · Radio automation

LibreTime on a dedicated server: Docker install, live inputs and backups

LibreTime is open-source radio automation. Staff upload audio and place shows on a calendar, the server plays them through Icecast on port 8000, and DJs go live on 8001 and 8002. Below, release 4.5.0 runs under Docker Compose on Ubuntu 24.04, private until the default account is gone, then behind nginx with HTTPS and tested backups.

See the machines

What LibreTime runs for a station

LibreTime keeps the schedule so nobody sits at a playout PC. The scheduling guide's workflow is upload, create a show on the calendar, drag tracks into it. Shows repeat weekly or monthly and can be linked so one playlist serves every airing; an autoloading playlist fills a show an hour before air.

Account typeWhat it can do, on top of the type above it
GuestView the calendar and the playout log, listen inside the interface
DJUpload to their own library and schedule it into shows assigned to them
Program ManagerCreate and assign shows, manage others' libraries, see listener statistics, export the playout log
AdminManage every account, track types and system settings

That log export feeds royalty reporting, per the users guide (who gets paid). A station that only needs a stream, a hosted Auto DJ and DJ logins should look at our internet radio plans.

Requirements and ports

ItemLibreTime figureNotes
Processor1 GHzImages are amd64 only, so no ARM line
Memory1 GB required, 2 GB recommendedPostgres, RabbitMQ, Liquidsoap, PHP
DiskNot statedOur rule of thumb is twice the library
8000/tcpIcecast listenersPublished by Docker
8001, 8002/tcpLive inputs, main and showPublished by Docker

The machine

Our dedicated streaming servers are physical machines with root and an unshared, unmetered port (1 Gbps on 10 of our 18 lines, 500 Mbps on the rest). Take a blade or rack server; those, and among cartridges only the m510 and m710x, carry an out-of-band console (iLO, iDRAC or a Supermicro BMC). Ready servers go live about 30 minutes after the order is approved; more bays or a chosen RAID level come from the custom build page in 4 to 24 hours.

At the default 256 kbps Ogg, 80% of 1 Gbps carries about 3,100 listeners by our arithmetic, but Icecast as shipped admits 100 (the clients default in Icecast 2.4.4's sample configuration). The backup block raises it through ICECAST_MAX_CLIENTS, which the image's README lists.

Installing release 4.5.0 with Docker Compose

You need Ubuntu 24.04, Docker Engine 28 or newer from Docker's packages, DNS pointing here and a root shell (sudo -i). Firewall first, with apt-get update && apt-get install -y ufw && sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable && ufw allow 8000,8001,8002/tcp (OpenSSH opens only port 22; allow your own SSH port first if it differs). Docker-published ports skip ufw on IPv4; the rule covers docker-proxy on [::].

Set H to the hostname and TZN to the station's IANA zone, then paste. It refuses Docker below 28, an unchanged H, or an existing /opt/libretime/.env, so a second paste cannot replace the passwords the database was created with.

H=radio.example.org; TZN=Europe/London; V=4.5.0
v=$(docker version -f '{{.Server.Version}}') && [ "${v%%.*}" -ge 28 ] \
 && [ "$H" != radio.example.org ] && [ ! -e /opt/libretime/.env ] \
 && type envsubst >/dev/null && install -d -m 750 /opt/libretime \
 && cd /opt/libretime && U=https://raw.githubusercontent.com/libretime/libretime/$V \
 && wget -qO docker-compose.yml "$U/docker-compose.yml" \
 && wget -qO config.template.yml "$U/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.new && { echo LIBRETIME_VERSION=$V
 for k in POSTGRES_PASSWORD RABBITMQ_DEFAULT_PASS ICECAST_SOURCE_PASSWORD \
 ICECAST_ADMIN_PASSWORD ICECAST_RELAY_PASSWORD; do echo "$k=$(openssl rand -hex 16)"; done
 } > .env.new && ! grep -q '=$' .env.new && mv .env.new .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://$H|" \
 -e "s|^  timezone: UTC\$|  timezone: $TZN|" \
 -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 started || echo "STOP - see below"
  • A STOP is a guard, missing envsubst (apt install gettext-base), a failed download or edit, or the migration or start; read docker compose -f /opt/libretime/docker-compose.yml logs, and if Postgres was slow, rerun the two compose commands.
  • If a first paste stopped after writing .env, the guard will refuse a second one; start over (nothing uploaded yet) with F=/opt/libretime/docker-compose.yml; { [ ! -f $F ] || docker compose -f $F down -v; } && rm -r /opt/libretime. The -v wipes the database and storage volumes.
  • Our changes to the official Docker guide, in full. The web port is bound to 127.0.0.1; .env is written once at mode 600 and checked for empty values; config.yml at mode 640, group 1000; public_url, api_key, secret_key and timezone set by sed (the template marks the first three required); files in /opt/libretime. LibreTime's own images run as UID and GID 1000, hence the group; /opt/libretime is mode 750 and owned by root, so no host login account can reach the file. Two later edits are ours as well, the ICECAST_MAX_CLIENTS line in the compose file and a start-at-boot entry in /etc/cron.d, since the compose file sets no restart policy.

A mapping without an address publishes on 0.0.0.0 and [::]; 127.0.0.1 keeps the web UI local, enforced from Docker Engine 28 (moby issue 45610).

First sign-in over SSH, then HTTPS

  1. After a few seconds, curl -fsS -o /dev/null http://127.0.0.1:8080/ && echo web-ok || echo "STOP - read the compose logs" on the server should print web-ok.
  2. On your workstation, ssh -L 18080:127.0.0.1:8080 -N you@SERVER, then open http://localhost:18080. CORSHelper.php accepts localhost origins, so this works while public_url names your domain.
  3. Log in with the documented default, admin / admin, and under Settings > Manage Users add yourself as type Admin with a long password.
  4. Log back in as yourself and delete admin with the x on its row; the users guide advises against leaving it on an internet-facing server. Fill in Settings > General.

The site file below is the reverse-proxy guide's, with three changes of ours, namely listen [::]:80; added (as in the installer guide), proxy_pass pointed at 127.0.0.1 (localhost may resolve to ::1, where nothing listens), and our file name. Certbot prompts for an email and the terms. The last line adds the start-at-boot cron entry; its log is overwritten on every boot.

H=radio.example.org
[ "$H" != radio.example.org ] && apt-get update && apt-get install -y nginx certbot python3-certbot-nginx \
 && ufw allow 80,443/tcp && echo "packages ok" || echo "STOP - set H, then check apt"
tee /etc/nginx/sites-available/libretime.conf >/dev/null <<'EOF'
server {
  listen 80;
  listen [::]:80;
  server_name HOSTNAME;
  client_max_body_size 512M;
  client_body_timeout 300s;
  location / {
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
    proxy_pass http://127.0.0.1:8080/;
  }
}
EOF
f=/etc/nginx/sites-available/libretime.conf
[ "$H" != radio.example.org ] && sed -i "s/HOSTNAME/$H/" $f && rm -f /etc/nginx/sites-enabled/default \
 && ln -sf $f /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx \
 && certbot --nginx -d "$H" && echo "HTTPS is live" \
 || echo "STOP - nginx test, reload or certificate did not succeed; read the output above"
printf '@reboot root sleep 30; docker compose --file /opt/libretime/docker-compose.yml up -d > /var/log/libretime-start.log 2>&1\n' \
  > /etc/cron.d/libretime-start

Live shows and the listener stream

In the live broadcast guide, BUTT connects to port 8002, mount /show, with the DJ's own login; 8001 is the main input. Both harbors are unencrypted by default. Listeners use http://H:8000/main; Icecast cannot be proxied (reverse-proxy guide) and browsers block http audio on https pages, so a secure-site player needs an Icecast TLS socket, not covered here. Our hosted plans print an https listen link.

Listener limit, backups, restore and updates

The block opens with the listener limit (set L; the line is added once). Recreating Icecast drops listeners for a few seconds until they press play again, so run it, and the same lines after an update, at a quiet hour. The backup guide lists configuration, database and file storage. This script keeps the station on air, dumps Postgres from the running container, archives the storage volume and /opt/libretime, checks each part, and prunes sets past 14 days only after a good one exists. A file uploaded mid-run may land in one part only. Copy /var/backups/libretime to another machine yourself.

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

To restore on the same machine, work as root through these steps.

  1. Define the variables and check the set and the volume with `c=/opt/libretime/docker-compose.yml; B=/var/backups/libretime/lt-STAMP; A="playout liquidsoap analyzer worker api legacy nginx"; s=$(docker volume inspect libretime_libretime_storage | grep -o '"Mountpoint": "[^"]*' | cut -d'"' -f4); test -d "$s" && gzip -t $B/db.sql.gz && tar -tzf $B/config.tar.gz libretime/.env >/dev/null && tar -tzf $B/storage.tar.gz >/dev/null && echo ok || echo "STOP - set or volume missing, nothing changed"`.
  2. Snapshot the present state without pruning, NOPRUNE=1 bash /usr/local/sbin/libretime-backup.sh && ls -d /var/backups/libretime/lt-* | tail -n 1 || echo "STOP - no rollback set, do not continue"; the folder it prints is your rollback set.
  3. Reload the database by running `docker compose -f $c stop $A && docker compose -f $c exec -T postgres dropdb -U libretime libretime && docker compose -f $c exec -T postgres createdb -U libretime -O libretime libretime && (set -o pipefail; gunzip -c $B/db.sql.gz | docker compose -f $c exec -T postgres psql -q -U libretime -v ON_ERROR_STOP=1 libretime) && echo db-ok || echo "STOP - the database may be half back; run steps 3 and 4 with B set to the rollback set"`.
  4. Put the files back and start with `[ -d "$s" ] && find "$s" -mindepth 1 -delete && tar --numeric-owner -xzf $B/storage.tar.gz -C "$s" && chown -R 1000:1000 "$s" && docker compose -f $c run --rm api libretime-api migrate && docker compose -f $c up -d && echo restored || echo "STOP - the audio may be half back; run steps 3 and 4 with B set to the rollback set, and read the compose logs if the files are back but the stack is down"`.
  5. Rollback is steps 1, 3 and 4 with B on the rollback set; nothing else is set aside.

On a new machine, after Docker and the firewall line, set c and B and run `[ ! -e /opt/libretime ] && tar --numeric-owner -xzf $B/config.tar.gz -C /opt && docker compose -f $c up -d --no-recreate postgres && docker compose -f $c create --no-recreate && echo config-ok || echo "STOP - /opt/libretime exists, or the unpack or start did not succeed". Once docker compose -f $c exec postgres pg_isready -U libretime` says accepting connections, run steps 1, 3 and 4, then the proxy and backup blocks.

To update, read every release note up to the target (some change config.yml), run the backup, and set N to the new tag from the releases page. Prepare with `cd /opt/libretime; if [ -z "$N" ]; then echo "STOP - set N first, nothing changed"; elif grep -qx "LIBRETIME_VERSION=$N" .env; then echo "STOP - already prepared for $N; nothing changed, do not roll back"; else cp -p .env .env.old && cp -p config.yml config.yml.old && cp -p docker-compose.yml docker-compose.yml.old && wget -qO docker-compose.yml.new "https://raw.githubusercontent.com/libretime/libretime/$N/docker-compose.yml" && sed -i 's/- 8080:8080/- 127.0.0.1:8080:8080/' docker-compose.yml.new && grep -q 127.0.0.1:8080:8080 docker-compose.yml.new && mv docker-compose.yml.new docker-compose.yml && sed -i "s/^LIBRETIME_VERSION=.*/LIBRETIME_VERSION=$N/" .env && echo prepared || echo "STOP - not prepared; run the rollback line"; fi. Run the listener-limit lines from the backup block again, since the download replaced the compose file. Then, with c and A as in restore step 1, if docker compose -f $c pull; then docker compose -f $c stop $A && docker compose -f $c run --rm api libretime-api migrate && docker compose -f $c up -d && echo update-ok || echo "STOP - run the rollback line, then restore steps 1, 3 and 4 from the pre-update set; the restore starts the station"; else echo "STOP - images not pulled, nothing changed on air; run the rollback line"; fi`. Pulling first limits the silence to the migration.

The rollback line is `cd /opt/libretime && test -f .env.old && test -f config.yml.old && grep -q 127.0.0.1:8080:8080 docker-compose.yml.old && cp -p .env.old .env && cp -p config.yml.old config.yml && cp -p docker-compose.yml.old docker-compose.yml && echo rolled-back || echo "STOP - a set-aside copy is missing; if prepare stopped before copying, nothing needs rolling back". After a good update, delete the three .old` files.

Licence terms

LibreTime's LICENSE file is the GNU Affero General Public License, version 3. Our general reading, which is not legal advice, is that running it for your own station owes nobody anything. Distribute copies, modified or verbatim, and the source goes with them; run a modified build that people use over the network, and they are owed its source too.

Questions

Does LibreTime run on Ubuntu 24.04?

Through Docker, yes; the 4.5.0 install script accepts only Ubuntu 20.04 and Debian 11.

Can DJs broadcast from home?

Yes. Any Icecast-capable encoder, BUTT or Mixxx for example, connects to port 8002, mount /show, with the DJ's own login. Without harbor TLS that login is unencrypted, so it should not be reused elsewhere.

Which ports does a LibreTime station open?

8000 for listeners, 8001 and 8002 for live DJs, and 80 and 443 for the web interface through nginx.

Put the station on its own machine

Choose a blade or rack server with a console and an unmetered port, then install LibreTime as above.

See the machines

Related on Server Room

Other use cases