Post

Deploy the complete WatchParty service with Docker, Traefik, Firebase, and Headscale

Run the full WatchParty service: synced rooms, chat, playlists, Firebase login, PostgreSQL persistence, Redis coordination, Traefik routing, and optional Headscale-only VBrowser.

Deploy the complete WatchParty service with Docker, Traefik, Firebase, and Headscale

WatchParty is a private watch-together service. It gives users shared rooms where playback state, playlist changes, chat, room permissions, screen sharing, and optional virtual browser sessions stay synchronized.

This guide documents the complete WatchParty service, not just the VBrowser feature. VBrowser matters because it is the most infrastructure-sensitive part, but it is only one component of the stack.

The complete homelab pattern is:

  • WatchParty runs as a Node/React application behind Traefik.
  • PostgreSQL persists rooms, owners, vanity links, room metadata, subscribers, and VBrowser assignments.
  • Redis supports fast runtime coordination and cache-style state.
  • Firebase handles native application login instead of a generic reverse-proxy login wall.
  • Rooms support synced media playback, playlists, chat, camera/video chat, screen sharing, subtitles, and room locking.
  • VBrowser containers are created on demand for sites that need a real shared browser.
  • VBrowser WebRTC can stay private over Headscale/Tailscale instead of public router port forwarding.
  • Old or broken VBrowser containers are removed automatically.
  • Container names are readable enough for operators to debug quickly.

All domains, IP addresses, OAuth IDs, API keys, and passwords in this post are placeholders. Replace them in your own environment, but do not publish real infrastructure details or secrets.


What this service does

Route or access pattern:

1
2
3
watch.example.com          # WatchParty web app
watch.example.com/vb-0/    # VBrowser web route through Traefik
<tailnet-ip>:59000         # private WebRTC UDP/TCP mux candidate

Main components:

1
WatchParty web UI, Node.js server, Socket.IO realtime rooms, PostgreSQL, Redis, Firebase Auth, Docker socket access for optional VBrowser, Neko/Chromium VBrowser container, Traefik, Headscale/Tailscale.

Network and port model:

1
2
3
4
5
6
Public HTTPS traffic:
Browser -> Traefik :443 -> WatchParty container :8080
Browser -> Traefik :443 -> VBrowser container :8080 under /vb-0

Private WebRTC media path:
Client on tailnet -> <server-tailnet-ip>:59000 -> VBrowser container mux port

The normal WatchParty app is a standard HTTPS web service. The special design choice is for VBrowser: its WebRTC mux port does not need to be forwarded publicly on the router. Users who need VBrowser join the private tailnet first.


Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
                              Public HTTPS

User browser
  ↓
https://watch.example.com
  ↓
Traefik
  ↓
WatchParty app
  ├── React web UI
  ├── Node.js / Socket.IO room server
  ├── PostgreSQL persistent room/account data
  ├── Redis runtime coordination
  ├── Firebase Auth verification
  └── Docker API launches optional disposable VBrowser


                              Private WebRTC

User device on Headscale/Tailscale
  ↓
<server-tailnet-ip>:59000 UDP/TCP
  ↓
Neko / Chromium VBrowser container

This split keeps the full WatchParty web service easy to reach while keeping the optional browser streaming path private.


Core WatchParty features

The base WatchParty service should work even when VBrowser is disabled. Document and verify these features first:

