SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

Halcyon: Turn Jellyfin to 90s Video Store

Halcyon turn your Jellyfin library to 1990s rental shop wey you fit waka inside browser. See Docker command, reverse proxy setup, and honest project caveats.

Wetín Halcyon dey do for your Jellyfin library

Halcyon Video dey redraw your Jellyfin library as video store for 1990s wey you fit waka inside through browser. Every film wey you get go become case for shelf. You go waka through the aisles under strip lights, pull box down, turn am over to read the specs for the back, then carry am go counter to start playback. Playback reports start, progress, and stop back to Jellyfin, so resume points and watch history go remain correct.

Halcyon dey read existing Jellyfin server through Jellyfin API and e no keep any library of e own. This guide assume say Jellyfin don already dey run and e dey scan without problem. If e no dey, first set up Jellyfin as media server for VPS and come back when your library dey look correct for the normal web client. Na the kind thing wey you install because the library don already dey there, no be because you need another service for your self-hosting list.

The project na GPL-3.0 and one person write am, and the README talk plainly say e no accept pull requests. Development dey move fast and no second maintainer dey to catch regression, so pin the image version before you show the store to anybody else. The last section explain how.

Rendering dey happen for where?

For browser. Halcyon na Vite and TypeScript app wey build on three.js, a JavaScript library wey dey draw 3D graphics through WebGL (web graphics library, na browser interface to GPU). The machine wey hold the screen dey composite the store geometry and box art.

The container no dey do plenty work. E dey run npm run serve, wey be vite preview --port 1420 --strictPort --host, and e dey serve the built files plus some small middleware routes. Halcyon no add transcoding, and e no run any engine for server.

So, na client own be the GPU question. Small VPS fit serve this well, because serving am mean serving static files over HTTP. Na laptop, tablet, or television wey dey run browser decide whether the store go move smoothly or crawl.

One feature dey break this rule. Remote Play dey start headless Chromium instances for server, then e dey stream the rendered store go phone or set top box over WebRTC (web real time communication). That path dey render for server, and default limit na two instances; you fit adjust am with REMOTE_PLAY_MAX_INSTANCES. If no mapped /dev/dri device dey available, those instances go render on CPU, so two core VPS go feel every extra viewer.

Wetin store dey read from your library

Jellyfin own structure dey provide the aisles. Halcyon dey arrange sections from your libraries and genres, and e dey group sequels from your BoxSets. The specs wey dey printed for the back of each case come from the MediaStreams metadata wey Jellyfin already dey keep. This mean say anything wey no dey for Jellyfin no go dey for the shelf.

This make the store a proper mirror of your metadata. Library wey an arr stack in Docker Compose feed, with artwork and genres already filled, go look much better here pass folder wey get loose files with generic names. Photo libraries get the same dependence on the thing wey index dem. Remember this when you dey compare PhotoPrism with Immich for the still images wey dey on the same server.

Try the video store demo before you install anything

Project publish the whole store wey dey run against one synthetic library for the hosted demo. If you add ?demo=1 to any Halcyon URL for your own deployment, e go do the same thing.

Use am to test your hardware. The demo library get around 2,000 titles and need roughly 2 GB browser memory. E heavier pass most personal libraries. If demo dey stutter for the device wey you plan use browse, your own library go stutter too. The fix na the 2.5D mode wey dem describe below, no be bigger VPS.

Docker use am

Na command wey upstream document.

docker run -d --name halcyon --network host --restart unless-stopped \
  ghcr.io/halcyon-video/halcyon-video

Then check say e don come up.

docker logs halcyon
curl -I http://127.0.0.1:1420

The log suppose show say preview server dey listen on port 1420, and curl suppose answer HTTP/1.1 200 OK. Container wey dey exit within few seconds almost always na port issue. --strictPort mean say server no go shift go 1421 when 1420 don already dey use, so e stop instead.

