weavesignal/deploy/cloudron/README.md
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

21 KiB
Raw Permalink Blame History

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, answers noindex, refuses PUT, icecast.server's admin pages and /status.json, and drops Icy-MetaData from requests (bench 02's trap).
  • The listener stream asks for a password by default (LISTENERS_AUTH=on in /app/data/weavesignal.env; the user is listener, the password LISTENERS_PASSWORD in /app/data/secrets.env). off leaves it open but unlisted.
  • optionalSso with proxyAuth: installed with Cloudron's user management, the station's own domain sits behind Cloudron's login; a Bearer token still reaches the admin routes (supportsBearerAuth). Cloudron's login does not cover the httpPorts domains: 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-For that 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.json and /feed; 403 for PUT); 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), /health with 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, nofollow on a page, the invite page, a public route, a feed and a station route, and /robots.txt disallowing /;
  • the two email keys added to a secrets.env that 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 To and From;
  • 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's oom_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 10 still 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 ListParticipants at info, two a second. Cloudron rotates logs; the scripts' log level is in m1'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, with NODE_IP in weavesignal.env as 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).
  • proxyAuth with supportsBearerAuth: 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 its path is !/health (ADR-0160), which Cloudron 10.1 applied on an update of an installed app (checked on a test Cloudron and on staging: /health 200 with no bearer, everything else 302). The exclusion is a prefix (/healthz also passes the login and gets this nginx's 404), harmless while the station serves only location = /health. SEARCH_LISTING=true was rendered and passed nginx -t, but no listed station has run.
  • That cloudron update from 0.1.1 adds the new PUBLIC_DOMAIN httpPort and the sendmail addon 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, and start.sh says so in the log).
  • The real relay's authentication: Cloudron's mail server with msmtp's auth plain and tls off, and that CLOUDRON_MAIL_FROM is accepted as the sender.
  • That Cloudron's proxy appends to X-Forwarded-For from 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 and curl can. Set LISTENERS_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: false is honoured for udpPorts (it is documented for TCP).
  • An hours-long soak: icecast.server grew 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.

  1. Build and check it: ./test-local.sh (it builds localhost/weavesignal-cloudron:<version>-local).
  2. Tag and push it to the project's registry: podman tag localhost/weavesignal-cloudron:<version>-local forgejo.weavesignal.top/weavesignal/weavesignal-cloudron:<version>, then podman push it.
  3. 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> (or cloudron update --server <server> --app <app domain> --image …).
  4. After the first install, in Cloudron's dashboard: give the Public pages domain a location if Cloudron did not, check that Mail (the sendmail addon) 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.