| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Appliance Recipe: Internet Radio Station (Icecast/Liquidsoap)

A kldload appliance that runs multiple internet radio stations from a single server — Icecast for streaming, Liquidsoap for playlist automation and mixing, ZFS for media storage with compression and snapshots. Run 30 stations from one box. What used to take FreeBSD jails now runs natively with per-station ZFS datasets.

What this replaces: Commercial radio automation software (SAM Broadcaster, RadioBOSS, PlayoutONE), multiple VPS instances, FreeBSD jail farms. One server, one OS, unlimited stations.

Internet radio is one of the best kldload appliance use cases because it exercises every ZFS strength at once. A music library is thousands of files that get read sequentially and cached aggressively — ZFS's ARC keeps hot playlists in RAM. FLAC files compress 40-60% with zstd — free disk space. Per-station datasets with quotas prevent one station from eating all the space. Daily snapshots protect against bulk re-taggers that corrupt metadata (every radio operator has this horror story). And Liquidsoap + Icecast is the open-source radio stack that runs actual commercial stations — not a toy, not a hobby project. This recipe runs 30 stations from one box because encoding 30 audio streams uses roughly one CPU core. The bottleneck is listener bandwidth, not processing.

Companion recipes: Plex on ZFS covers media serving for video. This recipe focuses on audio streaming, playlist automation, crossfade mixing, and multi-station management.


Architecture

                      kldload Internet Radio Server

  MEDIA STORAGE              AUTOMATION                   DELIVERY
  ─────────────              ──────────────               ────────

                          ┌─ liquidsoap@station1 ──┐
  /srv/music ────────────┤  (playlist, crossfade,  │
    (ZFS, zstd)           │   scheduled shows,     ├──► Icecast2 ──► Listeners
                          │   fallback/silence det) │    :8000       (web/apps)
  /srv/stations/station1 ─┤                         │
    (per-station media)   ├─ liquidsoap@station2 ──┤    /station1
                          │  (indie rock rotation)  ├──► /station2
  /srv/stations/station2 ─┤                         │    /station3
    (per-station media)   ├─ liquidsoap@station3 ──┤    ...
                          │  (jazz overnight)       │    /station30
  /srv/playlists ─────────┤                         │
    (auto-generated M3U)  ├─ ...                   │
                          │                         │
  /srv/archive ───────────┤─ liquidsoap@station30 ─┘
    (recorded shows)      │  (talk radio archive)
                          │
                          └─ playlist-generator.py ──► cron (hourly)
                               (scan library,
                                group by genre,
                                shuffle + weight)

  MONITORING: Icecast admin (:8000/admin) + liquidsoap telnet (:1234-1263)

Hardware

ComponentExamplesCost
Server (10 stations)Any x86_64, 2+ cores, 4 GB RAM$150-400
Server (30 stations)Any x86_64, 4+ cores, 8 GB RAM$300-800
Storage (music library)1-4 TB SSD or HDD — depends on library size$50-200
Network1 Gbps for ~500 concurrent 128kbps listenersincluded
USB stickkldload installer$5

No GPU required. Liquidsoap and Icecast are CPU-light — encoding 30 simultaneous 128kbps OGG/MP3 streams uses roughly one modern core. The bottleneck is network bandwidth for listeners, not processing power. A single gigabit link supports approximately 500 concurrent listeners at 128kbps, or 250 at 256kbps.


Step 1: Install kldload

cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=radio-server
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NET_METHOD=dhcp
EOF
kldload-install-target --config /tmp/answers.env

Step 2: Install the radio stack

# Icecast streaming server
apt install -y icecast2

# Liquidsoap — playlist automation, mixing, encoding
# The Debian repo version works fine for most setups
apt install -y liquidsoap

# Audio codecs and tools
apt install -y ffmpeg vorbis-tools lame flac

# Metadata and tagging
apt install -y id3v2 python3-mutagen

# Useful extras
apt install -y python3 python3-pip sox libsox-fmt-all

Step 3: ZFS datasets for media

