Self-hosted digital signage server for small deployments — homes, studios, shops, offices, dashboards. Runs on a Raspberry Pi or any Linux host, serves display URLs for one or more screens, and gives operators a web control panel from a phone or laptop. Built with Phoenix LiveView, SQLite, Oban, and Tailwind CSS.
- Media library for image/video uploads with HEIC/HEVC transcoding.
- Drag-and-drop playlists and a scene editor (dashboard grids and fullscreen layouts).
- Widgets: clock, weather, RSS/Atom feeds, slideshows, public Instagram posts.
- Public display URLs at
/d/:display_id; password-protected control panel at/c. - Live updates via Phoenix PubSub — displays refresh without a page reload.
- Scene override with
?scene=NAMEon any display URL. - Non-persistent weather preview override on
/d/previewfor visual inspection. - Kiosk Browser controls (wake, sleep, reload, restart) and built-in DB + media backups.
- Multi-language support (English and German). Backend language is set globally in Settings; each display can have its own locale for widget text.
- Elixir 1.18 and Erlang/OTP 25+ (pinned in
.tool-versions) - Node.js 22+ (Vite 8 needs Node 20.19+ or 22.12+)
- SQLite 3,
ffmpeg,libvips
The Docker workflows bundle the system dependencies.
Elixir/Erlang are pinned with asdf. On Debian/Ubuntu, the helper script installs build deps, asdf, the pinned versions, and compiles:
./setup-erlang.shWith asdf already installed, run asdf install (reads .tool-versions). asdf must
be on your shell PATH; in non-interactive shells source it explicitly:
. "$HOME/.asdf/asdf.sh"mix setup
mix phx.server- Control panel: http://localhost:4000/c
- Display view: http://localhost:4000/d/default
On first visit to /c you are redirected to /login. In development you set the
backend password there (the gate protects /c and the landing page /; display
URLs stay public). The password must be at least 12 characters.
For display-side visual tuning, you can force the weather widget into a temporary preview state without changing cached weather data or saved widget config:
/d/preview?scene=<scene-name>&weather_cond=cloudy
/d/preview?scene=<scene-name>&weather_cond=cloudy&weather_tod=day
sceneselects the scene by name.weather_condsupportsclear,partly,cloudy,fog,drizzle,rain,showers,snow, andthunder.weather_todis optional and supportsday,dawn,dusk, andnight.
This override only affects the rendered preview URL. It does not persist any changes to the widget instance or fetched provider data.
Production fails closed — the app refuses to boot until secrets are provisioned. Set these (or provide the persisted files noted in the table below):
SECRET_KEY_BASE=$(mix phx.gen.secret) # required
KAKEMONO_API_SECRET=<long random value> # required — displays authenticate with it
KAKEMONO_BACKEND_PASSWORD=<≥12 chars> # required — seeds the backend password on boot
PHX_HOST=signage.example.com # your real hostnameRun behind an HTTPS-terminating reverse proxy. In production the app forces SSL,
emits HSTS, marks the session cookie Secure, and restricts the LiveView websocket
origin to https://$PHX_HOST. The proxy must terminate TLS and forward
X-Forwarded-Proto. Anonymous web password setup is disabled in production, so the
backend password can only come from KAKEMONO_BACKEND_PASSWORD or an existing hash
file. Change it later from /c/settings.
Local development image:
./.docker/build.sh
./kdev mix setup
./kdev mix phx.serverDocker Compose (published release image):
services:
app:
image: sedrad/kakemono:latest
container_name: kakemono
restart: unless-stopped
ports:
- "${PORT:-4000}:${PORT:-4000}"
environment:
KAKEMONO_DATA_DIR: ${KAKEMONO_DATA_DIR:-/data}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env}
KAKEMONO_API_SECRET: ${KAKEMONO_API_SECRET:?set KAKEMONO_API_SECRET in .env}
KAKEMONO_BACKEND_PASSWORD: ${KAKEMONO_BACKEND_PASSWORD:?set KAKEMONO_BACKEND_PASSWORD in .env}
PHX_HOST: ${PHX_HOST:-localhost}
PHX_SERVER: "true"
PORT: ${PORT:-4000}
volumes:
- ./data:/dataExample .env values:
PORT=4000
PHX_HOST=signage.example.com
SECRET_KEY_BASE=replace-with-output-from-mix-phx.gen.secret
KAKEMONO_API_SECRET=replace-with-a-long-random-secret
KAKEMONO_BACKEND_PASSWORD=replace-with-a-strong-password-min-12-chars
KAKEMONO_DATA_DIR=/dataDocker Compose with Traefik 3.7 TLS proxy:
services:
traefik:
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Only needed if Traefik itself is behind another trusted proxy:
# - "--entrypoints.websecure.forwardedHeaders.trustedIPs=${TRAEFIK_TRUSTED_IPS}"
- "--certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-letsencrypt:/letsencrypt
app:
image: sedrad/kakemono:latest
container_name: kakemono
restart: unless-stopped
environment:
KAKEMONO_DATA_DIR: ${KAKEMONO_DATA_DIR:-/data}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env}
KAKEMONO_API_SECRET: ${KAKEMONO_API_SECRET:?set KAKEMONO_API_SECRET in .env}
KAKEMONO_BACKEND_PASSWORD: ${KAKEMONO_BACKEND_PASSWORD:?set KAKEMONO_BACKEND_PASSWORD in .env}
PHX_HOST: ${PHX_HOST:?set PHX_HOST in .env}
PHX_SERVER: "true"
PORT: "4000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.kakemono.rule=Host(`${PHX_HOST:?set PHX_HOST in .env}`)"
- "traefik.http.routers.kakemono.entrypoints=websecure"
- "traefik.http.routers.kakemono.tls.certresolver=letsencrypt"
- "traefik.http.services.kakemono.loadbalancer.server.port=4000"
- "traefik.http.middlewares.kakemono-forwarded-proto.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.kakemono.middlewares=kakemono-forwarded-proto"
volumes:
- ./data:/data
volumes:
traefik-letsencrypt:Additional .env values for Traefik:
TRAEFIK_ACME_EMAIL=admin@example.com
TRAEFIK_TRUSTED_IPS=203.0.113.10/32TRAEFIK_TRUSTED_IPS is only needed when you uncomment the
forwardedHeaders.trustedIPs line because Traefik is behind another trusted
proxy.
Traefik forwards X-Forwarded-* request headers to the container by default; the
middleware above makes X-Forwarded-Proto: https explicit for Phoenix SSL
rewrite handling after TLS termination.
cp .env.example .env # fill in the secrets below
docker compose up -dSet SECRET_KEY_BASE, KAKEMONO_API_SECRET, and KAKEMONO_BACKEND_PASSWORD in
.env. Persistent state lives under /data (one bind mount/volume): kakemono.db,
uploads/, backups/, secret.key, and backend_password.hash.
| Variable | Default | Description |
|---|---|---|
PORT |
4000 |
HTTP port |
PHX_HOST |
example.com (prod) |
Host for generated URLs and websocket origin check |
SECRET_KEY_BASE |
required (prod) | Phoenix signing/encryption secret |
KAKEMONO_API_SECRET |
dev-secret-change-me (dev) |
Shared x-kakemono-secret for display API calls; required in prod unless the secret file exists |
KAKEMONO_BACKEND_PASSWORD |
— | Backend login password (≥12 chars); required in prod unless the hash file exists |
KAKEMONO_DATA_DIR |
data/dev, data/test, or /data (prod) |
Parent directory for all runtime data |
Advanced overrides (default under the data dir): DATABASE_PATH,
KAKEMONO_UPLOADS_DIR, KAKEMONO_BACKUPS_DIR, KAKEMONO_API_SECRET_FILE,
KAKEMONO_BACKEND_PASSWORD_FILE. For normal deployments set only KAKEMONO_DATA_DIR.
The API secret can also be regenerated from /c/settings; it persists to
<data-dir>/secret.key and is loaded on startup when present.
assets/ Frontend build (Vite/esbuild), CSS, JS hooks, Vitest tests
config/ Phoenix and runtime configuration
lib/ App and web modules; each widget co-locates module + CSS + JS hook
under lib/kakemono/widgets/<name>/
priv/ Migrations, static files, gettext
test/ ExUnit tests and support
Dockerfile, docker-compose.yml, .docker/ Container images
Runtime data (DB, uploads, backups, secret/password files) lives under the data dir and is ignored by Git.
Kakemono is source-available under the
PolyForm Noncommercial License 1.0.0
(PolyForm-Noncommercial-1.0.0) for free non-commercial use.
The license permits personal, private, educational institution, research, charitable or nonprofit, community, and similar non-commercial use. Commercial use is not covered by this license and requires a separate paid commercial license from the copyright holder.
See LICENSE for the full terms.
mix test # Elixir suite
cd assets && npm test # frontend suite
mix kakemono.backup # zip of DB + uploaded media
mix kakemono.purge --yes # reset local dev dataDependency updates:
mix hex.outdated && mix deps.update --all && mix test
cd assets && npm outdated && npm update && npm test && npm run buildFor major bumps, pin the new version (mix.exs constraint or npm install pkg@latest)
and rerun the suites. Update Docker base images by editing the tags in Dockerfile /
.docker/Dockerfile.dev and rebuilding with --pull. Full verification pass:
mix format && mix test && cd assets && npm test && npm run buildA widget is a self-describing module using use Kakemono.Widget
(lib/kakemono/widget.ex). Widgets are auto-discovered — no registry to edit.
Co-locate all files in one folder:
lib/kakemono/widgets/<name>/
<name>.ex Widget module (use Kakemono.Widget)
<name>.css Optional styles
<name>.js Optional LiveView JS hook
- Implement the module. Only
type/0,name/0, andrender/1are required.fields/0is the single source of config truth — the JSON Schema, defaults, and scene-editor form all derive from it (seeKakemono.Widget.Config);icon/0supplies the picker glyph. Uselib/kakemono/widgets/clock/clock.exas a template. - Styles (optional). Add
<name>.css, wrap rules in@layer components { … }, and@importit fromassets/css/app.css. - JS hook (optional). Add
<name>.js, import it inassets/js/app.js, and add it to theHooksmap. - Remote data (optional). Implement
fetch/1({:ok, patch}|:skip|{:error, reason}) and list cached keys incache_fields/0; addprefetch/1andon_config_change/2as needed.Kakemono.Widgets.FetchWorkerrunsfetch/1; add the widgettypeto aRefreshSchedulercron line inconfig/config.exsto refresh on a cadence. See theweatherorrsswidgets.
Run mix compile && mix assets.build; the widget appears in the scene editor's picker.
The app uses Gettext with two domains:
default— backend admin UI (nav labels, buttons, flash messages, form labels).widgets— display-facing widget text (weather conditions, stat labels, weekday and month names, air-quality levels).
Translation files live under priv/gettext/. The backend language is set globally in
Settings (/c/settings); each display has its own locale selector in the Control panel.
Backend UI (LiveViews, controllers, templates):
gettext("Save")
ngettext("1 item", "%{count} items", count)Widget render functions (display-facing text):
dgettext("widgets", "Feels like")
dgettext("widgets", "Rain")All LiveViews and widgets already have Gettext imported — use KakemonoWeb, :live_view
and use Kakemono.Widget set it up.
mix gettext.merge priv/gettext --locale frThis creates priv/gettext/fr/LC_MESSAGES/{default,widgets,errors}.po. Fill in the
msgstr entries in each file, then add "fr" to the @supported list in
lib/kakemono/locale.ex.
After adding or changing gettext()/dgettext() calls:
mix gettext.extract --mergeThis updates the .pot templates and merges new strings into all existing .po files.
Translate the new empty msgstr entries in each locale's .po files.