FeatureWhat it doesInfrastructure dependency
RoomsCreates isolated spaces where users watch togetherNode server, Socket.IO, PostgreSQL for persistence
Synced playbackPlay, pause, seek, timestamps, playback rate, loop stateSocket.IO realtime room events
PlaylistsAdd, move, remove, and advance media entriesRoom state and optional database persistence
ChatText chat, reactions, and moderation actionsSocket.IO room events
User identityNames, avatars, authenticated UID, room ownershipFirebase Auth and room state
Room controlLocking, owner-only settings, kicking, disabled chatFirebase UID + owner/lock checks
Video chatPeer signaling for camera/microphone callsHTTPS + WebRTC signaling
Screen sharingShare a tab, window, or screen into the roomHTTPS + browser permissions + WebRTC signaling
Stream-your-own-fileLet users share local/video files through the browserBrowser APIs and room signaling
Internet mediaPlay direct HTTP video files and HLS streamsClient/server media handling
YouTube search/playbackOptional search integrationYouTube Data API key if search is enabled
Magnet/WebTorrentOptional torrent-style media sharingWebTorrent/browser support
VBrowserShared browser for websites that resist normal embeddingDocker + Neko + WebRTC networking

That order matters operationally. If rooms, login, and synced playback are broken, VBrowser cleanup will not save the service.


Room and permission model

WatchParty is room-centered rather than classic admin-dashboard-centered.

Important room concepts:

1
2
3
4
5
6
7
8
9
roomId       unique room identifier
vanity       optional human-readable room path/name
owner        Firebase UID that owns the room
password     optional room password
lock         optional UID allowed to control playback
roster       currently connected users
playlist     queued media entries
chat         room chat history/state
mediaPath    currently hosted or active media path

A clean permission model is:

  • anonymous or logged-in users can join public rooms, depending on room settings;
  • authenticated Firebase users can own persistent rooms;
  • the room owner can change room-level settings;
  • a locked room can restrict playback controls to one UID;
  • subscriber flags can unlock optional subscriber-only behavior;
  • VBrowser control should follow the same room lock/owner rules as other room control actions.

This avoids inventing a global admin/user model when the application is mostly based on rooms, owners, and authenticated UIDs.


Data model and persistence

PostgreSQL is what makes the service survive restarts cleanly.

Useful persistent tables include:

1
2
3
4
5
room          room metadata, owner, vanity name, media state, room settings
subscriber    subscription/customer status mapped to Firebase UID
link_account  linked external accounts
active_user   recent activity tracking
vbrowser      optional VM/container assignment state

Redis is runtime infrastructure. Treat it as fast coordination/cache state, not the only copy of important long-term room data.

Back up PostgreSQL seriously. Back up Redis only if you configure it to hold state you cannot recreate.


Media and realtime sync flow

A normal WatchParty session looks like this:

1
2
3
4
5
6
7
8
9
10
11
User opens room
  ↓
React app connects to the Node/Socket.IO namespace for that room
  ↓
Server sends room state: host media, playlist, roster, chat, lock state
  ↓
A controller plays, pauses, seeks, changes playlist, or updates subtitles
  ↓
Socket.IO broadcasts the change to every connected client
  ↓
Clients adjust playback to stay synchronized

For regular video URLs or HLS streams, the media path is the main state. For WebRTC-based features like video chat, screen sharing, or VBrowser, the room server also handles signaling so peers can negotiate media connections.


Optional integrations

Enable only what you use:

  • Firebase Auth for accounts, room ownership, and identity verification.
  • YouTube Data API if you want YouTube search inside the app.
  • Stripe/subscriptions if you use subscriber-only features.
  • Discord bot/linking if you expose Discord integration.
  • VBrowser/Docker if you want shared browsing.
  • Headscale/Tailscale if you want private WebRTC paths without public port forwarding.

Keeping optional integrations explicit makes the service easier to secure and debug.

Folder layout

Use one service directory:

1
2
3
4
5
6
7
8
/home/ubuntu/watchparty/
├── app/                    # cloned WatchParty source repository
├── docker-compose.yml
├── .env                    # private
├── secrets/
│   └── firebase-adminsdk.json
├── postgres/               # or a named Docker volume
└── redis/                  # or a named Docker volume

Keep the Firebase Admin SDK file and .env private:

1
chmod 600 .env secrets/firebase-adminsdk.json

Never commit those files to Git.


Get the WatchParty source repository