--network host dey for Remote Play, no be for the store. WebRTC need advertise the machine real address to the device wey wan receive the stream. Behind the default Docker bridge, container sabi only im own 172.x address. No phone for your network fit reach that address, so the stream no go connect. If na only store for browser you want, publish the port instead.

docker run -d --name halcyon -p 1420:1420 --restart unless-stopped \
  ghcr.io/halcyon-video/halcyon-video

This one na better default for VPS, because host networking put the container on every interface wey the machine get, including the public one. Docker wey dey run for VPS explain the remaining trade-off. --restart unless-stopped na wetin bring the store back after reboot, just like Compose services wey start on boot.

If you clone the repository and run docker compose up -d, e go build the image locally instead. The committed Compose file dey build from source by default, and e get the prebuilt image: line commented out. Uncomment that line if you want use the published image under Compose.

One hard limit as of August 2026: the published image na linux/amd64 only. The arm64 side of the multi-architecture push fail under emulation, and e dey wait for native arm runners. For arm64 VPS, the pull go fail with no matching manifest for linux/arm64/v8 in the manifest list entries. Building from the clone na the way to pass this problem.

Point am go your Jellyfin server

Open http://<host>:1420 and log in with your Jellyfin server address, username and password. The .env.local.example file for the repository na only for local development. Vite dey expose variables wey start with VITE_ to client-side code, so if you write Jellyfin password there, dem go compile am inside the JavaScript bundle wey every visitor dey download. For server wey other people fit reach, use the interface to log in.

Browser dey talk to Jellyfin directly. Halcyon's container no dey proxy the Jellyfin API, and this get two consequences wey you suppose know before you start debugging.

First, browser must fit reach Jellyfin, no be only the VPS wey dey serve Halcyon. Jellyfin wey bind to 127.0.0.1:8096 dey okay for local test, but e go leave the shelves empty for everybody else.

Second, the call dey cross origin, from Halcyon's address go Jellyfin address. Jellyfin dey answer API requests with Access-Control-Allow-Origin: * by default, so e go work without extra configuration. If you don narrow that setting, or put authentication proxy in front of Jellyfin API, browser console go report blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource and the store go load with empty shelves.

Put am behind reverse proxy, with authentication for front

vite preview na preview server. E no terminate TLS (transport layer security), and e no get access control of im own. So, anything wey public suppose put am behind nginx or Caddy.

