SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Halcyon: your Jellyfin as a 90s video store

Halcyon rebuilds your Jellyfin library as a walkable 1990s rental store in the browser. The Docker command, the reverse proxy, and the honest caveats.

What Halcyon does to your Jellyfin library

Halcyon Video redraws your Jellyfin library as a walkable 1990s video store in the browser. Every film you own becomes a case on a shelf. You walk the aisles under the strip lights, pull a box down, turn it over to read the specs on the back, and carry it to the counter to start playback. Playback reports start, progress and stop back to Jellyfin, so resume points and watch history stay correct.

Halcyon reads an existing Jellyfin server over the Jellyfin API and keeps no library of its own. This guide assumes Jellyfin is already running and scanning cleanly. If it is not, set up Jellyfin as a media server on a VPS first and come back once your library looks right in the normal web client. This is the kind of thing you install because the library is already there, not because you needed another service on your self hosting list.

The project is GPL-3.0 and written by one person, and the README states plainly that it does not accept pull requests. Development moves fast and there is no second maintainer to catch a regression, so pin the image version before you show the store to anyone else. The last section covers how.

Where does the rendering happen?

In the browser. Halcyon is a Vite and TypeScript app built on three.js, a JavaScript library that draws 3D graphics through WebGL (web graphics library, the browser's interface to the GPU). The store geometry and the box art are composited by the machine holding the screen.

The container does very little. It runs npm run serve, which is vite preview --port 1420 --strictPort --host, and serves the built files plus a few small middleware routes. Halcyon adds no transcoding and runs no engine on the server.

So the GPU question belongs to the client. A small VPS serves this happily, because serving it means static files over HTTP. The laptop, tablet or television running the browser is what decides whether the store moves smoothly or crawls.

One feature breaks that rule. Remote Play spawns headless Chromium instances on the server and streams the rendered store to a phone or a set top box over WebRTC (web real time communication). That path renders on the server, capped at two instances by default and adjustable with REMOTE_PLAY_MAX_INSTANCES. Without a mapped /dev/dri device those instances render on the CPU, so a two core VPS feels every extra viewer.

What the store reads from your library

The aisles come from Jellyfin's own structure. Halcyon lays out sections from your libraries and genres, and it groups sequels from your BoxSets. The specs printed on the back of each case come from the MediaStreams metadata Jellyfin already holds, which means anything missing in Jellyfin is missing on the shelf.

That makes the store a fair mirror of your metadata. A library fed by an arr stack in Docker Compose with artwork and genres already filled in looks much better here than a folder of loose files with generic names.

Try the video store demo before you install anything

The project publishes the whole store running against a synthetic library at the hosted demo. Appending ?demo=1 to any Halcyon URL does the same on your own deployment.

Use it as a hardware test. The demo library holds around 2,000 titles and wants roughly 2 GB of browser memory, which is heavier than most personal libraries. If the demo stutters on the device you plan to browse from, your own library will stutter too, and the fix is the 2.5D mode described below rather than a bigger VPS.

Run it with Docker

This is the command upstream documents.

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

Then check that it came up.

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

The log should show the preview server listening on port 1420, and curl should answer HTTP/1.1 200 OK. A container that exits within a few seconds is almost always the port. --strictPort means the server refuses to slide to 1421 when 1420 is taken, so it stops instead.

--network host is there for Remote Play, not for the store. WebRTC has to advertise the machine's real address to the device that wants the stream. Behind the default Docker bridge the container knows only its own 172.x address, which no phone on your network can reach, so the stream never connects. If you only want the store in a browser, publish the port instead.

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

That is the better default on a VPS, because host networking puts the container on every interface the machine has, including the public one. Running Docker on a VPS covers the rest of that trade. --restart unless-stopped is what brings the store back after a reboot, the same idea as Compose services that start on boot.

Cloning the repository and running docker compose up -d builds the image locally instead. The committed Compose file builds from source by default and carries the prebuilt image: line commented out, so uncomment that line if you want the published image under Compose.

One hard limit as of August 2026: the published image is linux/amd64 only. The arm64 half of the multi architecture push failed under emulation and is waiting on native arm runners. On an arm64 VPS the pull fails with no matching manifest for linux/arm64/v8 in the manifest list entries, and building from the clone is the way through.

Point it at your Jellyfin server

Open http://<host>:1420 and log in with your Jellyfin server address, username and password. The .env.local.example file in the repository is for local development only. Vite exposes variables prefixed with VITE_ to client side code, so a Jellyfin password written there is compiled into the JavaScript bundle every visitor downloads. On a server other people can reach, log in through the interface.

The browser talks to Jellyfin directly. Halcyon's container does not proxy the Jellyfin API, and that has two consequences worth knowing before you start debugging.

First, Jellyfin has to be reachable from the browser, not only from the VPS that serves Halcyon. A Jellyfin bound to 127.0.0.1:8096 is fine for a local test and leaves the shelves empty for everyone else.

Second, the call is cross origin, from Halcyon's address to Jellyfin's. Jellyfin answers API requests with Access-Control-Allow-Origin: * by default, so it works with no extra configuration. If you have narrowed that setting, or put an authentication proxy in front of the Jellyfin API, the browser console reports blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource and the store loads with bare shelves.

Put it behind a reverse proxy, with authentication in front

vite preview is a preview server. It terminates no TLS (transport layer security) and has no access control of its own, so it belongs behind nginx or Caddy on anything public.

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;
  }
}