The Docker build expects the WatchParty application source to exist at ./app.

Clone the upstream WatchParty repository into the service directory:

1
2
3
4
mkdir -p /home/ubuntu/watchparty
cd /home/ubuntu/watchparty

git clone https://github.com/howardchung/watchparty.git app

If you prefer SSH and already have deploy keys configured:

1
git clone [email protected]:howardchung/watchparty.git app

After cloning, inspect the app structure:

1
2
ls -la app
ls -la app/server app/src app/sql

The important files for this deployment are:

1
2
3
4
5
6
7
app/Dockerfile
app/package.json
app/server/server.ts
app/server/config.ts
app/server/vm/docker.ts
app/sql/schema.sql
app/sql/migrations.sql

If you maintain local patches, commit them in your own fork or keep a small deployment patch script. Do not edit files manually on the server without recording what changed, or upgrades will become painful.

Recommended fork workflow:

1
2
3
4
5
6
cd /home/ubuntu/watchparty/app

git remote -v
# origin should point to your fork if you want to keep custom production patches.

git checkout -b homelab-production

Update workflow later:

1
2
3
4
5
cd /home/ubuntu/watchparty/app

git fetch --all
git status
# Review upstream changes, reapply your local patches, then rebuild.

Environment files

Treat environment configuration as real files, not loose notes. I like to show it in two parts:

  1. a required production .env that the Compose stack actually loads;
  2. optional .env additions you only add when enabling extra integrations.

Create the real file here:

1
2
nano /home/ubuntu/watchparty/.env
chmod 600 /home/ubuntu/watchparty/.env

Required baseline:

# Public app route
WATCHPARTY_HOST=watch.example.com
NODE_ENV=production
PORT=8080

# PostgreSQL container bootstrap
POSTGRES_USER=watchparty
POSTGRES_PASSWORD=<generate-a-long-database-password>
POSTGRES_DB=watchparty

# App database/runtime connections
DATABASE_URL=postgres://watchparty:<same-database-password>@postgres:5432/watchparty
REDIS_URL=redis://redis:6379

# Firebase web login config
VITE_FIREBASE_API_KEY=<firebase-web-api-key>
VITE_FIREBASE_AUTH_DOMAIN=<project>.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=<firebase-project-id>
VITE_FIREBASE_APP_ID=<firebase-web-app-id>
VITE_FIREBASE_SIGNIN_METHODS=github,google,email

# Firebase server verification config
FIREBASE_PROJECT_ID=<firebase-project-id>
FIREBASE_CLIENT_EMAIL=<firebase-admin-client-email>
FIREBASE_PRIVATE_KEY_FILE=/run/secrets/firebase-adminsdk.json

Optional additions, only when you use these features:

# YouTube search inside WatchParty
YOUTUBE_API_KEY=<youtube-data-api-key-if-used>

# Paid/subscriber features, if enabled
STRIPE_SECRET_KEY=<stripe-secret-if-used>

# Discord integration, if enabled
DISCORD_BOT_TOKEN=<discord-token-if-used>
DISCORD_ADMIN_BOT_TOKEN=<discord-admin-token-if-used>

# Optional VBrowser / Neko
VBROWSER_HOSTNAME=watch.example.com
VBROWSER_TAILNET_IP=<server-tailnet-ip>
VBROWSER_MUX_PORT=59000
VM_MANAGER_CONFIG=<json-or-empty-depending-on-your-deployment>
VBROWSER_SESSION_SECONDS=10800

Why split it this way:

  • the required block is enough to boot the main WatchParty service;
  • optional integrations stay visibly optional;
  • secrets are easier to audit;
  • readers can copy the required file without accidentally enabling Stripe, Discord, or VBrowser.

Notes:

  • Firebase web config is not the same as the Admin SDK secret.
  • OAuth provider client secrets stay in Firebase or the provider console, not in public docs.
  • YouTube, Stripe, Discord, and VBrowser settings are optional. Do not add secrets for integrations you do not use.
  • The tailnet IP should be private to your Headscale/Tailscale network.
  • Keep this file out of Git and readable only by the deployment user.