server {
  listen 443 ssl;
  server_name halcyon.example.com;

  location / {
    proxy_pass http://127.0.0.1:1420;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

If you put domain name for front of the container, you need one extra setting. Halcyon dey answer localhost, raw IP addresses, and the names of the machine wey e dey run on, to protect against DNS rebinding. Inside container, the machine wey e dey run on na the container, so the hostname no be your own hostname. If request enter as halcyon.example.com, e go reject am, and the response go name the host wey e reject. Add that name.

docker run -d --name halcyon -p 127.0.0.1:1420:1420 --restart unless-stopped \
  -e HALCYON_ALLOWED_HOSTS=halcyon.example.com \
  ghcr.io/halcyon-video/halcyon-video

The value dey comma separated. Leading dot like .example.com go match subdomains, while all go turn off the check. Use all only for machine wey nothing outside fit reach.

Once you serve the store over https://, the Jellyfin address wey you type for login must be https:// too. Browser dey block plain http:// API call wey come from HTTPS page, and console go show Mixed Content: The page at 'https://halcyon.example.com/' was loaded over HTTPS, but requested an insecure resource. Login go simply fail, without any explanation inside Halcyon. Serve both over TLS, or keep both for plain HTTP inside private network.

Now authentication. The store dey ask for Jellyfin credentials, so stranger wey find the URL go meet login screen. One feature dey change this. If you turn on Remote Play under Settings and then Connection, e go give your Jellyfin session to the server. That one mean visitors to /remote.html go get their own instance of your real library. Na the purpose of the feature be that, and e mean say na secrecy of the URL dey between internet and your films. If you enable Remote Play, put single sign on for front of the whole site with Authentik as self-hosted SSO gateway, or remove the public hostname and reach the store through WireGuard tunnel wey wg-easy dey manage.

Two details follow this setup. Reverse proxy dey carry only the store. Remote Play stream na WebRTC over UDP, and e no dey pass through HTTP proxy. So e need im own path for 3478/udp and 49200 to 49260/udp when the bundled TURN relay dey use. The plain docker run wey we mention above no keep any volume, so Remote Play seed no go survive docker rm. Compose file dey mount halcyon-data volume at /data and set REMOTE_PLAY_SEED to /data/remote-play-seed.json for exactly this reason.

Wetin to do when store dey run badly

Halcyon dey render only when e need am. Store wey dey idle no dey composite any frame, and when window lose focus, animation loop dey stop. Na why tab wey you leave open no dey cook laptop battery. This fit help machine wey performance just dey borderline. But e no go help machine wey no fit draw the store at all.

For those clients, 2.5D mode dey available. E use plain HTML and CSS without WebGL, and dem design am for hardware as small as Raspberry Pi. You fit switch between 3D and 2.5D from settings or power menu without reloading the page. So you fit test both modes for the same device within seconds. Make you get realistic expectation: the author describe the flat mode as rough and still under development. Treat am as fallback for weak clients.

When client no strong enough for 3D store, failure dey obvious. The tab fit reload by itself, or browser fit report say WebGL context don lost, usually while shelves still dey fill. Move that device to 2.5D instead of reducing your library.

Image pin am check am before you pull

Take this part seriously. Tags v0.1.0 through v0.3.1 all land within days of each other, and v0.2.1 dey exist only because image push for v0.2.0 fail. Bug reports dey welcome upstream, but patches no dey welcome, so release stream na one person working state.

Running latest with habit of docker pull mean say store fit change under you for any normal Tuesday. Pin am by digest, na the only reference wey no fit move.

docker buildx imagetools inspect ghcr.io/halcyon-video/halcyon-video:0.3.1

This one go print digest wey dey behind the tag. Use am instead of the tag.

docker run -d --name halcyon -p 1420:1420 --restart unless-stopped \
  ghcr.io/halcyon-video/halcyon-video@sha256:747dcc821a3d2fa318b50e76024783c1835609047e84f502e23d021bc1898b20

That digest na 0.3.1 on 10 August 2026. Read the current one yourself instead of copying am, and read release notes before you move, because patch release for here fit bring store layout changes together with fixes.

FAQ

Halcyon need GPU for my VPS?

No be normal use. three.js dey draw the store inside browser, so na the client machine dey do the rendering, while the container only dey serve static files for port 1420. Remote Play na the exception. E dey run headless Chromium for server and stream the result. That path dey render with CPU unless you map /dev/dri inside the container for hardware acceleration.

I fit put Halcyon for public internet?

Only if authentication dey in front. The store dey ask for Jellyfin credentials, but when you turn on Remote Play, you dey give your Jellyfin session to the server. So anybody wey load /remote.html fit get instance of your real library without login. Put reverse proxy with single sign on before am, or keep the hostname out of public DNS and reach the store through VPN.

Why shelves empty after I log in?

Browser dey call Jellyfin API directly, so Jellyfin must dey reachable from the browser, no be only from the VPS. Open browser console. blocked by CORS policy mean say Jellyfin no dey accept request from Halcyon's address. Mixed Content message mean say page dey use HTTPS, while the Jellyfin address wey you enter na plain HTTP.

I need --network host?

Na only for Remote Play. WebRTC must advertise the machine real address. But behind Docker bridge, the container fit offer only 172.x address wey no phone for your network fit reach. If na to browse the store for browser, -p 1420:1420 dey work and e expose much less of the host.

Which image tag I suppose use?

Pin digest instead of latest. Read the digest for one version with docker buildx imagetools inspect ghcr.io/halcyon-video/halcyon-video:0.3.1, run that digest, and move only after you read the release notes. As of August 2026, the published image na linux/amd64 only. So arm64 host must build from the clone with docker compose up -d.