A domain name in front of the container needs one more setting. Halcyon answers to localhost, raw IP addresses and the names of the machine it runs on, as a guard against DNS rebinding. Inside a container, the machine it runs on is the container, so its hostname is not yours. A request arriving as halcyon.example.com is refused, and the response names the host it refused. 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 is comma separated, a leading dot such as .example.com matches subdomains, and all turns the check off. Reach for all only on a machine nothing outside can reach.

Once the store is served over https://, the Jellyfin address you type at login has to be https:// as well. A browser blocks a plain http:// API call made from an HTTPS page, and the console reads Mixed Content: The page at 'https://halcyon.example.com/' was loaded over HTTPS, but requested an insecure resource. The login simply fails, with no explanation inside Halcyon. Serve both over TLS, or keep both on plain HTTP inside a private network.

Then authentication. The store asks for Jellyfin credentials, so a stranger who finds the URL meets a login screen. One feature changes that. Turning on Remote Play, under Settings and then Connection, donates your Jellyfin session to the server so that visitors to /remote.html get their own instance of your real library. That is the point of the feature, and it means the secrecy of the URL is what stands between the internet and your films. If you enable Remote Play, put single sign on in front of the whole site with Authentik as a self hosted SSO gateway, or drop the public hostname and reach the store over a WireGuard tunnel managed with wg-easy.

Two details go with that. The reverse proxy carries the store only: the Remote Play stream is WebRTC over UDP and does not travel through an HTTP proxy, so it needs its own path on 3478/udp and on 49200 to 49260/udp when the bundled TURN relay is in use. And the plain docker run above keeps no volume, so the Remote Play seed does not survive docker rm. The Compose file mounts a halcyon-data volume at /data and sets REMOTE_PLAY_SEED to /data/remote-play-seed.json for exactly that reason.

What to do when the store runs badly

Halcyon renders on demand. An idle store composites no frames, and losing window focus stops the animation loop, which is why a tab left open does not cook a laptop battery. That helps a machine that is merely borderline. It does nothing for a machine that cannot draw the store at all.

For those clients there is a 2.5D mode, plain HTML and CSS with no WebGL, meant for hardware as small as a Raspberry Pi. You switch between 3D and 2.5D from the settings or the power menu with no page reload, so testing both on the same device takes seconds. Be realistic about what you get: the author describes the flat mode as rough and still in progress. Treat it as a fallback for weak clients.

When a client is too small for the 3D store, the failure is loud. The tab reloads itself, or the browser reports a lost WebGL context, usually while the shelves are still filling. Move that device to 2.5D rather than trimming your library.

Pin the image, and check before you pull

Take this part seriously. Tags v0.1.0 through v0.3.1 all landed within days of each other, and v0.2.1 exists only because the image push for v0.2.0 failed. Bug reports are welcome upstream, patches are not, so the release stream is one person's working state.

Running latest with a habit of docker pull means the store can change under you on any ordinary Tuesday. Pin by digest, the one reference that cannot move.

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

That prints the digest behind the tag. Use it in place of the tag.

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

That digest was 0.3.1 on 10 August 2026. Read the current one yourself instead of copying it, and read the release notes before you move, because a patch release here can carry store layout changes as well as fixes.

FAQ

Does Halcyon need a GPU on my VPS?

Not for normal use. The store is drawn by three.js in the browser, so the client machine does the rendering and the container only serves static files on port 1420. The exception is Remote Play, which runs headless Chromium on the server and streams the result. That path renders on the CPU unless you map /dev/dri into the container for hardware acceleration.

Can I put Halcyon on the public internet?

Only behind authentication. The store asks for Jellyfin credentials, but turning on Remote Play donates your Jellyfin session to the server, so anyone who loads /remote.html gets an instance of your real library without logging in. Put a reverse proxy with single sign on in front of it, or keep the hostname off public DNS and reach the store over a VPN.

Why are the shelves empty after I log in?

The browser calls the Jellyfin API directly, so Jellyfin must be reachable from the browser and not only from the VPS. Open the browser console. blocked by CORS policy means Jellyfin is not accepting the request from Halcyon's address. A Mixed Content message means the page is on HTTPS while the Jellyfin address you entered is plain HTTP.

Do I need --network host?

Only for Remote Play. WebRTC has to advertise the machine's real address, and behind the Docker bridge the container can offer only a 172.x address that no phone on your network can reach. For browsing the store in a browser, -p 1420:1420 works and exposes much less of the host.

Which image tag should I use?

Pin a digest rather than latest. Read the digest for a version with docker buildx imagetools inspect ghcr.io/halcyon-video/halcyon-video:0.3.1, run that digest, and move only after reading the release notes. As of August 2026 the published image is linux/amd64 only, so an arm64 host has to build from the clone with docker compose up -d.