# Main music library — shared across all stations
# zstd compression works well on FLAC/WAV (40-60% savings)
# and still saves 5-10% on MP3/OGG metadata overhead
zfs create -o mountpoint=/srv/music \
           -o compression=zstd \
           -o recordsize=128K \
           -o atime=off \
           rpool/srv/music

# Auto-generated playlists (small text files, compress heavily)
zfs create -o mountpoint=/srv/playlists \
           -o compression=zstd \
           -o atime=off \
           rpool/srv/playlists

# Recorded shows archive
zfs create -o mountpoint=/srv/archive \
           -o compression=zstd-3 \
           -o recordsize=128K \
           -o atime=off \
           rpool/srv/archive

# Per-station datasets — each station gets its own dataset
# with independent quotas, compression stats, and snapshots
for i in $(seq 1 30); do
  zfs create -o mountpoint=/srv/stations/station${i} \
             -o compression=zstd \
             -o recordsize=128K \
             -o atime=off \
             -o quota=50G \
             rpool/srv/stations/station${i}
done

# Snapshot the library daily — accidental deletes, bad taggers, roll back
cat > /etc/cron.d/radio-snapshots << 'EOF'
0 4 * * * root for ds in music playlists archive; do zfs snapshot rpool/srv/${ds}@auto-$(date +\%Y\%m\%d); done
0 4 * * * root for i in $(seq 1 30); do zfs snapshot rpool/srv/stations/station${i}@auto-$(date +\%Y\%m\%d); done
15 4 * * * root for ds in music playlists archive; do zfs list -t snapshot -o name -H rpool/srv/${ds} | grep @auto- | head -n -30 | xargs -r -n1 zfs destroy; done
15 4 * * * root for i in $(seq 1 30); do zfs list -t snapshot -o name -H rpool/srv/stations/station${i} | grep @auto- | head -n -30 | xargs -r -n1 zfs destroy; done
EOF
Per-station ZFS datasets are the key pattern here. Station 1 has a 50GB quota — it can't fill the disk no matter how much content is uploaded. Station 1's snapshots are independent of Station 2's. You can zfs send one station's entire library to another server for a mirror. You can clone a station dataset to test a new playlist structure without risking the live station. And the shared music library (/srv/music) is a separate dataset that all stations read from — one copy of every song, deduplicated by the filesystem, with its own snapshot policy. This is the radio equivalent of the per-movie dataset pattern from the Plex recipe.

Why ZFS is perfect here: Audio files sit on disk and get read sequentially — ZFS's prefetch and ARC cache keep hot playlists in memory. Per-station datasets mean one station's runaway recording can't fill the disk for everyone else (quotas). Snapshots let you roll back the entire library if a bulk re-tagger corrupts metadata. And zstd compression on FLAC/WAV files saves 40-60% of disk space with zero CPU overhead at read time.


Step 4: Configure Icecast

cat > /etc/icecast2/icecast.xml << 'ICECAST'
<icecast>
    <location>Radio Server</location>
    <admin>admin@radio-server.local</admin>

    <limits>
        <clients>1000</clients>
        <sources>60</sources>
        <queue-size>524288</queue-size>
        <client-timeout>30</client-timeout>
        <header-timeout>15</header-timeout>
        <source-timeout>10</source-timeout>
        <burst-on-connect>1</burst-on-connect>
        <burst-size>65535</burst-size>
    </limits>

    <authentication>
        <!-- Change these passwords before going live -->
        <source-password>changeme-source</source-password>
        <relay-password>changeme-relay</relay-password>
        <admin-user>admin</admin-user>
        <admin-password>changeme-admin</admin-password>
    </authentication>

    <hostname>radio-server.local</hostname>

    <listen-socket>
        <port>8000</port>
    </listen-socket>

    <!-- Mount points — one per station -->
    <mount>
        <mount-name>/station1</mount-name>
        <fallback-mount>/silence</fallback-mount>
        <fallback-override>1</fallback-override>
        <max-listeners>200</max-listeners>
    </mount>

    <mount>
        <mount-name>/station2</mount-name>
        <fallback-mount>/silence</fallback-mount>
        <fallback-override>1</fallback-override>
        <max-listeners>200</max-listeners>
    </mount>

    <!-- Repeat for each station... or generate programmatically: -->
    <!-- for i in $(seq 3 30); do
         echo "    <mount>"
         echo "        <mount-name>/station${i}</mount-name>"
         echo "        <fallback-mount>/silence</fallback-mount>"
         echo "        <fallback-override>1</fallback-override>"
         echo "        <max-listeners>200</max-listeners>"
         echo "    </mount>"
         done -->

    <mount>
        <mount-name>/silence</mount-name>
        <max-listeners>10</max-listeners>
    </mount>

    <fileserve>1</fileserve>

    <paths>
        <logdir>/var/log/icecast2</logdir>
        <webroot>/usr/share/icecast2/web</webroot>
        <adminroot>/usr/share/icecast2/admin</adminroot>
        <alias source="/" destination="/status.xsl"/>
    </paths>

    <logging>
        <accesslog>access.log</accesslog>
        <errorlog>error.log</errorlog>
        <loglevel>3</loglevel>
    </logging>
