# vim: ft=yaml.docker-compose services: anthias-server: image: ghcr.io/screenly/anthias-server:${DOCKER_TAG}-${DEVICE_TYPE} # A crash must not cost the device its storage. The host's # core_pattern is the kernel default `core`, a relative path, so a # dump lands in the crashing process's working directory, which is # WORKDIR /usr/src/app: inside the container, on no mount. With # core_uses_pid=1 every crash adds a fresh `core.` rather than # replacing one, and nothing sweeps them. They are invisible to # `docker images`, and `docker image prune` cannot touch a live # container's writable layer, so the usual disk investigation comes # back clean while the card fills. A device in the field reached # 24.2 GB of them in anthias-celery (issue 3417). Sentry already # reports crashes, so on a device a dump buys nothing and costs the # card. Anchored here and aliased by every service below: the rule # is "any of our containers", not "the one that happened to crash". ulimits: &no-core-dumps core: 0 build: context: . dockerfile: docker/Dockerfile.server ports: - 80:8080 environment: - MAC_ADDRESS=${MAC_ADDRESS} - HOST_USER=${USER} - HOME=/data - LISTEN=0.0.0.0 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 # HTTP proxy support (GH #3239). upgrade_containers.sh sources # /etc/anthias/proxy.env before envsubst, so these substitute to # the configured proxy (or empty strings when none is set, which # every client treats as "no proxy"). NO_PROXY already includes the # anthias-server/redis service names so internal traffic bypasses # the proxy. - HTTP_PROXY=${HTTP_PROXY} - HTTPS_PROXY=${HTTPS_PROXY} - NO_PROXY=${NO_PROXY} - http_proxy=${http_proxy} - https_proxy=${https_proxy} - no_proxy=${no_proxy} # Chunk size for large browser uploads, for an operator behind a # proxy that caps request bodies. Empty when unset, which the # server reads as "use the default". The durable place to set it # is `upload_chunk_size_mb` in ~/.anthias/anthias.conf, which # wins over this and survives an upgrade; balena devices use a # dashboard variable, which the supervisor injects into every # service. This entry only carries a value exported into the # environment of the run that renders the template. - ANTHIAS_UPLOAD_CHUNK_SIZE_MB=${ANTHIAS_UPLOAD_CHUNK_SIZE_MB} # Gate startup on redis actually answering PING (see the # healthcheck on the redis service below) — plain service_started # only orders container creation, so uvicorn's first Channels / # Celery touch could still race a redis that hadn't finished # loading its RDB. NOTE: docker-compose only; the balena # supervisor doesn't support depends_on conditions, so fleet # devices rely on each consumer's own reconnect logic instead. depends_on: redis: condition: service_healthy # No `devices:` here, deliberately. This service needs no hardware # access at all: HDMI-CEC is driven by the anthias-viewer container # (privileged, so it already sees every /dev/cec*) and reached over # the Redis command bus — see src/anthias_server/lib/cec_client.py. # /dev/vchiq used to be listed for libcec's display-power probe, # which is gone. restart: always volumes: - resin-data:/data - /home/${USER}/.anthias:/data/.anthias - /home/${USER}/anthias_assets:/data/anthias_assets - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro labels: io.balena.features.supervisor-api: '1' logging: driver: journald options: tag: anthias-server anthias-viewer: image: ghcr.io/screenly/anthias-viewer:${DOCKER_TAG}-${DEVICE_TYPE} ulimits: *no-core-dumps build: context: . dockerfile: docker/Dockerfile.viewer mem_limit: ${VIEWER_MEMORY_LIMIT_KB}k # redis: the viewer subscribes to the anthias.viewer pub/sub # channel and publishes the display resolution; wait for a PING # so neither hits "connection refused" at boot. (compose-only — # see the note on anthias-server's depends_on.) depends_on: anthias-server: condition: service_started redis: condition: service_healthy environment: - HOME=/data - PORT=8080 - NOREFRESH=1 - LISTEN=anthias-server # Forward the host's system locale into the viewer container so # AnthiasWebview can send a matching Accept-Language header # (issue 480 — multi-language URL assets used to render in # English regardless of the Pi's locale). upgrade_containers.sh # sources /etc/default/locale before running envsubst, so these # substitute to whatever `update-locale` wrote. When the host has # no locale configured these substitute to empty strings; the # viewer's start_viewer.sh treats an empty value as "unset" so # downstream consumers see the container image's defaults rather # than an empty LANG. - LANG=${LANG} - LANGUAGE=${LANGUAGE} - LC_ALL=${LC_ALL} # HTTP proxy support (GH #3239) — also picked up by QtWebEngine # (Chromium reads http_proxy/https_proxy on Linux) for web assets. - HTTP_PROXY=${HTTP_PROXY} - HTTPS_PROXY=${HTTPS_PROXY} - NO_PROXY=${NO_PROXY} - http_proxy=${http_proxy} - https_proxy=${https_proxy} - no_proxy=${no_proxy} privileged: true restart: always # cage (the wlroots kiosk compositor used on Pi 5 / x86 / # arm64 viewers) doesn't always release the DRM master on # SIGTERM — its event loop blocks on a libinput shutdown # path that hangs on certain kernels, and the kernel's GPU # driver leaves dangling references that prevent the next # container start from acquiring DRM master. ``stop_grace_period`` # caps how long ``docker compose down/restart`` waits before # SIGKILLing the container; 3s is short enough that an # upgrade rolls in a reasonable time even if cage hangs. # ``stop_signal: SIGKILL`` skips the SIGTERM-then-wait dance # entirely on intentional stops — same effect as a kernel # OOM killer landing on the viewer, which the next ``up`` # cleans up from automatically. stop_grace_period: 3s stop_signal: SIGKILL shm_size: ${SHM_SIZE_KB}kb volumes: - resin-data:/data - /home/${USER}/.asoundrc:/data/.asoundrc - /home/${USER}/.anthias:/data/.anthias - /home/${USER}/anthias_assets:/data/anthias_assets - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro labels: io.balena.features.supervisor-api: '1' logging: driver: journald options: tag: anthias-viewer anthias-celery: # Runs on the same image as anthias-server with a CMD override. # Shipping one image instead of two is the point — server and celery # share their entire root filesystem (base apt + venv + app source), # and a separate celery image was duplicating ~825 MB extracted of # identical content per device. See refactor: drop celery image. image: ghcr.io/screenly/anthias-server:${DOCKER_TAG}-${DEVICE_TYPE} ulimits: *no-core-dumps # ``mem_limit`` keeps a runaway celery task (e.g. a HEIC → # WebP convert on a decompression-bomb fixture) from eating # the box's RAM and starving the viewer. 60% of host (computed # in bin/upgrade_containers.sh) leaves comfortable headroom on # every supported SBC. mem_limit: ${CELERY_MEMORY_LIMIT_KB}k # ``nice -n 19 ionice -c 3`` keeps any subprocess celery # spawns (ffprobe, Pillow's libheif binding) at idle priority # so the on-device viewer always wins CPU + IO contention. No # current task is CPU-bound enough that this matters in # practice, but the wrappers are cheap insurance against a # pathological input. command: > nice -n 19 ionice -c 3 celery -A anthias_server.celery_tasks.celery worker -B -n worker@anthias --loglevel=info --scheduler celery.beat.Scheduler # redis is both the broker and the beat heartbeat; gate on a PING # so the worker doesn't burn its first connection retries during # compose startup. (compose-only — see anthias-server's note.) depends_on: anthias-server: condition: service_started redis: condition: service_healthy environment: - HOME=/data - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 # HTTP proxy support (GH #3239) — the celery worker downloads URL # assets (requests, honours these) and shells out to yt-dlp (inherits # the container env). - HTTP_PROXY=${HTTP_PROXY} - HTTPS_PROXY=${HTTPS_PROXY} - NO_PROXY=${NO_PROXY} - http_proxy=${http_proxy} - https_proxy=${https_proxy} - no_proxy=${no_proxy} # See the note on anthias-server: no hardware access needed. Celery # runs the periodic display-power query and the schedule, but it # asks anthias-viewer to do the CEC work rather than opening the # devices itself. restart: always volumes: - resin-data:/data - /home/${USER}/.anthias:/data/.anthias - /home/${USER}/anthias_assets:/data/anthias_assets - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro labels: io.balena.features.supervisor-api: '1' logging: driver: journald options: tag: anthias-celery # Captive-portal WiFi provisioning — Screenly-Labs/wifi-connect, a # ground-up replacement for the balena-os tool Anthias shipped until # 2026.04 (that one had no ARMv6 binary and a `set -e` bug that made # its captive-portal branch unreachable; see "chore: drop wifi-connect # service end-to-end"). Upstream publishes a multi-arch image, so # there is nothing for Anthias to build: the tag is pinned to an exact # CalVer release rather than `2026.10` or `latest` so an upstream patch # can never land on a fleet without a deliberate bump here. anthias-wifi-connect: image: ghcr.io/screenly-labs/wifi-connect:2026.10.13 ulimits: *no-core-dumps # Pinned, because three other things address this container BY # NAME and compose's generated name is `anthias-anthias-wifi- # connect-1`: the systemd unit's `docker start`, the # `docker logs anthias-wifi-connect` in the docs, and the # pre-create in upgrade_containers.sh. Without this the unit fails # with "No such container" and the portal never starts — which is # invisible until a device is actually offline, because every # other code path ends at the ExecCondition skip. # # Also the name the pre-rebrand container used, so journald tags # and operator muscle memory carry over. container_name: anthias-wifi-connect # The documented exception to "never network_mode: host" in # CLAUDE.md, and the only one. That rule exists so host-only facts # (IPs, MACs, interfaces) are routed through host_agent -> Redis # instead of punching a hole in bridge isolation to read them, and # every Anthias service still obeys it. This service is not reading # a host fact: it is *serving DHCP*, an L2 broadcast protocol that # does not survive Docker's DNAT, and pinning that server to the # WiFi interface — so it cannot answer a lease request from the # customer's ethernet LAN — needs an interface that exists in the # namespace. There is no bridge-side equivalent to route through. # # The exception is bounded: nothing Anthias wrote runs in this # namespace. The image is upstream's, built FROM scratch, and # Anthias's own detection of it goes the ordinary way — through # the address resolver the splash page already uses. network_mode: host # The whole flow is NetworkManager calls over the system bus. Root # inside the container is what makes them pass NM's authorization # (NM short-circuits uid 0 before polkit), so no `privileged: true` # is needed — the default capability set already carries # CAP_NET_BIND_SERVICE for udp/53 and udp/67. environment: - DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket # A PREFIX as of wifi-connect 2026.10.6: the portal appends four # random characters, so five screens unboxed in one room do not all # broadcast "Anthias Setup" for a phone to collapse into one entry # and join at random. The /hotspot screen shows the full name, so # there is nothing for an installer to work out. - PORTAL_SSID=Anthias Setup - WIFI_CONNECT_BRAND_NAME=Anthias # Themes the portal the user opens on their phone so it matches the # setup screen on the display in front of them: plum canvas, yellow # Connect, Anthias mark in the header. Role tokens only, with one # documented exception — see the header in the file. - WIFI_CONNECT_BRAND_CSS=/data/hotspot/brand/brand.css - WIFI_CONNECT_BRAND_LOGO=/data/hotspot/brand/logo.svg # Pinned, which also removes the portal's "Switch theme" control. # The plum canvas is the brand; which scheme a two-minute setup page # uses is ours to decide, not a question to put to someone who is # trying to get a screen online. Needs wifi-connect >= 2026.10.2, # where --default-theme is actually read. - WIFI_CONNECT_DEFAULT_THEME=dark # NOT port 80: anthias-server publishes 80:8080 on the host, so # binding the portal there fails outright (upstream reports it as # PortalUnavailable). 8000 matches the port the previous Anthias # integration used. The cost is that an OS connectivity probe, # which always goes to port 80, lands on the Anthias admin UI # instead of the portal; the portal is reached through DHCP option # 114 (RFC 8910), which iOS 14+, Android 11+, macOS and Windows 11 # all honour, and through the address printed on the setup screen. - PORTAL_LISTENING_PORT=8000 # Where the join QR lands for /hotspot to render. resin-data is # mounted at /data in anthias-server too, so the file the portal # writes here is the file the page reads. - WIFI_CONNECT_QR_OUTPUT=/data/hotspot # How the portal tells anthias-server what to put on the screen: # the SSID actually on the air, the per-run passphrase and the # portal's address, in one atomic write. Its PRESENCE is also how # the server knows a portal run is live -- wifi-connect writes it # once it commits to raising the access point and removes it when # the run ends, which the QR files cannot say because they are # written before the portal's own online check. # # Read by anthias_server.lib.wifi_connect; the path is asserted # against that module in tests/test_wifi_connect.py. - WIFI_CONNECT_STATE_FILE=/data/hotspot/state.json # The same QR also goes to stdout as Unicode blocks, which is a # genuine fallback on a plain console but is unreadable once # journald has line-wrapped it, and it is pure noise in # `docker logs`. The SSID is still logged as text; the passphrase # is NOT, as of upstream 2026.10.4 — it says "the passphrase is in # the join QR" and gates the plaintext behind --log-passphrase. # Deliberately left off: /hotspot renders both from the QR payload # on the shared volume, which is the path an operator actually # uses, and a WPA passphrase in journald is worth avoiding. - WIFI_CONNECT_NO_QR_STDOUT=true # Bring the portal down after 15 minutes with no successful join # and exit. This is the safety valve: if NetworkManager ever # misreports a working network as offline (its connectivity check # needs to reach network-test.debian.org, which a filtered # corporate LAN may block), the portal has taken a working device # off the air. A finite timeout bounds that to one window instead # of forever. # IDLE, not a cap on the session, as of wifi-connect 2026.10.9: # the clock restarts whenever someone loads a page or submits a # join, so an installer typing a passphrase at minute 14 is not # cut off -- which used to cost them the credentials too, since # the next run generates a new passphrase and a new SSID suffix. # Captive-portal probes do not count, so a phone left associated # cannot hold a screen in setup mode. - ACTIVITY_TIMEOUT=900 # Deliberately NOT `restart: always`. wifi-connect is a one-shot: # it exits 0 the moment it finds the device online, so a restart # policy turns into a container start every 60s forever, and — far # worse — it would re-raise the portal every 15 minutes on a device # whose connectivity check is blocked, which is an outage that never # ends. One run per boot is upstream's own systemd semantic; # anthias-wifi-connect.service (ansible network role) is what # starts that run on each boot. restart: "no" # The safety gate lives in anthias-wifi-connect.service's # ExecCondition, and a plain `docker compose up -d` would walk # straight past it: `restart: "no"` governs restart-on-exit, not # the initial start. Without this profile, every run of # upgrade_containers.sh would raise an access point on a device # that is online — losing a WiFi-connected screen its network for # the length of a portal session, on every upgrade. # # A profiled service is skipped by `up -d` entirely: not started, # not created, and NOT removed by `--remove-orphans` once it # exists (verified against docker compose v5.6). So # upgrade_containers.sh creates it stopped, by name, and the # systemd unit is the only thing that ever starts it. profiles: - wifi-connect volumes: # Carries the QR, the state file AND the portal's branding: the # branding is staged here by anthias-server's startup script, so # there is nothing host-side to install and both platforms get the # same portal. - resin-data:/data - type: bind source: /run/dbus/system_bus_socket target: /run/dbus/system_bus_socket logging: driver: journald options: tag: anthias-wifi-connect redis: image: ghcr.io/screenly/anthias-redis:${DOCKER_TAG}-${DEVICE_TYPE} ulimits: *no-core-dumps build: context: . dockerfile: docker/Dockerfile.redis ports: - 127.0.0.1:6379:6379 # Lets the depends_on conditions above hold service startup until # redis actually answers, covering the RDB-load window after a # restart on a slow SD card. start_period keeps the early probes # from counting against the retry budget. healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 12 start_period: 10s restart: always volumes: - redis-data:/var/lib/redis logging: driver: journald options: tag: anthias-redis volumes: resin-data: redis-data: