| Filename | Latest commit message | Latest commit date |
|---|---|---|
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md. |
||
| .. | ||
| CHANGELOG.md | ||
| CloudronManifest.json | ||
| DESCRIPTION.md | ||
| Dockerfile | ||
| logo.png | ||
| memory-gate.sh | ||
| POSTINSTALL.md | ||
| README.md | ||
| start.sh | ||
| supervisord.conf | ||
| test-local.sh | ||
The Cloudron package
One Cloudron app holds release one's server (ADR-0022): the control plane, the chat, the desk,
Liquidsoap 2.5 playout, the listener process with icecast.server, LiveKit with its built-in
TURN, the station's public pages, and nginx in front of them, all run by supervisord as the
cloudron user. PostgreSQL is Cloudron's addon (ADR-0082); the chat's mail is Cloudron's
sendmail addon. It is built for amd64 only.
The manifest's version is the package's; what each version changed, and which commit it
carries, is in CHANGELOG.md.
| File | What it is |
|---|---|
Dockerfile |
Built from the repository's root. Every Rust and runtime stage is cloudron/base 6.0.1 (Ubuntu 26.04): GStreamer 1.28.2 from Ubuntu, gst-plugins-rs 0.15.4+fixup's webrtc plugin (LiveKit) built against it, libsrt 1.5.7 from source in place of Ubuntu's 1.5.4, Liquidsoap 2.5.0-rc1 from upstream's Ubuntu 26.04 package (it carries libsrt 1.5.7), LiveKit 1.13.10's binary, msmtp from Ubuntu. The public pages (apps/web) are built in a Node 24 stage with pnpm 10.30.2. Sources are pinned by hash, except pnpm itself (by version; the pages' dependencies by its lockfile). |
CloudronManifest.json |
Ports, domains, addons, the memory limit. The id is top.weavesignal.cloudronapp since 1.0.0. |
CHANGELOG.md |
What each package version changed. |
start.sh |
Writes every service's settings from Cloudron's environment on each start, appends settings new to this version to weavesignal.env, generates the station identifier and secrets on the first start, and checks the control plane's settings and ports before supervisord starts. |
supervisord.conf |
The seven services. |
test-local.sh |
The package run locally without a Cloudron, with checks, a room test, a restart test and cold starts. |
memory-gate.sh |
The local proxy for the idle-versus-production memory gate. |
How the processes are wired
The same as deploy/compose/compose.yaml, inside one container:
| Program | Listens (loopback unless said) | Talks to |
|---|---|---|
livekit |
7880 (signalling), the media port and TURN over TLS on every address | — |
desk |
SRT 7313/udp; its control socket /run/weavesignal/desk.sock |
LiveKit; reads /app/data/carts |
playout (weavesignal.liq) |
its control socket playout.sock |
the desk's SRT (caller, every second); the listener process's feed |
listeners (listeners.liq) |
7312 (icecast.server, /status.json, and 7313/tcp beside it), 7315 (the feed); listeners.sock |
writes the legal logger into /app/data/recordings/logger |
control |
7310 | PostgreSQL; the three sockets; /status.json; LiveKit's API |
chat |
7311 | PostgreSQL; the control plane's studio view |
nginx |
8000, 8001, 8002, 8003 on every address | the services above |
Playout sends the programme to the listener process as PCM in WAV (M4); the listener process
holds the mount, plays its own fallback while the feed is away, and encodes the one Ogg Opus
stream listeners hear, so a playout or desk restart does not disconnect anyone. The desk now
starts with the rest: 0.1.x's desk-start.sh waited for the mount because playout's switch to
the desk began a chained Ogg stream on the mount, and that switch no longer reaches it.
Surfaces
| Surface | Manifest | Inside the container |
|---|---|---|
The station: /health, /api/ (the studio's routes and the chat's), the pages, feeds, cards |
httpPort 8000 |
nginx → control plane 7310, chat 7311 (/api/v1/chat/) |
The listener stream, /programme.opus only |
httpPorts.LISTENERS_DOMAIN → 8001 |
nginx → icecast.server 7312 |
| LiveKit's signalling | httpPorts.ROOM_DOMAIN → 8002 |
nginx → LiveKit 7880 |
The public pages: the player with call-in and the chat, /join/, /echo-test/, /feeds/, /cards/, /media/episodes/, and only their public routes |
httpPorts.PUBLIC_DOMAIN → 8003 |
nginx → the pages, control plane 7310, chat 7311 |
| Room media | udpPorts.LIVEKIT_MEDIA_PORT, 7882 |
LiveKit's one UDP port (ADR-0023) |
| TURN over TLS | tcpPorts.TURN_TLS_PORT, 15349 |
LiveKit's TURN, with the tls addon's certificate (ADR-0059, ADR-0106: Cloudron's own TURN holds 5349) |
| TURN over UDP (off by default) | udpPorts.TURN_UDP_PORT, 3479 |
LiveKit's TURN |
| ICE over TCP (off by default) | tcpPorts.LIVEKIT_TCP_PORT, 7881 |
LiveKit |
The public ports have no containerPort, so Cloudron maps each to the same number inside the
container, and start.sh gives LiveKit the number the administrator chose: LiveKit advertises the
port it binds. The SRT link, the feed, icecast.server's /status.json (ADR-0115), the control
plane, the chat, LiveKit's API and the control sockets stay on loopback inside the container
(ADR-0048 rule 3, ADR-0049, ADR-0086). The one socket on every address that is not a published
port is playout's SRT caller's ephemeral UDP port, an outgoing connection's.
Cloudron's own TURN service holds 3478, 5349 and 50000–51000/udp on the server (reported on Cloudron's forum, not measured here). Where it runs, choose 15349 for TURN over TLS, and keep the media port out of 50000–51000.
Why a public domain
proxyAuth puts the station's own domain behind Cloudron's login when the app uses Cloudron's
users, and the addon cannot be removed or narrowed after install. Chatters, callers, invited
guests, podcast apps and anyone reading a show card have no Cloudron account, so their routes
are served on a domain of their own, as the listener stream and the room already were. The
public domain serves the built pages and an allow-list: the chat's info, socket, signin,
guest, notices, me, signout, reports and statement reviews; the control plane's
station, player, call-in, pass routes and now playing; and GET on feeds, cards and
episode files. Everything else under /api/ is 404 there, even with the bearer: the studio,
the chat's moderation and admin routes, retention, carts, /health. Cards and feeds print
https:// and the public domain (publishing.public_url); without one they fall back to the
app's origin, which a podcast app cannot pass when the login is on, and start.sh says so.
PUBLISHING_URL in weavesignal.env overrides both.
Every surface still answers noindex while the station is private (ADR-0078).
Privacy and access
- The stream is published with
public=false(playout/listeners.liq): it asks no directory to list it. The listener domain serves only the mount, answersnoindex, refusesPUT,icecast.server's admin pages and/status.json, and dropsIcy-MetaDatafrom requests (bench 02's trap). - The listener stream asks for a password by default (
LISTENERS_AUTH=onin/app/data/weavesignal.env; the user islistener, the passwordLISTENERS_PASSWORDin/app/data/secrets.env).offleaves it open but unlisted. optionalSsowithproxyAuth: installed with Cloudron's user management, the station's own domain sits behind Cloudron's login; aBearertoken still reaches the admin routes (supportsBearerAuth). Cloudron's login does not cover thehttpPortsdomains: the room is guarded by short-lived room tokens (ADR-0085), the stream by its password, the public domain by serving only public routes.- The client's address: nginx takes the rightmost address in
X-Forwarded-Forthat is not private (Cloudron's proxy appends the one it saw) and gives each service that one address; the control plane (join.forwarded_for, call-in's per-address limit) and the chat (listen.forwarded_for) read it. Under No retention the chat stores no address (ADR-0115).
The chat's mail
The chat sends sign-in codes by piping each email to a sendmail-compatible program (MAIL_MODE
command); it has no SMTP client. Cloudron's sendmail addon gives an SMTP relay, so the image
carries msmtp, and start.sh writes /run/weavesignal/msmtprc (0600, never backed up) from
CLOUDRON_MAIL_* on each start: PLAIN authentication without TLS on the relay's port inside the
server (msmtp picks no plain-text method by itself without TLS), and CLOUDRON_MAIL_FROM as
the sender. MAIL_MODE in weavesignal.env: cloudron (the default; the outbox when the addon
gives no server), outbox (files in /app/data/chat-outbox), or off.
The station's files
All under /app/data, so they are backed up: carts (the media worker's FLAC; the desk and
playout read it), library, fallback (played by playout and by the listener process while
the programme is away; empty, they play silence), recordings (retention's directory; the
legal logger writes recordings/logger and the sweeper deletes there, ADR-0113), show-cards,
episodes, chat-outbox. The cart demo pack is in the image (/app/code/carts-demo).
Migrations (now 0001–0007) run at every start, by the control plane, before it listens.
Settings this version added (CHAT_PORT, PLAYOUT_FEED_PORT, MAIL_MODE, PUBLISHING_URL,
PUBLISHING_PLACE, PUBLISHING_ZONE) are appended to an existing weavesignal.env with their
defaults on the first start of 0.2.0; the administrator's lines are left as they are.
Build and run locally
podman build -t localhost/weavesignal-cloudron:0.2.0-local -f deploy/cloudron/Dockerfile .
deploy/cloudron/test-local.sh # needs localhost/weavesignal-desk-dev:m1 for the tone guest
deploy/cloudron/memory-gate.sh # then, with the app still running
deploy/cloudron/test-local.sh cold 3 # three cold starts, each with the room test
deploy/cloudron/test-local.sh down
test-local.sh runs the image read-only with tmpfs /run and /tmp, a volume for
/app/data, the manifest's memory limit, PostgreSQL 16 on a private network for the addon, a
small SMTP sink for the sendmail addon, and a self-signed certificate in /etc/certs. Its
containers are named weavesignal-cloudron-local-* and its host ports are 27400–27403, 27449/tcp
and 27482/udp (each a setting; it refuses a port already in use). It checks:
- health and the database; the listener domain (401 without the password; 404 for
/, the admin pages,/status.jsonand/feed; 403 forPUT); a late listener decoding Opus with no ICY metadata; - that nothing listens off loopback but nginx and LiveKit's public ports (and playout's SRT caller), and that 7310, 7311, 7312 and 7315 are on loopback;
- the public domain: the three pages, the public routes (200), the studio's, the chat's admin,
retention's, carts' and staff routes (404 even with the bearer),
/healthwith no bearer (200, ADR-0160),/feeds/reaching the control plane; the chat's admin route on the station domain with the bearer; - unlisted (ADR-0133): exactly one
X-Robots-Tag: noindex, nofollowon a page, the invite page, a public route, a feed and a station route, and/robots.txtdisallowing/; - the two email keys added to a
secrets.envthat lacked them, as a station installed before 0.8.0 has, and the chat starting with its key; - the default loop (ADR-0130) on the stream while the room is empty (not silence, not the tone);
- with
COMPAT=true(the main run; cold starts leave it off, as installed), the MP3 stream: 401 without the password, MP3 at 44.1 kHz, no ICY metadata; - the chat's sign-in email through msmtp to the SMTP sink, authenticated, with the right
ToandFrom; - the room test: a guest publishing a 1 kHz tone through the room domain with a publish-only token (ADR-0090), and the tone in the listener stream;
- the restart test: a listener connected for 110 s while playout is killed by its PID at 45 s; one connection, a clean decode, at most one gap with the programme back after it, the listener process untouched (since 0.8.0 the listener process decodes the feed natively and playout is back in about 0.3 s: one run heard a single 100 ms dropout, the next none);
- the faults in the log (
non-monotonic DTS,Generator max buffered length exceeded,EPIPE) and the cgroup'soom_kill.
The guest runs in the app's network namespace: rootless Podman does not carry WebRTC's UDP into the container as Docker's published ports do, so the port mapping, TURN and LiveKit's public address are checked on a real Cloudron.
What the local checks show (2026-10-26)
Every check above passed on the final image (localhost/weavesignal-cloudron:0.2.0-local).
Three cold starts of three, each from a fresh volume (the first start migrates an empty
database; the start measured is the restart that follows, every process cold):
| Cold start | Health with the database | The listener mount answers | The room test | Faults logged, oom_kill |
|---|---|---|---|---|
| 1 | 1.7 s | 17.3 s | the tone heard 20 s into the recording, −10.5 dBFS, 100 % at 1 kHz | none, 0 |
| 2 | 1.5 s | 17.0 s | the same, −10.6 dBFS | none, 0 |
| 3 | 1.5 s | 17.0 s | the same, −10.5 dBFS | none, 0 |
With the desk started beside the rest, no run logged non-monotonic DTS, Generator max buffered length exceeded or EPIPE (0.1.x needed desk-start.sh to reach that). Two earlier
runs of the full test and three earlier cold starts, on images differing only in the scripts'
details, gave the same figures.
The restart test: playout was killed 45.5 s into a listener's connection and supervisord had it running again 0.1 s later; the listener stayed on one connection for the whole 110 s (1,315,556 bytes, 108.8 s decoded, no decoder errors), heard one 1.6 s gap in the tone, the listener process's fallback, and then the programme again. The listener process kept its PID. In 0021 (M4, the bare stack) the programme came back 5.6 s after playout was stopped, 5 s of it the stop itself; here the kill is instant. A new listener hears the programme about 20 s behind live (the tone heard from 20 s into the recording), as 0021 measured.
Two things seen and not fixed here:
- Stopping the container takes about 10.5 s: supervisord stops the programs by priority,
and the two Liquidsoaps take 2–6 s each to exit. LiveKit drained its rooms past
supervisord's 10 s every time, so it now has 3 s (
stopwaitsecs);podman stop -t 10still ended in SIGKILL at 10.3–10.9 s. Nothing is lost but the last partial page of a logger hour; Cloudron's own stop timeout is to be seen on the server. - The log is busy: the listener process and playout log every control-socket connection
(the control plane polls them every 250 ms), and LiveKit logs each
ListParticipantsat info, two a second. Cloudron rotates logs; the scripts' log level is inm1's fixed scripts.
The memory limit
0.8.0 (2026-11-02): the limit stays 1.5 GiB. memory-gate.sh on the 0.8.0 image, with
the MP3 stream on (a second encoder in the listener process) and the processing chain on:
idle 526 MiB current, 527 MiB peak; loaded (300 s) peak 699 MiB, 45 % of the limit;
oom_kill 0 throughout. Loaded RSS: listener process 334 MB, playout 198 MB, LiveKit 83 MB,
desk 62 MB, control 38 MB, chat 37 MB. The labs Cloudron, at rest after the update, read
355 MiB peak in the app's cgroup. The chat's share is understated: since its per-address
limits (0.4.1) the load generator's 1,000 sockets, all from one address, are mostly closed
(958 of them), so the chat held about 40 readers; 0020 measured 97 MiB resident for 4,000
sockets, which the headroom covers (about 800 MiB, 52 %). The load generator needs addresses
of its own before the next gate.
The figures below are 0.2.0's.
1.5 GiB (memoryLimit 1610612736), unchanged. Measured locally with memory-gate.sh on
2026-10-26, cgroup v2, read-only root, PostgreSQL outside the container as Cloudron's addon is:
| Invariant | Idle (60 s after the full test) | Loaded (300 s) |
|---|---|---|
memory.current / peak |
660 MiB / 663 MiB | 908 MiB / 909 MiB (59 % of 1.5 GiB) |
oom_kill |
0 | 0 |
| Per process (RSS) | listener process 271 MB, playout 254 MB, desk 53 MB, LiveKit 52 MB, supervisord 28 MB, control 11 MB, chat 11 MB, nginx 8 MB | listener process 339 MB, playout 280 MB, chat 105 MB, LiveKit 70 MB, desk 54 MB, nginx 2 × 15 MB, control 13 MB |
| Verdict | PASS: peak 59 % of the limit |
The load: 3 tone publishers through LiveKit and the desk (a presenter and two guests; a caller's token now needs consent, M3), 50 listeners through nginx for 300 s (3.6 MB each, the constant 96 kbit/s), one SSE client on the studio events (3.1 MB), and the chat's own load generator through nginx three times over: 1,000 sockets each (10 never reading, 50 reconnecting), 8 posters at 7, 20 and 60 messages a second; every message acknowledged (p99 21–62 ms) and delivered, every reconnect recovered.
The gate's rule (peak ÷ 0.7) gives 1.27 GiB. The limit stays at 1.5 GiB because the listener
process grows under listeners (271 to 339 MB here) and icecast.server grew at rest in 0.1.x (about 1.5 MB a minute), a cold
start without the script cache needs about 1.3 GB, the chat was measured at 97 MiB resident
for 4,000 sockets (0020) beside 1,000 here, the load was gentle beside 2,000 listeners (who
reach the station through edges or a CDN, ADR-0051, not this container), and video is not in
the image yet. A test install repeats the gate on a Cloudron itself, over hours.
Not tested here, for the server
- Docker's published UDP and TCP ports, LiveKit's public address (
use_external_ip, withNODE_IPinweavesignal.envas the override) and TURN over TLS from outside. - TURN relays: LiveKit's TURN allocates relay ports (30000–40000) on its node address; behind Docker's bridge the server's own traffic to them may not come back in (M3's measurement).
proxyAuthwithsupportsBearerAuth: that a Bearer request passes Cloudron's login, and what the studio app's requests carry. The addon cannot be added or removed after install. Since 0.8.0 itspathis!/health(ADR-0160), which Cloudron 10.1 applied on an update of an installed app (checked on a test Cloudron and on staging:/health200 with no bearer, everything else 302). The exclusion is a prefix (/healthzalso passes the login and gets this nginx's 404), harmless while the station serves onlylocation = /health.SEARCH_LISTING=truewas rendered and passednginx -t, but no listed station has run.- That
cloudron updatefrom 0.1.1 adds the newPUBLIC_DOMAINhttpPort and thesendmailaddon to the installed app (if it does not, cards and feeds fall back to the app's origin and the chat's codes to the outbox, andstart.shsays so in the log). - The real relay's authentication: Cloudron's mail server with msmtp's
auth plainandtls off, and thatCLOUDRON_MAIL_FROMis accepted as the sender. - That Cloudron's proxy appends to
X-Forwarded-Forfrom a private address, as nginx's real-address rule here assumes; and its upload size limit for library tracks (200 MB). - The player page's stream in a browser. With
LISTENERS_AUTH=on(off by default since 1.0.0) the page's stream address is on the password-protected listener domain, and a browser does not ask for a password for audio another domain serves, so the page will not play it; players andcurlcan. SetLISTENERS_AUTH=off(open but unlisted) to hear it in the page. No local check plays the page in a browser; the pages' scripts and styles are checked to be in the image. - Whether
enabledByDefault: falseis honoured forudpPorts(it is documented for TCP). - An hours-long soak:
icecast.servergrew slowly at rest in 0.1.x; the legal logger's hourly files under retention's sweeper.
Publish a version
The image is built here and pushed to the public registry; Cloudron installs it by its tag.
- Build and check it:
./test-local.sh(it buildslocalhost/weavesignal-cloudron:<version>-local). - Tag and push it to the project's registry:
podman tag localhost/weavesignal-cloudron:<version>-local forgejo.weavesignal.top/weavesignal/weavesignal-cloudron:<version>, thenpodman pushit. - Install or update with the server named explicitly, from this folder (where the manifest is):
cloudron install --server <server> --location <app domain> --image forgejo.weavesignal.top/weavesignal/weavesignal-cloudron:<version>(orcloudron update --server <server> --app <app domain> --image …). - After the first install, in Cloudron's dashboard: give the Public pages domain a location if
Cloudron did not, check that Mail (the
sendmailaddon) is listed for the app, open the public domain's player page, and read the start line in the app's log (public pages …; cards and feeds …; chat mail command).
The Dockerfile uses BuildKit cache mounts and needs the repository's root as its context, so it
is not built by an on-server cloudron install or cloudron update without --image.