</icecast>
ICECAST

# Enable and start Icecast
systemctl enable --now icecast2

# Verify: http://radio-server:8000 shows the Icecast status page
# Admin: http://radio-server:8000/admin (admin / changeme-admin)

Step 5: Liquidsoap station configs

# Create config directory
mkdir -p /etc/liquidsoap

# Station 1 — full-featured example with all the bells and whistles
cat > /etc/liquidsoap/station1.liq << 'STATION'
#!/usr/bin/liquidsoap

# Station 1 — Classic Rock
# Reads from playlist, crossfades, handles silence, scheduled shows

set("log.file.path", "/var/log/liquidsoap/station1.log")
set("server.telnet", true)
set("server.telnet.port", 1234)

# Main playlist — random rotation from auto-generated M3U
main_playlist = playlist(
  mode="randomize",
  reload=3600,          # Re-read playlist every hour
  reload_mode="rounds", # Finish current round before reloading
  "/srv/playlists/station1.m3u"
)

# Jingles — station ID, bumpers, promos
jingles = playlist(
  mode="randomize",
  "/srv/stations/station1/jingles"
)

# Insert a jingle every 4 tracks
radio = rotate(
  weights=[4, 1],
  [main_playlist, jingles]
)

# Scheduled shows — override the main rotation at specific times
# Saturday night special: 8pm-midnight
saturday_night = playlist(
  mode="randomize",
  "/srv/playlists/station1-saturday.m3u"
)

radio = switch([
  ({ 6w and 20h-23h59m59s }, saturday_night),
  ({ true }, radio)
])

# Crossfade between tracks (5 second overlap)
radio = crossfade(
  duration=5.0,
  fade_in=3.0,
  fade_out=3.0,
  radio
)

# Silence detection — if 5 seconds of silence, skip to next track
radio = skip_blank(
  max_blank=5.0,
  threshold=-40.0,
  radio
)

# Fallback chain: main radio -> emergency jingle loop -> sine wave
emergency = single("/srv/stations/station1/emergency.mp3")
safety = sine(440.0)

radio = fallback(
  track_sensitive=false,
  [radio, emergency, safety]
)

# Normalize audio levels
radio = normalize(radio)

# Output to Icecast — OGG Vorbis at 128kbps
output.icecast(
  %vorbis(quality=0.4),
  host="localhost",
  port=8000,
  password="changeme-source",
  mount="/station1",
  name="Station 1 - Classic Rock",
  genre="Rock",
  description="Classic rock, all day, all night",
  radio
)

# Also output MP3 for compatibility with older players
output.icecast(
  %mp3(bitrate=128),
  host="localhost",
  port=8000,
  password="changeme-source",
  mount="/station1.mp3",
  name="Station 1 - Classic Rock (MP3)",
  genre="Rock",
  description="Classic rock, all day, all night",
  radio
)
STATION

# Make it executable
chmod +x /etc/liquidsoap/station1.liq

Step 6: Auto-generated playlists

cat > /usr/local/bin/playlist-generator.py << 'PYEOF'
#!/usr/bin/env python3
"""
Scan the music library, group by genre/directory, generate M3U playlists
with weighted random shuffle. Avoids repeating tracks within a configurable
window. Runs on cron — regenerates playlists hourly.
"""
import os
import random
import hashlib
from pathlib import Path
from datetime import datetime

