Files
iptvnator/docker/README.md
T
4grayandClaude Opus 5.5 b88a1c81e5 feat(self-hosted): server storage API in web-backend (#1898)
* feat(interfaces): add the server storage HTTP contract

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): read server storage configuration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): open the server database and track revisions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): playlist and user-state repositories

Playlist documents use the shared desktop row codec, so the server writes
the same rows. keepPayload upserts take the stored playlist/items/header
fields when the client leaves them out. Xtream user data lives in the
server-only server_user_state table, one JSON value per namespace and
playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): storage error mapping and access token guard

Storage errors map to JSON status bodies and are logged by name and code
only. An optional bearer token guards every route except /, /health,
/config.js and /v1/instance, provider proxy routes included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): serve the /v1 server storage API

/v1/instance is always public and reports whether server storage is on.
With storage, /v1/playlists serves whole documents with ETags and 304s,
accepts keepPayload upserts, and deletes playlists with their user state;
/v1/user-state and /v1/app-state store the rest. The proxy routes are
unchanged unless an access token is configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): open server storage on start and close on shutdown

With IPTVNATOR_STORAGE=server, main.ts opens the database before it
listens and exits with a clear message when the data directory is not
writable. SIGTERM and SIGINT close the server and the database. The
native better-sqlite3 module stays outside the esbuild bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): match weakened ETags on playlist reads

nginx weakens the ETag of a gzipped response, and clients send the weak
form back in If-None-Match. The route now compares tags weakly, as RFC 9110
requires for GET, so it answers 304 before reading the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(docker): persist server storage on a volume

The image builds better-sqlite3 in a native stage per target platform,
enables server storage on the /data volume, and raises the nginx body
limit to 64 MB for /api with gzip for JSON. The compose file mounts a
named volume and documents the optional access token. The PWA does not
use the storage yet; docs say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web-backend): type the storage route responses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): harden server storage reads, deletes and app state

- Metadata reads return the stored document without its payload fields,
  so fields that live only in the JSON survive a metadata round-trip.
- A conditional GET checks that the playlist exists before answering 304.
- Deleting a playlist and its user state commits in one transaction; the
  ETag moves only after the commit.
- Browser app state is stored under a client: prefix and the instance id
  is repaired when empty, so a client write cannot stop the backend.
- User-state buckets have no prototype, so ids like __proto__ round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(self-hosted): keep the access token unset until the PWA sends it

Add the release note for the Docker server storage volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): bound shutdown and reject padded playlist ids

- SIGTERM closes idle connections at once and cuts the rest after five
  seconds, so a long provider download cannot keep the backend running.
- Playlist ids with surrounding whitespace are rejected: the row codec
  trims ids, so storage and ETags would track different keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): clear optional columns on whole-document upserts

The row codec maps empty values to undefined, which ON CONFLICT DO UPDATE
skips, so a cleared User-Agent, Referrer or credential kept its old column
value and came back on the next read. Server upserts now write NULL to
every replaceable column the document no longer sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(self-hosted): linear token parsing, no-store app state, longer start

- The access guard reads the Bearer token by string scanning instead of a
  regular expression that CodeQL flagged as polynomial on long whitespace.
- GET /v1/app-state/:key sends Cache-Control: no-store like the other
  state routes, so devices never reuse a stale value.
- The entrypoint waits up to IPTVNATOR_BACKEND_START_TIMEOUT (300 s) for
  /health, because the backend migrates its database before it listens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(self-hosted): metadata reads skip payloads, redact JSON parse warnings

List playlist metadata from a server-only payload-free copy of each
document instead of running json_remove over every payload blob, and log
only the error name when a stored JSON value fails to parse, since the
parser's message quotes the stored text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): copy carried-over panel clock into playlist metadata

When an upsert keeps the stored serverTimezone, build the metadata copy
from the saved payload so metadata and full reads agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:05:35 +02:00

11 KiB

Self-hosted IPTVnator

User-facing overview with quick start, variables and FAQ: https://4gray.github.io/iptvnator/download/docker/. This file is the reference behind it.

The self-hosted image contains both pieces required for the browser PWA:

  • Angular PWA static files served by nginx
  • The monorepo web-backend Express app proxied under /api

The historical standalone 4gray/iptvnator-backend image is no longer needed for the default Docker deployment.

PWA Limitations And Playback Troubleshooting

The Docker image runs the browser PWA, not the Electron desktop application. That means:

  • EPG/XMLTV panels and multi-EPG views are not available in the self-hosted PWA.
  • Playlist metadata is stored in the browser through IndexedDB, and Xtream user data is stored by the PWA data source in localStorage. The PWA does not use the Electron SQLite database or DB worker.
  • Docker cannot launch local desktop players or Embedded MPV. If a stream does not play inline in the browser, use the in-app copy URL action and open the stream manually in an external player such as MPV, VLC, or IINA.
  • Electron-only features such as the download manager, external-player process control, and remote-control integrations are outside this runtime.

Run With Docker Compose

From the repository root:

docker compose -f docker/docker-compose.yml up --build -d

The ready-to-run compose file is docker-compose.yml. By default the app is available at http://localhost:4333. No additional environment variables, backend repository checkout, or separate backend container are required for the default local deployment.

Build The Image

docker build -t 4gray/iptvnator -f docker/Dockerfile .

The image build runs:

pnpm nx build web --configuration=pwa
pnpm nx build web-backend

Those Nx builds generate platform-independent JS and static assets. In multi-architecture CI builds, the Dockerfile runs this build stage on the native BuildKit builder platform, then copies the generated output into each target runtime image. That avoids running the Angular/Nx build through QEMU when publishing linux/amd64 and linux/arm64 images.

Published Docker Tags

