NostalgiaTV server - 7 to 30-day schedules, web UI, HDHR tuner, Plex integration
50K+
A self-hosted companion server for the NostalgiaTV Android app. Generates each profile's schedule 7, 14 or 30 days ahead, indexes your Plex, Jellyfin, or Emby content, manages channels and commercials, and exposes a browser-based Watch and Configure UI — plus an HDHR/IPTV tuner so other apps (Plex DVR, Channels DVR, Jellyfin, TiviMate, etc.) can tune in too.
Built for amd64 and arm64 — PCs, servers, Raspberry Pi 4/5, Synology/QNAP NAS, Apple Silicon Macs.
http://<server>:19850 in any browser/watch — Live TV guide with an inline player, plus On Demand, Movies, and TV Shows browsing. Sign in with Plex, Jellyfin, Emby, or a local account./configure — admin tabs: Channels, Commercials, Groups, Schedule, Design, Profile Configuration, Servers, Admin (External Sources, Automatic Backup, Logs)./configure, or in Guide Schedule settings in the app. A longer schedule takes longer to rebuild; a change applies on the next schedule pass (within about 6 hours) or right away with Refresh Schedule.HALFWAY, CHAPTERS (from the item's own server), and SILENCE — real ffmpeg silence detection, which runs on the Docker server only (too CPU-heavy for a TV stick).Exposes your whole channel lineup to anything that speaks HDHR or M3U:
GET /discover.json, /lineup.json, /device.xml, /lineup_status.jsonGET /channels.m3u (M3U playlist) and GET /xmltv.xml (EPG)GET /stream/{channelId} for tuner playbackStreaming modes (Configure → Admin → Tuner Streaming):
Hardware acceleration (continuous mode): pick VAAPI / Quick Sync / NVENC / V4L2 in the Admin tab, or leave on Auto-detect. The container must be given the device:
services:
nostalgiatv:
# Intel (VAAPI, Quick Sync):
devices:
- /dev/dri:/dev/dri
# Raspberry Pi (V4L2):
# - /dev/video11:/dev/video11
# NVIDIA (NVENC) — requires nvidia-container-toolkit:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
/dev/dri is all it needs. Quick Sync covers Tiger Lake (11th gen) and newer; older Intel iGPUs should use VAAPI. The server joins the group that owns each mapped device automatically at startup, so no group_add is needed (unless you pin user: in compose, which skips that step — then add the device's group with group_add).--runtime=nvidia to Extra Parameters and set NVIDIA_VISIBLE_DEVICES to your GPU's UUID (the image already requests the video driver capability NVENC needs).tuner hwaccel auto-probe: <backend> unavailable (...) for Auto-detect, or tuner: ... produced no output (hwaccel=...) for an explicit choice.Max simultaneous tuners caps concurrent continuous streams (default 2) and is advertised to clients via discover.json.
To disable the tuner entirely, set HDHR_ENABLED=false.
Create a folder, save this as docker-compose.yml inside it:
services:
nostalgiatv:
image: purestream711/nostalgiatv-server:latest
container_name: nostalgiatv-server
ports:
- "19850:19850"
volumes:
- ./data:/app/data
- ./config:/app/config
- ./logos:/app/logos
environment:
- TZ=America/New_York
# Run as your host user so the bind-mounted folders above stay writable
# (find yours with `id -u` / `id -g`). See "Volumes & permissions" below.
- PUID=1000
- PGID=1000
restart: unless-stopped
Then start it:
docker compose up -d
Or pull and run directly, no compose file:
docker pull purestream711/nostalgiatv-server:latest
docker run -d \
--name nostalgiatv-server \
-p 19850:19850 \
-v "$(pwd)/data:/app/data" \
-v "$(pwd)/config:/app/config" \
-v "$(pwd)/logos:/app/logos" \
-e TZ=America/New_York \
-e PUID=1000 \
-e PGID=1000 \
--restart unless-stopped \
purestream711/nostalgiatv-server:latest
Then open http://localhost:19850 (or http://<your-server-ip>:19850 from another device) and sign in with Plex, Jellyfin, or Emby.
http://<server>:19850 — you'll land on the Watch UI's sign-in screen./configure and adjust channels, commercials, profiles, themes, etc.http://192.168.1.100:19850).The tuner is enabled by default. In your DVR app, point it at:
http://<server>:19850http://<server>:19850/channels.m3uhttp://<server>:19850/xmltv.xmlTo disable the tuner, set HDHR_ENABLED=false.
Three DSM-specific snags account for nearly all Synology setup trouble:
Create the volume folders first. Container Manager doesn't reliably create the relative ./data, ./config, and ./logos bind-mount folders when building a project (and when DSM does create them, they're root-owned). Before the first build, create the three subfolders in File Station inside the folder holding your docker-compose.yml (e.g. docker/nostalgiatv-server/data, …/config, …/logos). A project that crashes within seconds of starting, with logs complaining the app folders don't exist or can't be written, is this.
Set PUID/PGID to a real DSM user. The compose default 1000:1000 usually doesn't exist on DSM. SSH in and run id <your-dsm-username> — use the printed uid and gid (on DSM 7 the first admin account is commonly uid=1026, with gid=100 (users) or 101 (administrators), but trust id over these examples). The container repairs folder ownership on boot by itself; what it can't fix is a PUID/PGID pair that has no access to the share the folders live on.
Container Manager shows a stale version after updating. Its registry check caches the :latest tag, so "Update available"/version labels can lag the actual Docker Hub image. To force a real update, SSH in and run docker compose pull && docker compose up -d from the project folder (or put that in a root Task Scheduler task you trigger on demand). The version the server is actually running is shown on the Configure page — trust that, not Container Manager's label.
| Variable | Default | Description |
|---|---|---|
TZ | America/New_York | Timezone (affects schedule generation and EPG display) |
PUID | 1000 | Host user ID the server runs as, so files it writes to the bind mounts are owned by you. id -u to find yours |
PGID | 1000 | Host group ID the server runs as. id -g to find yours |
SCHEDULE_HOURS | 168 | Default hours of schedule to generate ahead (168 = 7 days), used by every profile that has not picked its own Schedule Length (7, 14 or 30 days — Guide Schedule settings in the app, the Schedule tab on the web). Supported range 24–2160 (90 days) |
SCHEDULE_REFRESH_INTERVAL | 21600 | Seconds between scheduler ticks (21600 = 6 hours) |
SCHEDULE_REGEN_THRESHOLD_HOURS | 96 | Extend a channel when its remaining schedule falls below this many hours (96 = 4 days). Keep it well below SCHEDULE_HOURS; the server caps it below each profile's own Schedule Length |
PLEX_CONTENT_POLL_INTERVAL_SECONDS | 300 | How often to probe your servers for content adds/removals (5 minutes) |
HDHR_ENABLED | true | Whether the HDHR tuner emulator is on |
WEATHER_API_KEY | (bundled key) | WeatherAPI.com key for the native Storm Channel. Defaults to the same key the Android app ships with — override to use your own |
LOG_LEVEL | INFO | Logging level — DEBUG, INFO, WARNING, ERROR |
FORWARDED_ALLOW_IPS | * | Which peer addresses may set X-Forwarded-*. See Running behind a reverse proxy |
SCHEDULE_HOURS, SCHEDULE_REFRESH_INTERVAL, and HDHR_ENABLED can also be changed at runtime via POST /api/settings, which persists them to config/server_settings.json (they then override the env vars on restart).
Putting the server behind nginx / Caddy / Traefik / SWAG / Nginx Proxy Manager for HTTPS works out of the box, as long as the proxy forwards the standard headers:
| Header | Why it matters |
|---|---|
X-Forwarded-Proto | Tells the server the client is on HTTPS. Without it, redirects and tuner URLs come back http:// and the browser blocks them as mixed content |
X-Forwarded-Host | The public hostname, used for the HDHR tuner / M3U / XMLTV URLs |
X-Forwarded-Port | The public port, when it isn't the default 443/80 |
nginx sets X-Forwarded-Proto for you with:
location / {
proxy_pass http://nostalgiatv:19850;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Live TV / HLS streams are long-lived — don't cut them off mid-playback.
proxy_buffering off;
proxy_read_timeout 3600s;
}
Caddy and Traefik set these automatically; no extra configuration needed.
The server only honours those headers from peers listed in FORWARDED_ALLOW_IPS, which defaults to * because a containerised deployment never sees its proxy as 127.0.0.1 — the peer is the Docker bridge gateway or the proxy's own container IP. Nothing in the server authenticates on client IP (auth is session token / API key only), so this is safe; the one thing a forged X-Forwarded-For can influence is an access-log line. If you terminate TLS at a known address and also expose port 19850 to an untrusted network, you can pin it:
environment:
- FORWARDED_ALLOW_IPS=172.18.0.5
Serving from a subpath (https://example.com/nostalgiatv/) is not supported — proxy from the root of a hostname or subdomain instead.
| Path | Purpose |
|---|---|
/app/data | SQLite database, content index, registered devices, automatic backups, persistent state |
/app/config | Profiles, channel overrides, custom channels, themes, server settings |
/app/logos | Custom channel logos uploaded via the UI |
Back up data/ and config/ together — they're a matched pair.
The container starts as root, chowns data/, config/, and logos/ to PUID:PGID, then drops to that unprivileged user before running — so a first run on a native-Linux host (Synology, QNAP, Portainer, Dockhand, Raspberry Pi) works even though the Docker daemon creates those bind-mount folders as root. Set PUID/PGID to your host user (id -u / id -g) if it isn't the default 1000:1000, and the database and config will be created and owned correctly with no manual chown. Docker Desktop on Windows/macOS handles bind-mount ownership through its own file-sharing layer and generally works regardless of PUID/PGID.
The full API is documented at http://<server>:19850/docs (FastAPI auto-generated, ~230 endpoints across status, channels, schedule, profiles, servers, design, commercials, groups, content, HDHR, Live TV, trailers, weather, logs, backups, and config import/export).
Key endpoints for orientation:
GET /health — container liveness probe (DB-free; the Docker HEALTHCHECK target)GET /api/status — server state and pairing probePOST /api/register — Android device registration (returns API key)POST /api/register/jellyfin — first-run bootstrap for a Jellyfin/Emby-only installGET /api/schedule — bulk schedule download for app playbackGET/POST /api/servers/{id}/libraries — backend-neutral per-server library selectionGET /api/config/export and POST /api/config/import — full config transferGET /discover.json, /lineup.json, /channels.m3u, /xmltv.xml, /stream/{id} — HDHR/IPTVAll /api/* endpoints (except /api/status, /api/register*, and /api/auth/*) require either an X-API-Key header or an authenticated browser session. HDHR endpoints are intentionally unauthenticated so legacy tuner apps can reach them.
Pulling a new image is not enough on its own — a container keeps running the image it was created from, so restarting it after a pull still boots the old version. This is how Docker works for every image, not just this one; the container has to be recreated to pick up the new image:
docker compose pull
docker compose up -d
docker compose up -d detects the newly pulled image and recreates the container automatically (it does nothing if the image didn't change). Your data/, config/, and logos/ folders live outside the container, so settings, channels, and schedules are preserved — though backing up config/ before updating never hurts.
Or with plain Docker (no compose):
docker pull purestream711/nostalgiatv-server:latest
docker stop nostalgiatv-server && docker rm nostalgiatv-server
# re-run your original `docker run` command
Using a GUI manager (Synology Container Manager, Portainer, Unraid, etc.): use its update/recreate action, not restart — restart never changes the image. On Synology specifically, Container Manager's update check caches the :latest tag and can claim you're up to date when you aren't; see the Synology section for the reliable SSH/Task Scheduler method.
Verifying the update: the version the server is actually running is shown on the Configure page (top bar) and in the first line of docker logs nostalgiatv-server. The version in the top bar (Configure and Watch) also gets a green pill border when it is Docker Hub's latest image and an amber one when a newer image is published — the server compares its own build id with the build tag Docker Hub lists next to :latest (checked once an hour; hover the version for both build ids). Every push is also tagged on Docker Hub with its version (:0.9.65) and build (:build-20260915-0210), so the Hub's Tags page names exactly what latest is. If the page still shows the old build after an update, the container was restarted but not recreated — run the two commands above.
Run from source instead of the published image:
docker compose -f docker-compose.dev.yml up -d --build
Or without Docker:
cd docker
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 19850 --reload
The latest image is published manually with a multi-arch buildx push — this is the only publish path. It reuses a persistent buildx instance named ntv-builder to avoid accumulating stray builders in Docker Desktop:
docker login -u purestream711
docker buildx use ntv-builder 2>/dev/null || \
docker buildx create --name ntv-builder --driver docker-container --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t purestream711/nostalgiatv-server:latest \
--push .
The Docker Hub overview page is not linked to a source repo, so it does not auto-sync from this README — update the repository description manually (or via the Hub API) after changing this file.
Content type
Image
Digest
sha256:429e4aa57…
Size
323.5 MB
Last updated
about 23 hours ago
docker pull purestream711/nostalgiatv-server