Why
Films, shows and music you already own shouldn't need a subscription to watch. Jellyfin is a free media server that streams your own library to its TV, phone and desktop apps and to any browser, from a machine you own.
What
One app, jellyfin, on the place jellyfin, on none. That is deliberate: Jellyfin's TV, phone and desktop apps cannot get past an Edgible sign-in, so org would stop them playing. Jellyfin's own login protects it, and nobody can sign up: the setup wizard makes the admin from the command line before anything is public, and the admin adds everyone else.
It runs one service, jellyfin, from the image Jellyfin publishes, pinned to 12.2.20261005-225228. Your media folder is mounted read-only, so Jellyfin never changes your files. Hardware transcoding is optional: on Linux with an Intel or AMD GPU, uncomment the devices: lines in the Compose file. Without it, Jellyfin transcodes on the CPU, or not at all when the app plays the file as it is.
To use it in a card, copy the jellyfin service, its two volumes, and the JELLYFIN_ lines.
Settings, users and watch history live in the volume jellyfin-config, and artwork and transcodes in jellyfin-cache. Back up jellyfin-config; the cache rebuilds. Your media stays in JELLYFIN_MEDIA_DIR, outside Docker. The memory below is Jellyfin's own documented minimum, which is higher than an empty test library measures; transcoding several streams wants more. The disk is for Jellyfin itself, not your media. Minimum recommended for the place jellyfin: 4 GB of memory and 10 GB of disk, on an arm64 or amd64 machine, with a GPU if you have one (it runs without).
The Compose file is docker-compose.yml, the settings card.env, the card card.yml, and the last test test.yml.
How
Five steps. Edit card.env before you start.
1. Fetch
mkdir -p jellyfin
curl -fsSL https://github.com/Edgible/cards/archive/refs/heads/main.tar.gz \
| tar -xz --strip-components=2 -C jellyfin cards-main/jellyfin
2. Edit card.env
Set DEVICE to a name edgible device list prints, and JELLYFIN_MEDIA_DIR to the full path of the folder that holds your media. Keep that folder outside jellyfin/, because the last Tear down step deletes jellyfin/. The admin password is generated in the next step.
nano jellyfin/card.env
3. Check
curl -fsSLo check-env.py https://raw.githubusercontent.com/Edgible/card-kit/main/check-env.py
python3 check-env.py jellyfin
It fills each empty secret and moves a taken port, as lines to paste. Run it again until it says no conflicts.
4. Start
docker compose --env-file jellyfin/card.env -f jellyfin/docker-compose.yml up -d --wait
docker compose --env-file jellyfin/card.env -f jellyfin/docker-compose.yml ps
--wait returns when each service is running, and healthy when it has a healthcheck. Then finish Jellyfin's setup wizard, before Publish, so it is never public. Until the wizard is finished, anyone who reaches Jellyfin can make the admin:
sh jellyfin/prepare.sh
prepare.sh is:
#!/bin/sh
# Prepare for the jellyfin card: finish Jellyfin's setup wizard, so it is never public.
# Run it after Start and before Publish, from the directory that holds jellyfin/:
# sh jellyfin/prepare.sh
set -eu
cd "$(dirname "$0")"
set -a; . ./card.env; set +a
COMPOSE=${COMPOSE:-docker compose --env-file card.env -f docker-compose.yml}
B="http://127.0.0.1:$JELLYFIN_PORT"
H='Content-Type: application/json'
curl -fsS -X POST "$B/Startup/Configuration" -H "$H" \
-d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}'
curl -fsS "$B/Startup/User" >/dev/null
curl -fsS -X POST "$B/Startup/User" -H "$H" \
-d "{\"Name\":\"$JELLYFIN_ADMIN_USER\",\"Password\":\"$JELLYFIN_ADMIN_PASSWORD\"}"
curl -fsS -X POST "$B/Startup/RemoteAccess" -H "$H" \
-d '{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}'
curl -fsS -X POST "$B/Startup/Complete"
It prints nothing when it works. The first start downloads about 400 MB.
5. Publish
set -euo pipefail
set -a
. jellyfin/card.env
set +a
device_id=$(edgible device list --json | jq -er --arg name "$DEVICE" '
map(select(.name == $name))
| if length == 1 then .[0].id
else error("need exactly one device named " + $name + " (" + (map(.status + " " + .id) | join(", ")) + ")")
end
')
edgible app create existing \
--non-interactive \
--name jellyfin \
--port "$JELLYFIN_PORT" \
--protocol https \
--auth-modes none \
--device-id "$device_id"
edgible app list
Verify
jellyfin uses none.
curl -sS -o /dev/null -w '%{http_code}\n' "https://<jellyfin hostname>"
That prints 302: Jellyfin sending you to its web app, with no Edgible sign-in, as its apps need. Sign in with JELLYFIN_ADMIN_USER and JELLYFIN_ADMIN_PASSWORD, and add a library pointing at /media. In a Jellyfin app, set the server to https://<jellyfin hostname> and sign in the same way.
The rest of the setup is in the Jellyfin docs.
Tear down
Four steps, in this order. Step 1 runs wherever edgible is logged in. Steps 2 to 4 run on the machine that runs the containers. Steps 2 and 3 read card.env, so keep it until step 4.
1. Unpublish
edgible app delete jellyfin --yes
2. Stop
docker compose --env-file jellyfin/card.env -f jellyfin/docker-compose.yml down
3. Delete the data
Skip this step to keep the data. The loop copies each volume to a .tgz file in this directory first.
for volume in $(docker volume ls -q --filter label=com.docker.compose.project=jellyfin); do
docker run --rm -v "$volume:/data:ro" -v "$PWD:/backup" alpine tar -czf "/backup/$volume.tgz" -C /data .
done
docker compose --env-file jellyfin/card.env -f jellyfin/docker-compose.yml down --volumes
4. Remove the card
rm -rf jellyfin