Compose pattern

A simplified Compose pattern looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
services:
  watchparty:
    build: ./app
    container_name: watchparty
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./secrets/firebase-adminsdk.json:/run/secrets/firebase-adminsdk.json:ro
    networks:
      - proxy
      - internal
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.watchparty.entrypoints=https"
      - "traefik.http.routers.watchparty.rule=Host(`watch.example.com`)"
      - "traefik.http.routers.watchparty.tls=true"
      - "traefik.http.routers.watchparty.tls.certresolver=cloudflare"
      - "traefik.http.services.watchparty.loadbalancer.server.port=8080"

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U watchparty"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

networks:
  proxy:
    external: true
  internal:
    internal: true

For a production-style deployment, add a few hardening details:

  • initialize PostgreSQL from sql/schema.sql on first boot;
  • keep Redis persistent if you care about runtime continuity during restarts;
  • bind any debug host port to 127.0.0.1 only;
  • attach the app to both internal and proxy, but keep databases only on internal;
  • use healthchecks so Compose starts the app only after dependencies are ready.

A more complete pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./app/sql/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 30s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 5s
      retries: 5

  watchparty:
    build:
      context: ./app
    image: local/watchparty:latest
    container_name: watchparty
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./secrets/firebase-adminsdk.json:/run/secrets/firebase-adminsdk.json:ro
    ports:
      - "127.0.0.1:8088:8080" # optional local-only debug access
    networks:
      - internal
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.watchparty.entrypoints=http"
      - "traefik.http.routers.watchparty.rule=Host(`watch.example.com`)"
      - "traefik.http.middlewares.watchparty-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.routers.watchparty.middlewares=watchparty-https-redirect"
      - "traefik.http.routers.watchparty-secure.entrypoints=https"
      - "traefik.http.routers.watchparty-secure.rule=Host(`watch.example.com`)"
      - "traefik.http.routers.watchparty-secure.tls=true"
      - "traefik.http.routers.watchparty-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.watchparty-secure.service=watchparty"
      - "traefik.http.services.watchparty.loadbalancer.server.port=8080"
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8080/ || exit 1"]
      interval: 60s
      timeout: 10s
      retries: 5
      start_period: 90s

volumes:
  postgres_data:
  redis_data:

networks:
  internal:
  proxy:
    external: true

If VBrowser runs Docker containers on the same host, the app needs a controlled way to start them. Some deployments mount the Docker socket; others SSH back to the Docker host. Either way, treat that permission as privileged operator access, not a normal web-app permission.


Database initialization and migrations

On a fresh install, PostgreSQL can load the initial schema automatically when the data volume is empty:

1
2
volumes:
  - ./app/sql/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro

That only runs on first database creation. If the database volume already exists, apply schema changes intentionally:

1
docker compose exec -T postgres psql -U watchparty -d watchparty < app/sql/migrations.sql

Before applying migrations:

1
docker compose exec postgres pg_dump -U watchparty watchparty > backups/watchparty-before-migration.sql

For a small homelab, this simple workflow is usually enough:

  1. back up PostgreSQL;
  2. rebuild the WatchParty image;
  3. apply any SQL migrations;
  4. restart the app;
  5. verify login, room creation, and playback sync.

Firebase Auth setup

In Firebase Authentication:

  1. Add your public WatchParty domain as an authorized domain.
  2. Enable the sign-in methods you want, for example:
    • Email/password
    • Google
    • GitHub
  3. For GitHub OAuth, configure the callback URL shown by Firebase:
1
https://<project>.firebaseapp.com/__/auth/handler

In the frontend, redirect-based OAuth is usually more reliable than popup-based OAuth when the app runs behind reverse proxies, strict browsers, or mobile webviews.