MUSIC_ROOT = "/srv/music"
PLAYLIST_DIR = "/srv/playlists"
STATION_DIR = "/srv/stations"
AUDIO_EXTENSIONS = {".mp3", ".ogg", ".flac", ".wav", ".m4a", ".opus"}

# How many tracks before a repeat is allowed
NO_REPEAT_WINDOW = 50

def scan_directory(path):
    """Recursively find all audio files in a directory."""
    tracks = []
    for root, dirs, files in os.walk(path):
        for f in files:
            if Path(f).suffix.lower() in AUDIO_EXTENSIONS:
                tracks.append(os.path.join(root, f))
    return sorted(tracks)

def shuffle_no_repeat(tracks, window=NO_REPEAT_WINDOW):
    """
    Shuffle tracks ensuring no track appears within 'window' positions
    of its previous occurrence. Uses a reservoir approach.
    """
    if len(tracks) <= window:
        random.shuffle(tracks)
        return tracks

    # Seed with current hour so playlists change every regen
    seed = int(datetime.now().strftime("%Y%m%d%H"))
    rng = random.Random(seed)

    result = []
    pool = list(tracks)
    rng.shuffle(pool)
    recent = set()

    while pool:
        # Find a track not in the recent window
        found = False
        for i, track in enumerate(pool):
            track_id = hashlib.md5(track.encode()).hexdigest()[:8]
            if track_id not in recent:
                result.append(track)
                recent.add(track_id)
                if len(recent) > window:
                    # Remove oldest from recent set by rebuilding
                    # (approximation — works well enough for radio)
                    oldest_idx = len(result) - window - 1
                    if oldest_idx >= 0:
                        old_id = hashlib.md5(
                            result[oldest_idx].encode()
                        ).hexdigest()[:8]
                        recent.discard(old_id)
                pool.pop(i)
                found = True
                break

        if not found:
            # All remaining tracks are in the recent window — just append
            result.extend(pool)
            break

    return result

def write_m3u(tracks, output_path):
    """Write an M3U playlist file."""
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w") as f:
        f.write("#EXTM3U\n")
        for track in tracks:
            f.write(f"{track}\n")

def generate_genre_playlists():
    """
    Each subdirectory of MUSIC_ROOT is treated as a genre.
    Generate one playlist per genre.
    """
    if not os.path.exists(MUSIC_ROOT):
        return

    for genre_dir in sorted(os.listdir(MUSIC_ROOT)):
        genre_path = os.path.join(MUSIC_ROOT, genre_dir)
        if not os.path.isdir(genre_path):
            continue

        tracks = scan_directory(genre_path)
        if not tracks:
            continue

        shuffled = shuffle_no_repeat(tracks)
        playlist_name = genre_dir.lower().replace(" ", "-")
        write_m3u(shuffled, f"{PLAYLIST_DIR}/genre-{playlist_name}.m3u")
        print(f"  {genre_dir}: {len(tracks)} tracks")

def generate_station_playlists():
    """
    Each station directory can have its own music.
    Generate a combined playlist from station-specific + shared library tracks.
    """
    if not os.path.exists(STATION_DIR):
        return

    for station_dir in sorted(os.listdir(STATION_DIR)):
        station_path = os.path.join(STATION_DIR, station_dir)
        if not os.path.isdir(station_path):
            continue

        tracks = scan_directory(station_path)
        if not tracks:
            continue

        shuffled = shuffle_no_repeat(tracks)
        write_m3u(shuffled, f"{PLAYLIST_DIR}/{station_dir}.m3u")
        print(f"  {station_dir}: {len(tracks)} tracks")

def generate_master_playlist():
    """Generate one big playlist from the entire library."""
    tracks = scan_directory(MUSIC_ROOT)
    if tracks:
        shuffled = shuffle_no_repeat(tracks)
        write_m3u(shuffled, f"{PLAYLIST_DIR}/all-music.m3u")
        print(f"  master: {len(tracks)} tracks")