Pull request builds validate the Dockerfile without pushing an image. Docker Hub publishing happens only from trusted repository events. Publishing requires the repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN; pull request builds and default manual runs do not use those secrets.

Tag pattern Published from Use case
latest master pushes Default self-hosted image for users who want the newest merged PWA build.
<version>-pwa master pushes Latest PWA image for the current package.json version.
<version>-pwa-<sha> master pushes Immutable PWA image for a specific merged commit within a version.
sha-<sha> master pushes Commit-addressable image, useful for rollback and support diagnostics.
<version> / v<version> v* release tags Release image aligned with a repository release tag.
stable Stable v* release tags Most recent non-prerelease tagged release image.
manual-<sha> Manual runs with push Explicit maintainer-triggered rebuilds outside normal publish events.

Use latest for the simplest self-hosted setup. Pin sha-<sha> or <version>-pwa-<sha> when you need reproducible deployments. Use release tags when you want the Docker image to track a tagged IPTVnator release rather than every merge to master.

Runtime Configuration

The container writes /usr/share/nginx/html/assets/app-config.js on startup. That file sets window.__IPTVNATOR_CONFIG__.BACKEND_URL, which the PWA reads before it creates PwaService.

These variables are supported by the Docker image. The compose file sets the safe local defaults shown below.

Variable Default Purpose
BACKEND_URL /api Browser-facing backend URL used by the PWA. Keep /api for the bundled nginx proxy.
CLIENT_URL http://localhost:4333 Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. Multiple origins can be comma-separated.
PORT 3000 Internal Express backend port. nginx proxy config is rendered from the template to match it at startup.
IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS 0 Set to 1 or true only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints.
NODE_EXTRA_CA_CERTS unset Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path.
IPTVNATOR_STORAGE server in the image server opens the server storage database under IPTVNATOR_DATA_DIR and its /api/v1 routes; none keeps the backend stateless.
IPTVNATOR_DATA_DIR /data in the image Data root; the database is <root>/databases/iptvnator.db. Mount a volume here.
IPTVNATOR_ACCESS_TOKEN unset When set, every route except /, /health, /config.js and /v1/instance requires Authorization: Bearer <token>, proxy routes included. The browser app cannot send a token yet: leave it unset until it can.
IPTVNATOR_MAX_DOCUMENT_MB 64 Largest playlist document the backend accepts. Keep the nginx client_max_body_size in step.
IPTVNATOR_BACKEND_START_TIMEOUT 300 Seconds the entrypoint waits for the backend's /health before giving up. Database migrations run before the backend listens.

The web backend proxy accepts only http and https provider URLs. The PWA first registers provider URLs through /provider-targets, then uses the returned targetId for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default so a publicly exposed instance cannot be used as a generic internal-network fetcher. If you enable IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1, keep the instance restricted to trusted users.

For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node:

services:
    iptvnator:
        volumes:
            - ./ca.pem:/etc/ssl/private/provider-ca.pem:ro
        environment:
            NODE_EXTRA_CA_CERTS: /etc/ssl/private/provider-ca.pem

The entrypoint renders the nginx config from docker/nginx.conf, starts the backend, waits for /health, and only then starts nginx. The nginx config serves the PWA with SPA fallback, avoids caching assets/app-config.js, and proxies /api/* to the internal backend. The Dockerfile and compose file both define a health check against /api/health. If nginx or the backend exits after startup, the entrypoint exits the container so Docker Compose can apply the restart: unless-stopped policy.

Server Storage

The image opens a server storage database on the /data volume (IPTVNATOR_STORAGE=server, IPTVNATOR_DATA_DIR=/data) and serves its API under /api/v1. It is the backend half of keeping playlists, favorites, history and playback positions on the server for every device; the PWA starts using it in a follow-up change. Until then the volume holds an empty database and each browser keeps its own data.

Provider credentials will be stored unencrypted in that database, as the desktop app does. Do not expose an instance with server storage to the internet without authentication in your reverse proxy. The backend already supports IPTVNATOR_ACCESS_TOKEN, but the browser app cannot send the token yet, so setting it now makes the app unusable. Back up the volume like any database: stop the container, or copy databases/iptvnator.db together with its -wal file.

IPv6, VPNs, And Connection Fallback

Node races IPv6 and IPv4 addresses when a provider hostname has both ("happy eyeballs"), and its stock per-attempt budget of 250 ms is too short for many VPN and container networks. The classic symptom is a provider that answers wget from inside the container but fails in IPTVnator with Bad Gateway (ETIMEDOUT) — typically behind an IPv4-only VPN namespace such as Gluetun/WireGuard, where the IPv6 route is unreachable and the working IPv4 handshake needs more than 250 ms.

The backend therefore raises the per-attempt connection budget to 2500 ms at startup. This keeps the IPv6→IPv4 fallback fully automatic in both directions. If you pass --network-family-autoselection-attempt-timeout yourself through NODE_OPTIONS, your value wins and the backend leaves it untouched.

If a provider still fails, check the container logs first: outbound provider failures are logged with the target hostname and the underlying Node error codes (ETIMEDOUT, ENETUNREACH, ENOTFOUND, ...), and the same code is returned to the app in the error message. As a last resort you can pin the legacy behavior entirely:

services:
    iptvnator:
        environment:
            # Disables IPv6/IPv4 racing completely; only for networks where
            # IPv6 can never work. Breaks IPv6-only deployments.
            NODE_OPTIONS: '--dns-result-order=ipv4first --no-network-family-autoselection'

Local Validation

pnpm nx test web-backend
pnpm nx build web --configuration=pwa --skip-nx-cache
pnpm nx build web-backend
pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted
docker compose -f docker/docker-compose.yml config