Use Firebase custom claims only if the app actually needs global roles. Many room-based apps can avoid classic admin/user roles and instead rely on:

  • authenticated Firebase UID;
  • room owner permissions;
  • per-room membership;
  • optional subscription flags.

Optional VBrowser launch model

The VBrowser container should be disposable. A good launch flow is:

  1. Remove stale containers with the VBrowser label.
  2. Generate a fresh browser password.
  3. Generate a Docker-safe container name.
  4. Start Neko/Chromium with one mux port.
  5. Add Traefik labels for the /vb-0 route.
  6. Return the container ID to WatchParty so it can terminate the session later.

A safe name format is:

1
vbrowser-YYYYMMDD-HHMMSS-randomid

Example:

1
vbrowser-20260513-121500-a1b2c3d4

Avoid / and : in Docker names. They are useful in human timestamps, but invalid for container names.


Optional Headscale-only VBrowser WebRTC

Neko can use a single mux port for WebRTC instead of a wide range of dynamic ports.

Example environment for the VBrowser container:

NEKO_WEBRTC_UDPMUX=59000
NEKO_WEBRTC_TCPMUX=59000
NEKO_WEBRTC_NAT1TO1=<server-tailnet-ip>

The NAT candidate should be the server’s private tailnet IP, not the public internet address, when the goal is Headscale-only access.

That gives a clean rule:

1
Only devices connected to the tailnet can complete the VBrowser media path.

This is especially useful when you do not want to open UDP/TCP WebRTC ports on the home router.


Optional Traefik route for VBrowser

The VBrowser web UI can still route through public HTTPS under a path prefix:

1
2
3
4
5
6
7
8
9
10
11
labels:
  - "traefik.enable=true"
  - "traefik.docker.network=proxy"
  - "traefik.http.routers.vbrowser-0.entrypoints=https"
  - "traefik.http.routers.vbrowser-0.priority=100"
  - "traefik.http.routers.vbrowser-0.rule=Host(`watch.example.com`) && PathPrefix(`/vb-0`)"
  - "traefik.http.routers.vbrowser-0.middlewares=vbrowser-0-stripprefix"
  - "traefik.http.middlewares.vbrowser-0-stripprefix.stripprefix.prefixes=/vb-0"
  - "traefik.http.routers.vbrowser-0.tls=true"
  - "traefik.http.routers.vbrowser-0.tls.certresolver=cloudflare"
  - "traefik.http.services.vbrowser-0.loadbalancer.server.port=8080"

The HTTPS route loads the VBrowser interface. The WebRTC media connection still uses the private mux candidate.


VBrowser cleanup policy

VBrowser containers should not become permanent pets. They should be cattle:

  • remove stale VBrowser-labeled containers before launching a new one;
  • stop the container when the room closes;
  • stop the container when the room has been empty and idle for a short grace period;
  • keep a hard maximum session duration;
  • use --rm on the VBrowser docker run command when possible.

Useful operator checks:

1
docker ps -a --filter label=vbrowserUS

Remove stale VBrowser containers safely by label:

1
docker ps -aq --filter label=vbrowserUS | xargs -r docker rm -f

Be careful with broad Docker cleanup commands. Removing by a dedicated label is much safer than deleting every stopped container on the host.


Deployment steps

Create the service directory and clone the app:

1
2
3
4
mkdir -p /home/ubuntu/watchparty/secrets
cd /home/ubuntu/watchparty

git clone https://github.com/howardchung/watchparty.git app

If you already cloned it, update or inspect it instead:

1
2
3
4
cd /home/ubuntu/watchparty/app
git status
git pull --ff-only
cd ..

Create private config:

1
2
nano .env
chmod 600 .env

Add the Firebase Admin SDK JSON:

1
2
nano secrets/firebase-adminsdk.json
chmod 600 secrets/firebase-adminsdk.json

Build and start from the service root:

1
2
3
4
cd /home/ubuntu/watchparty

docker compose build watchparty
docker compose up -d

This works because the Compose file uses build.context: ./app, so Docker builds the cloned WatchParty repository.

Watch logs during the first boot:

1
docker compose logs -f watchparty

Operations runbook

Useful daily commands:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Service status
docker compose ps

# App logs
docker compose logs --tail=200 watchparty

# Database logs
docker compose logs --tail=100 postgres

# Redis health
docker compose exec redis redis-cli ping

# Public HTTP check
curl -I https://watch.example.com

Inspect persistent rooms:

1
docker compose exec postgres psql -U watchparty -d watchparty -c   'select "roomId", vanity, owner, "lastUpdateTime" from room order by "lastUpdateTime" desc limit 10;'

Inspect active VBrowser assignments:

1
docker compose exec postgres psql -U watchparty -d watchparty -c   'select id, pool, vmid, state, "roomId", "assignTime" from vbrowser order by id desc limit 10;'

Clean only VBrowser containers, never the whole Docker host:

1
docker ps -aq --filter label=vbrowserUS | xargs -r docker rm -f

Upgrade workflow:

1
2
3
4
5
6
7
8
mkdir -p backups

docker compose exec postgres pg_dump -U watchparty watchparty > backups/watchparty-$(date -u +%Y%m%d-%H%M%S).sql

docker compose build watchparty
docker compose up -d

docker compose logs --tail=100 watchparty

Rollback is just as important as upgrade:

1
2
3
4
# Example rollback idea: use the previous image tag or restore from backup.
# Exact commands depend on how you tag builds in your own deployment.
docker compose down
docker compose up -d

For real production, tag images instead of relying only on latest.


Verification checklist

Verify the app container is healthy:

1
docker compose ps

Verify the public route:

1
curl -I https://watch.example.com

Verify Firebase login and core room behavior in a real browser:

1
2
3
4
5
6
7
8
Open https://watch.example.com
Sign in with an enabled provider
Create a room
Open the room in a second browser/device
Test chat
Test playlist add/remove
Test play, pause, seek, and timestamp sync
Test room owner settings or lock behavior if enabled

Verify persistence after restart:

1
docker compose restart watchparty

Then reopen a persistent room and confirm metadata, owner settings, and playlist behavior are still correct.

If you use VBrowser, verify the server has a tailnet IP:

1
tailscale ip -4

Verify a VBrowser session creates a readable container name:

1
2
3
docker ps --filter label=vbrowserUS --format 'table {{.Names}}	{{.Status}}	{{.Ports}}'

Expected name pattern:

1
vbrowser-20260513-121500-a1b2c3d4

Verify cleanup after stopping or leaving the room:

1
docker ps -a --filter label=vbrowserUS

The list should become empty after the app’s cleanup grace period.


Backup checklist

Back up:

1
2
3
4
5
6
/home/ubuntu/watchparty/docker-compose.yml
/home/ubuntu/watchparty/.env                           # private backup only
/home/ubuntu/watchparty/secrets/firebase-adminsdk.json  # private backup only
/home/ubuntu/watchparty/app/                            # if locally patched
PostgreSQL data or dumps
Optional uploaded/media directories, if configured

Create database dumps in addition to volume backups:

1
docker compose exec postgres pg_dump -U watchparty watchparty > watchparty.sql

Do not publish database dumps. They can contain user accounts, room history, tokens, and application state.


Monitoring and maintenance

Minimum checks worth automating:

CheckWhy it mattersExample
HTTPS routeConfirms Traefik, DNS, and app are reachablecurl -I https://watch.example.com
App healthcheckConfirms Node server respondsdocker compose ps watchparty
PostgreSQL backup agePrevents silent data-loss riskcheck newest file in backups/
Redis healthCatches runtime coordination issuesredis-cli ping
VBrowser leftoversKeeps the host cleandocker ps -a --filter label=vbrowserUS
Disk usageVideo/browser workloads can grow logs fastdf -h and Docker log limits