if __name__ == "__main__":
    print(f"[{datetime.now()}] Generating playlists...")
    generate_genre_playlists()
    generate_station_playlists()
    generate_master_playlist()
    print("Done.")
PYEOF
chmod +x /usr/local/bin/playlist-generator.py

# Run on first setup
/usr/local/bin/playlist-generator.py

# Regenerate playlists every hour via cron
cat > /etc/cron.d/playlist-generator << 'EOF'
0 * * * * root /usr/local/bin/playlist-generator.py >> /var/log/playlist-generator.log 2>&1
EOF

Step 7: Multi-station setup

# Systemd template unit — one instance per station
cat > /etc/systemd/system/liquidsoap@.service << 'UNIT'
[Unit]
Description=Liquidsoap radio station %i
After=network.target icecast2.service
Requires=icecast2.service

[Service]
Type=simple
ExecStart=/usr/bin/liquidsoap /etc/liquidsoap/%i.liq
Restart=always
RestartSec=5
User=liquidsoap
Group=liquidsoap
# Each station gets its own telnet port (1234 + station number)
Environment=LIQUIDSOAP_TELNET_PORT=0

[Install]
WantedBy=multi-user.target
UNIT

# Create a minimal config for each station
# (copy and customize from the station1 template above)
for i in $(seq 2 30); do
  cat > /etc/liquidsoap/station${i}.liq << STNCFG
#!/usr/bin/liquidsoap
set("log.file.path", "/var/log/liquidsoap/station${i}.log")
set("server.telnet", true)
set("server.telnet.port", $((1233 + i)))

main = playlist(mode="randomize", reload=3600,
  "/srv/playlists/station${i}.m3u")

jingles = playlist(mode="randomize",
  "/srv/stations/station${i}/jingles")

radio = rotate(weights=[4, 1], [main, jingles])
radio = crossfade(duration=5.0, fade_in=3.0, fade_out=3.0, radio)
radio = skip_blank(max_blank=5.0, threshold=-40.0, radio)

emergency = sine(440.0)
radio = fallback(track_sensitive=false, [radio, emergency])
radio = normalize(radio)

output.icecast(%vorbis(quality=0.4),
  host="localhost", port=8000,
  password="changeme-source",
  mount="/station${i}",
  name="Station ${i}",
  radio)
STNCFG
  chmod +x /etc/liquidsoap/station${i}.liq
done

# Create log and jingle directories
mkdir -p /var/log/liquidsoap
for i in $(seq 1 30); do
  mkdir -p /srv/stations/station${i}/jingles
done
chown -R liquidsoap:liquidsoap /var/log/liquidsoap

# Enable and start all 30 stations
systemctl daemon-reload
for i in $(seq 1 30); do
  systemctl enable --now liquidsoap@station${i}
done

# Check status
systemctl list-units 'liquidsoap@*' --no-pager

Step 8: Recording and archiving

# Add recording output to any station's liquidsoap config.
# This records the live stream to the ZFS archive dataset.

# Add these lines to a station's .liq file (e.g., station1.liq):

# Record every show to a date-stamped file
# Files rotate every hour (3600 seconds)
output.file(
  %vorbis(quality=0.6),
  "/srv/archive/station1/%Y-%m-%d/%H-%M.ogg",
  reopen_on_metadata=false,
  reopen_delay=3600.0,
  radio
)

# ZFS snapshots handle the rest — every recording is protected.
# To find a specific show from last Tuesday:
#   ls /srv/archive/station1/2026-03-24/
#
# To recover a deleted recording:
#   ls /srv/archive/.zfs/snapshot/auto-20260324/station1/
#
# To replicate archives to a backup server:
#   zfs send rpool/srv/archive@auto-20260327 | \
#     ssh backup-server zfs recv tank/radio-archive

Step 9: Monitoring

# Icecast admin page — listener counts, mount point status
# http://radio-server:8000/admin
# Shows: active mount points, connected listeners per station,
# source uptime, bytes served, peak listeners

# Quick listener count per station (from the command line)
curl -s -u admin:changeme-admin \
  http://localhost:8000/admin/stats | \
  grep -oP '<source mount="[^"]*">.*?<listeners>\d+' | \
  sed 's/<source mount="//;s/">.*<listeners>/ listeners: /'

