Guide / How I built it
How I Built a 24/7 Self-Hosted Internet Radio with Liquidsoap and Icecast on a €5 Server
Running a 24/7 Icecast + Liquidsoap station for years: non-Latin metadata, nginx proxy_pass, and surviving reboots.
Why a radio at all
There is a music scene in Bulgaria — dark wave, post-punk, industrial, thirty years of it — that nobody was archiving. Bands released a cassette in 1994, a CD-R in 2003, a Bandcamp page in 2015, and then the label folded, the site went dark, the drummer moved abroad. The music still existed on somebody's hard drive. It just wasn't anywhere.
So I built a place for it. DWR — Darkwave Radomir — is a 24/7 internet radio station and an archive of that scene: around 4,600 files from roughly 380 artists, every one tagged by hand, playing around the clock. I'm not a programmer. I'm a musician and a video editor who needed a station to exist and couldn't find anyone else who would make it. I built it alone, on a rented server that costs about €5 a month, and I've kept it on the air ever since. You can hear it right now at dwr.ivaylodemidov.info.
This is not a tutorial. It's the story of what the stack is, why it's deliberately boring, and the four things that broke in ways no documentation warned me about — with the exact lines that fixed them. If you're here because one of those four is breaking on your server at this moment, skip straight down. The story will wait.
The stack, and why it's boring on purpose
My first station was, on paper, sensible: a DJ program on a Windows machine doing the playout, a website on shared hosting as the public face, and one WordPress plugin connecting the two. One plugin, written by one person, who at some point simply stopped maintaining it. The host's PHP version kept marching forward, and somewhere ahead on that road was the version my abandoned plugin wouldn't survive. My entire public face was on death row with a date I couldn't see — and nothing to repair, because it wasn't mine to repair.
That gave me the only selection rule I've needed since: software dies when it's abandoned, so depend only on things too big and too boring to abandon. The current stack is the result.
- Ubuntu 24.04 LTS — supported until 2029.
- Icecast 2.4.4 — the tower. One signal in, copied out to every listener. It has distributed streams for over twenty years and has not changed its mind about anything.
- Liquidsoap 2.4.5 — the robot DJ. Watches the folder, reads tags, encodes, feeds the tower. It runs in Docker, from the official
savonet/liquidsoap:v2.4.5image — more on that below. - nginx 1.24.0 — the front door. The only part the public ever touches; it wears the TLS certificate and passes traffic inward.
- systemd — the caretaker. Starts everything at boot, in the right order.
- A Hetzner cloud server — the €5 one. The whole station idles lighter than a browser tab.
What I rejected: the hosted radio platforms (you pay forever and exist at their pleasure), and the one-click self-hosted panel everyone recommends. The panel is a good project. But it wraps this same stack inside an appliance, and when an appliance breaks you don't fix it — you file an issue and wait. My station is about two hundred lines of configuration I can read. When it breaks, I'm the one who repairs it, because I'm the one who built it.
One choice that surprised me: Docker, not a native install. I started out convinced native was simpler. Then I checked what apt install liquidsoap actually gives you on Ubuntu 24.04 — version 2.2.4, a dialect with breaking differences from 2.4 (callbacks moved, cue_cut removed, a rewritten HTTP API). Building 2.4 from source via opam works, but it's slow, memory-hungry, leaves you with no service file, and can stall on a small machine. The maintainers' own .deb is explicitly marked not-for-production. The Docker image is the one route the Liquidsoap authors call production-ready: pinned version, FFmpeg and every codec baked in, zero compilation. It is what my production station runs. That settled it.
The four things that broke
Everything below is from a real rebuild. When I wrote the station up as a book, I rented a second bare server and walked the whole path again from zero — firewall, tower, robot, door, certificate, now-playing page — so that every claim was something I had watched work, not something I remembered. These four cost the most hours. I'm giving them away whole, with the lines, because someone is losing a weekend to them right now.
1. Non-Latin tags (Cyrillic, Greek, Japanese, accented characters) silently kill Liquidsoap metadata
Symptom. The music plays perfectly. But the Icecast status page's "Currently playing" is blank, status-json.xsl has no title field at all, and the Liquidsoap log turns red on every song change:
[charset:3] Trying to convert string over max length of 1024 bytes!
[output.icecast:2] Metadata update may have failed with error: ECONNRESET
My tags are Bulgarian — Cyrillic artist names, Cyrillic titles, and long comment fields with whole biographies in them. But this is not a Bulgarian problem. Anything outside plain ASCII triggers the same path: Greek, Russian, Hebrew, Arabic, Japanese, Korean, or a French é in a long enough field. If your tags are in any language the Latin alphabet wasn't built for, you will meet this.
What I tried that didn't work. Eight hypotheses. I blamed the embedded cover art and stripped it — no change. I added a buffer() a forum recommended — no change, and one variant passed --check cleanly and then died on launch with That source is fallible, which taught me that --check reads grammar, not reality. I raised settings.charset.max_string_length and excluded the comment field from recoding — the charset error went away but the title was still missing. I found a setting that silenced everything: send_icy_metadata=false. It works, by turning the announcement channel off entirely. Trading the disease for a coma.
What actually fixed it. I stopped guessing and asked the system to list its own defaults:
docker run --rm --entrypoint liquidsoap savonet/liquidsoap:v2.4.5 \
--list-settings 2>/dev/null | grep -iE "recode|encoder.metadata"
Two defaults, hiding in plain sight. First, a recode pass that rewrites tag character sets is on by default. For modern UTF-8 tags it's unnecessary — and it's exactly what chokes on long non-Latin fields, and when it chokes it takes the entire metadata update down with it, title included. Second, the default export list — the fields Liquidsoap pushes to Icecast on every song change — includes comment and the embedded picture fields. My robot was shoving a biography and a full-size image through a channel designed for one line of text, every three minutes, and the tower was hanging up mid-sentence. That hang-up is the ECONNRESET.
The fix — two lines at the top of your .liq
settings.request.metadata_decoders.recode := false
settings.encoder.metadata.export := ["artist","title","album"]
The first retires the rewriter. The second trims what goes up the pipe to the three light fields a radio actually announces. The log went clean and the title appeared on the very first track — Cyrillic and all.
One more thing the browser will do to you afterward: Icecast serves non-Latin text in status-json.xsl as HTML entities (Пе…). If your now-playing page sets element.textContent = title, it shows the raw entities. Decode them first:
function decodeEntities(str) {
var t = document.createElement('textarea');
t.innerHTML = str;
return t.value;
}
// element.textContent = decodeEntities(title);
A textarea, not a div — the browser decodes the entities but nothing gets executed. Invisible for Latin-only tags; mandatory for everyone else.
2. nginx proxy_pass with a trailing path returns 404 on Icecast mounts — while the status page works fine
Symptom. You put nginx in front of Icecast. http://your.domain/status-json.xsl loads. http://your.domain/radio — the actual stream — gives 404. You stare at a config that visibly works for one URL and not the other.
My first version of the location block was this:
location /radio {
proxy_pass http://127.0.0.1:8000/radio; # 404
}
When proxy_pass ends with a path, nginx rewrites the request URI before forwarding it. Icecast mounts are sensitive to the exact path they receive, and what arrives after the rewrite isn't the mount it's looking for. The cruel part is the asymmetry: status-json.xsl is a static file and tolerates the rewrite, so half your setup looks correct and you go hunting in the wrong place.
The fix — drop the path from proxy_pass
location /radio {
proxy_pass http://127.0.0.1:8000; # no path: URI passed through intact → 200
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
Without a path, nginx forwards the original /radio untouched and Icecast answers 200.
The other three lines in that block are the ones every "why does my stream stutter behind nginx" thread eventually arrives at. proxy_buffering off matters most: nginx was designed to buffer finite web pages, and a radio stream is infinite — with buffering on it fills a buffer that never ends, spills to disk, and eventually cuts the listener. The long read timeout stops nginx from reaping a listener whose phone went quiet in a tunnel for sixty seconds. Always nginx -t before systemctl reload nginx; the test reads the config without touching the running server.
3. Docker --restart unless-stopped does not survive a reboot — use a systemd unit that knocks first
Symptom. You run Liquidsoap with --restart unless-stopped. You docker kill it to test, and it comes back in three seconds. You go to bed satisfied. Weeks later a kernel update reboots the machine and the radio is silent. Docker is running. Icecast is running. The container is in a crash loop, or dead.
--restart answers exactly one question: what if the container dies while the machine is up? It says nothing about boot order. On a fresh boot Docker may start the robot before the tower is standing — Liquidsoap wakes into a world with no Icecast on port 8000, knocks, finds nobody, and fails. And you can't simply write After=icecast2.service in a unit, because on Ubuntu 24.04 Icecast is still a SysV init script under a compatibility shim (systemctl is-enabled icecast2 tells you so). systemd can start it; it cannot promise the moment it's actually accepting connections. Script launched is not doors open.
The fix — a unit that waits for Icecast to answer, then starts the container
# /etc/systemd/system/radio.service
[Unit]
Description=Radio - Liquidsoap in Docker
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Knock on the tower until it answers (up to 30 tries, one second apart)
ExecStartPre=/bin/bash -c 'for i in $(seq 1 30); do curl -sf -o /dev/null --max-time 2 http://127.0.0.1:8000/status-json.xsl && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/bin/docker start radio
ExecStop=/usr/bin/docker stop radio
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable radio.service
systemctl start radio.service
This assumes the container already exists by name (docker run -d --name radio --restart unless-stopped --network host …). ExecStartPre replaces trust in the schedule with a knock: poll the tower's status endpoint until it responds, then — and only then — docker start radio.
Two things will look wrong and aren't. systemctl status radio.service says Active: active (exited) — for a oneshot unit with RemainAfterExit that is the healthy state: job done, state held, Docker holds the leash from here. And yes, you now have two restart mechanisms. That's a design, not a redundancy: --restart covers the container dying while the machine runs; the unit covers the machine dying and returning in the wrong order. Neither covers both.
The only real test is reboot, and then checking without typing a single manual start. On my rebuild the container came back Up 5 seconds on its own. The music I had playing before the reboot cut out for under a minute and the player reconnected by itself.
4. Icecast /admin returns 404; /admin/ asks for a password
Small, and it cost me a stupid half hour. Behind nginx, https://your.domain/admin — no trailing slash — answers with a bare 404, and it looks like the admin interface isn't being proxied. It is. Type https://your.domain/admin/ with the slash and you get the password prompt. Nothing is broken; the slash is part of the address. If you're checking that your proxy reaches the tower's control room, the prompt is the proof — you don't need to log in.
What the four have in common
None of them is a bug. Every one is a sensible default meeting a use case the default wasn't written for: a character-set rewriter that assumes short Latin tags, a proxy that assumes finite web pages, a restart policy that assumes the rest of the world is already up, a URL convention from 2003. The documentation for each piece is accurate. The trouble lives in the gaps between the pieces, and the gaps only show up when you run the whole chain for real, for months, with real files.
That's the actual lesson from keeping a station on the air for years: the config isn't the hard part. The hard part is knowing what healthy looks like, so that when the log turns red at 3 a.m. you can tell a cosmetic warning from a station that has gone silent while every status page stays green.
Between these four fixes and a station that runs itself for months — firewall, three Icecast passwords, the fallback ladder so you never crash into silence, the certificate that renews itself, a now-playing page that tells the truth, and the drill for rebuilding the whole thing in an hour when your provider folds — there are about ten more chapters of road. I wrote them down, in order, as a weekend: Your Own 24/7 Internet Radio: Live by Sunday Night.