Self-hosted, mobile-first TV Time clone: follow shows, mark episodes / shows / movies as watched, rate them, track watch-time and stats, search & follow content. Single-user (multi-user-ready schema). Docker-first — the only host dependency is Docker.
📱 The app is an installable PWA: add it to your home screen for a full-screen, offline-shell, native-feeling mobile experience.
The default setup is plain HTTP, no domain, no reverse proxy — the app is published straight to a host port.
# 1. Configure
cp .env.example .env
# then edit .env — at minimum set:
# TMDB_API_KEY (free key: https://www.themoviedb.org/settings/api)
# POSTGRES_PASSWORD + matching DATABASE_URL (openssl rand -hex 24)
# SESSION_SECRET (openssl rand -hex 32)
# APP_PORT defaults to 8080; PUBLIC_BASE_URL defaults to http://localhost:8080.
# 2. Bring the stack up (builds images, runs migrations, starts db + app)
docker compose up -dThen open http://<host-ip>:8080 on your phone (same LAN) or
http://localhost:8080 on the host, and log in.
Coming from TV Time? Import your history straight from the app: log in, then go to
Profile → Import from TV Time and upload your GDPR export .zip — a progress bar
shows the reconstruction live. See MIGRATION.md for the full
step-by-step guide (requesting the export, the TMDB key, and what gets imported).
⚠️ PWA over plain HTTP: service workers require a secure context (HTTPS orlocalhost). On a bare LAN IP likehttp://192.168.1.x:8080the service worker won't register, so install-to-home-screen / offline shell are disabled — but the app still works fine as a normal mobile web app. To get the full PWA back, enable HTTPS (see "Enabling HTTPS later" below).
Default (HTTP-first): the app is published directly to the host.
┌──────────────┐
LAN client ─► │ SvelteKit │ host :${APP_PORT:-8080} -> container :3000
│ (app) │ SSR + API + cron ──► TMDB API (outbound)
└──────┬───────┘
│
┌──────▼──────┐
│ PostgreSQL │ persistent volume
└─────────────┘
Optional (--profile tls): Caddy fronts the app for automatic HTTPS.
Internet ─► [ Caddy :443 TLS auto ] ─► app:3000 ─► PostgreSQL
| Service | Image / build | Role |
|---|---|---|
db |
postgres:16-alpine |
Database. Persistent named volume pgdata, healthchecked. |
migrate |
built from apps/web/Dockerfile |
One-shot: applies Drizzle migrations, then exits. Runs after db is healthy. |
app |
built from apps/web/Dockerfile |
SvelteKit server (SSR + API + PWA + nightly cron + in-app TV Time import). Published on ${APP_PORT:-8080}. Starts after migrate succeeds. |
caddy |
caddy:2-alpine |
Optional reverse proxy + automatic HTTPS on :443. Only runs under --profile tls. |
Startup order is enforced with healthchecks + depends_on
(db healthy → migrate completes → app starts; with --profile tls, caddy
waits for app to be healthy).
Plain HTTP is fine on a trusted LAN, but HTTPS restores the full PWA (installable,
offline shell) and lets cookies be marked Secure. Two paths:
Put the host on your Tailscale tailnet and let Tailscale
terminate TLS with a valid *.ts.net cert — reachable from your phone anywhere:
# on the host, with the stack already up on :8080
tailscale serve --bg 8080 # serves https://<host>.<tailnet>.ts.net -> :8080Then set PUBLIC_BASE_URL=https://<host>.<tailnet>.ts.net in .env and
docker compose up -d to pick it up. No caddy profile needed. This is the
simplest way to get a secure context (and the PWA) without owning a domain.
If you have a domain pointing at the host (ports 80/443 reachable):
-
Set
PUBLIC_BASE_URL=https://tv.example.comin.env(optionallyACME_EMAIL). -
Start the stack with the proxy:
docker compose --profile tls up -d
Caddy provisions and auto-renews a Let's Encrypt certificate and proxies to
app:3000. See caddy/Caddyfile (incl. a :80 localhost
fallback and how to enable the ACME e-mail).
showtrackr/ # repo root
├─ docker-compose.yml # the whole stack (DevOps)
├─ .env.example # all environment variables (DevOps)
├─ pnpm-workspace.yaml # pnpm workspace (DevOps)
├─ package.json # root scripts: dev / build / docker:up / import
├─ .npmrc # node-linker=hoisted (Docker-runtime friendly)
├─ caddy/Caddyfile # reverse proxy config (DevOps)
├─ apps/web/ # SvelteKit app (Frontend) — incl. apps/web/Dockerfile
│ # in-app TV Time import: src/lib/server/import/*
├─ packages/db/ # Drizzle schema + migrations (DB)
└─ packages/importer/ # Reconstruction pipeline (library used by the app) + CLI
All configuration lives in a single root .env (never committed). See
.env.example for the full annotated list:
DATABASE_URL, POSTGRES_*, TMDB_API_KEY, SESSION_SECRET, PUBLIC_BASE_URL,
APP_PORT, BODY_SIZE_LIMIT (upload cap for the in-app TV Time import),
ALLOW_REGISTRATION, ACME_EMAIL (optional).
The migrate service reuses the app image and runs pnpm run migrate inside
packages/db. That script is expected to be runtime-safe — i.e. it applies the
committed SQL migrations using drizzle-orm's migrator (drizzle-orm/postgres-js/migrator
or equivalent), not drizzle-kit, since dev tooling is pruned from the production
image. It must read DATABASE_URL and be idempotent.
docker compose up -d --build # rebuild + (re)start db + app (HTTP) (npm run docker:up)
docker compose logs -f app # tail the app logs
docker compose ps # service status
docker compose --profile tls up -d # also start the optional Caddy HTTPS proxy
docker compose down # stop (keeps volumes/data)
docker compose config # validate the compose fileLocal (non-Docker) development of the web app:
pnpm install
pnpm dev # -> pnpm --filter ./apps/web dev