Suggested maintenance rhythm:

  • daily: check containers are healthy;
  • weekly: verify a database dump can be created;
  • weekly: remove stale VBrowser containers by label if any exist;
  • monthly: test restore in a temporary database;
  • after every upgrade: create a room, test chat, playlist, playback sync, login, and VBrowser if enabled.

Common problems

Login works but room ownership does not

Check that the frontend sends a valid Firebase ID token and the server verifies it with the Firebase Admin SDK. Also confirm the Firebase project ID in the frontend config matches the Admin SDK project.

Docker build fails because ./app does not exist

The WatchParty source repository was not cloned into the expected path. From the service root, run:

1
2
cd /home/ubuntu/watchparty
git clone https://github.com/howardchung/watchparty.git app

Then rebuild:

1
docker compose build watchparty

Rooms disappear after restart

Room persistence is missing or the database schema was not initialized. Check DATABASE_URL, PostgreSQL connectivity, and migrations/schema setup.

Playback is not synchronized

Check Socket.IO connectivity in the browser devtools network tab. If the socket disconnects or falls back badly through the proxy, inspect Traefik websocket headers and the WatchParty server logs.

Chat, roster, or playlist updates lag

Check Redis and server load first. Then inspect whether the app container has enough CPU/memory and whether reverse-proxy websocket timeouts are too aggressive.

YouTube search does not work

The basic service can still play direct media without YouTube search. If search is required, verify YOUTUBE_API_KEY, API enablement, quotas, and restrictions in Google Cloud.

Screen sharing or camera does not work

These browser APIs require HTTPS and user permission. Verify the page is loaded over HTTPS and that browser/site permissions are not blocked.

VBrowser page loads but the stream does not connect

Check that the client is connected to the Headscale/Tailscale network and can reach the server tailnet IP:

1
ping <server-tailnet-ip>

Then check that the mux port is reachable from a tailnet client. If it is not, inspect host firewall rules and container port publishing.

Browser container starts with an ugly random Docker name

Give the VBrowser launch command an explicit --name value:

1
--name="vbrowser-$(date -u +%Y%m%d-%H%M%S)-$RANDOM_ID"

Do not use / or : in the name.

Stale VBrowser containers block new sessions

Make the launch path remove old containers with the VBrowser label before starting a new one:

1
docker ps -aq --filter label=vbrowserUS | xargs -r docker rm -f

Also confirm that normal room shutdown calls the app’s VM release or terminate function.

OAuth login fails after redirect

Check:

  • Firebase authorized domains;
  • OAuth provider callback URL;
  • frontend Firebase config;
  • browser console redirect errors;
  • whether the app uses redirect login when popups are blocked.

Traefik returns 404 under /vb-0

Check:

  • the router rule includes PathPrefix("/vb-0");
  • the strip-prefix middleware is attached;
  • the VBrowser container is on the proxy Docker network;
  • the router priority is higher than the main WatchParty route if needed.

Security notes

  • Protect the main WatchParty route with the app’s native auth model, not a reverse-proxy wall that breaks login callbacks.
  • Keep Firebase Admin SDK credentials private and mode 600.
  • Do not expose PostgreSQL, Redis, or internal admin APIs directly to the internet.
  • Do not publish the Docker socket through a web API.
  • Enable optional integrations only when you actually use them.
  • Prefer Headscale-only VBrowser WebRTC when you do not want public media ports.
  • Use labels for targeted VBrowser cleanup instead of broad host-wide cleanup.
  • Keep public documentation generic: use watch.example.com, <server-tailnet-ip>, and <secret> placeholders.
  • If you expose any WebRTC ports publicly, document the firewall rule and why it is necessary.

Service documentation map

This post connects to the rest of the homelab stack:

This post is licensed under CC BY 4.0 by the author.