# Liquidsoap telnet interface — live control
# Each station has its own telnet port (1234 for station1, 1235 for station2, etc.)
# Connect:
telnet localhost 1234

# Useful telnet commands:
#   help                    — list all commands
#   request.queue           — show upcoming tracks
#   request.push /path.mp3  — queue a specific track next
#   skip                    — skip current track
#   metadata               — show current track metadata
#   uptime                 — how long this station has been running
#   version                — liquidsoap version

# ZFS storage stats per station
zfs list -o name,used,quota,compressratio -r rpool/srv/stations
# Example output:
# NAME                          USED   QUOTA  RATIO
# rpool/srv/stations/station1   12.4G    50G  1.45x
# rpool/srv/stations/station2    8.7G    50G  1.52x
# rpool/srv/stations/station3   23.1G    50G  1.38x

# Overall storage
zfs list -o name,used,compressratio,available rpool/srv

Step 10: Systemd services

# Summary of all services

# Icecast — the streaming server (one instance, all mount points)
systemctl status icecast2

# Liquidsoap — one instance per station (template unit)
systemctl status liquidsoap@station1
systemctl status liquidsoap@station2
# ... through station30

# Start/stop individual stations without affecting others
systemctl stop liquidsoap@station15
systemctl start liquidsoap@station15

# Restart all stations (e.g., after config change)
systemctl restart 'liquidsoap@*'

# View logs
journalctl -u icecast2 -f
journalctl -u liquidsoap@station1 -f

# Boot order: icecast2 starts first, then liquidsoap instances connect to it
# If Icecast restarts, liquidsoap instances auto-reconnect (Restart=always)

Why ZFS matters here

Compression for audio

FLAC and WAV files compress 40-60% with zstd — a 1 TB music library stored in 500 GB of disk space. Even pre-compressed formats like MP3 and OGG see 5-10% savings because zstd compresses the ID3 tags, headers, and metadata padding that every audio file carries. ZFS applies compression transparently — Liquidsoap reads files at full speed from the ARC cache.

Per-station datasets

Each station gets its own ZFS dataset with independent quotas, compression ratios, and snapshot schedules. Station 12 filling up its 50 GB quota does not affect Station 1. You can check per-station disk usage with zfs list, set different compression policies (lossless archives get zstd-9, working directories get zstd), and snapshot each station independently.

Snapshots protect the library

Accidentally delete half the classic rock collection? zfs rollback rpool/srv/music@auto-20260326 — the entire library is back in seconds. A bad metadata tagger corrupts ID3 tags across 10,000 files? Snapshot recovery. A new intern drags the wrong folder to the trash? Snapshots. Daily snapshots cost almost nothing (they only store changed blocks), and they have saved more music libraries than any backup tool.

Dedup for syndicated content

When 30 stations share the same jingles, station IDs, ads, and bumper music, ZFS deduplication stores each unique file block exactly once. Ten stations using the same 30-second sponsor spot? Stored once. This matters most for syndicated content, shared sound effects libraries, and common intro/outro music. Enable with zfs set dedup=on rpool/srv/stations (requires sufficient RAM for the dedup table — roughly 5 GB per TB of data).


Licensing note

Internet radio has specific music licensing requirements that differ from traditional broadcast radio. In the United States:

  • SoundExchange — collects royalties for the digital performance of sound recordings. Required for all internet radio stations playing copyrighted music. Rates are per-listener-per-song.
  • ASCAP, BMI, SESAC — performance rights organizations that license the underlying musical compositions (not the recordings). You typically need licenses from all three.
  • DMCA compliance — internet radio must follow specific rules: no pre-announced playlists, no more than 3 songs from the same album in a row, no more than 4 songs from the same artist in a 3-hour window. Liquidsoap's random rotation mode helps with compliance.
  • Outside the US — licensing varies by country. Many countries have a single collection society (PRS in the UK, GEMA in Germany, SOCAN in Canada). Check your local requirements.
  • Royalty-free and Creative Commons — stations playing only royalty-free, CC-licensed, or original music do not need these licenses. The Free Music Archive and Jamendo are good sources.