chore: production docker + compose for LAN deployment

This commit is contained in:
2026-02-12 11:22:47 +01:00
parent 3a0bb1cd37
commit e1c939799b
9 changed files with 218 additions and 4 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.git/
.venv/
__pycache__/
*.pyc
node_modules/
# local runtime state
logs/
*.log
syncplayer.db
*.db
# media uploads should be mounted via volume
media/
*.md
.vscode/

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ syncplayer.db
media/*
!media/idle_image.png
# Docker compose persistent data directory
data/
# OS / IDE
.DS_Store
Thumbs.db

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps (kept minimal). We install curl for a simple container healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -r -u 10001 -g root syncplayer
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy app code
COPY . .
# Default persistent directories (can be mounted).
ENV DATA_DIR=/data \
MEDIA_DIR=/data/media \
LOG_DIR=/data/logs \
HOST=0.0.0.0 \
PORT=5000 \
ASYNC_MODE=eventlet
RUN mkdir -p /data/media /data/logs \
&& chown -R syncplayer:root /data
USER syncplayer
EXPOSE 5000
# Basic HTTP healthcheck (admin redirect)
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:${PORT}/ >/dev/null || exit 1
# Gunicorn + eventlet is a solid choice for Flask-SocketIO.
# We intentionally run a single worker to avoid starting multiple UDP listeners.
CMD ["gunicorn", "-k", "eventlet", "-w", "1", "-b", "0.0.0.0:5000", "wsgi:app"]

View File

@@ -107,6 +107,49 @@ Open display pages:
- `http://<server-ip>:5000/display/<public_id>`
## Docker (production-ready)
This repo includes a `Dockerfile` and `docker-compose.yml` suitable for LAN deployments.
### Docker networking for LAN + UDP triggers
SyncPlayer listens on **UDP ports configured per Event**.
Docker has two workable strategies:
1) **Publish a UDP port range** (works on Docker Desktop + Linux)
- The default `syncplayer` service publishes `7000-7999/udp`.
- Keep your Event UDP ports within that range.
2) **Host networking** (Linux only)
- Use `docker compose --profile hostnet up -d --build`
- Dynamic UDP ports work without pre-declaring a range.
- Discovery/casting protocols (mDNS/SSDP/etc.) also work best here.
In both cases HTTP + Socket.IO is reachable at `http://<host-ip>:5000`.
### Run
```bash
docker compose up -d --build
```
Data is persisted in `./data/` on the host:
- `./data/syncplayer.db`
- `./data/media/` (uploaded videos + idle image)
- `./data/logs/system.log`
Then open:
- Admin: `http://<host-ip>:5000/admin/`
- Displays: `http://<host-ip>:5000/display/<public_id>`
### Notes
- Default Gunicorn configuration runs **1 worker** to avoid starting multiple UDP listeners.
- Change `SECRET_KEY` in `docker-compose.yml` for real deployments.
## UDP trigger
Configure UDP Port + UDP Payload on an Event in the admin UI. The UDP listener binds ports automatically (refreshes config every 5s).

View File

@@ -55,8 +55,9 @@ def create_app() -> Flask:
def _configure_logging(app: Flask) -> None:
os.makedirs(os.path.join(app.root_path, "..", "logs"), exist_ok=True)
log_path = os.path.abspath(os.path.join(app.root_path, "..", "logs", "system.log"))
log_dir = app.config.get("LOG_DIR") or os.path.abspath(os.path.join(app.root_path, "..", "logs"))
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.abspath(os.path.join(log_dir, "system.log"))
handler = RotatingFileHandler(log_path, maxBytes=2_000_000, backupCount=3)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
handler.setFormatter(fmt)

View File

@@ -6,11 +6,17 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
DB_PATH = os.path.join(BASE_DIR, "syncplayer.db")
# Container-friendly persistent data root.
# Defaults to project root for local dev.
DATA_DIR = os.environ.get("DATA_DIR", BASE_DIR)
DB_PATH = os.path.join(DATA_DIR, "syncplayer.db")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}")
SQLALCHEMY_TRACK_MODIFICATIONS = False
MEDIA_DIR = os.environ.get("MEDIA_DIR", os.path.join(BASE_DIR, "media"))
MEDIA_DIR = os.environ.get("MEDIA_DIR", os.path.join(DATA_DIR, "media"))
LOG_DIR = os.environ.get("LOG_DIR", os.path.join(DATA_DIR, "logs"))
MAX_CONTENT_LENGTH = int(os.environ.get("MAX_CONTENT_LENGTH", str(2 * 1024 * 1024 * 1024))) # 2GB
# Socket.IO tuning for LAN

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
services:
# Default service: works on Docker Desktop (Windows/macOS) and Linux.
# Exposes HTTP and a *range* of UDP ports for triggers.
# Keep your Event UDP ports within this published range.
syncplayer:
build: .
ports:
- "5000:5000/tcp"
# UDP trigger ports (edit range if needed)
- "7000-7999:7000-7999/udp"
environment:
# Persistent data root inside container (mounted below)
DATA_DIR: /data
MEDIA_DIR: /data/media
LOG_DIR: /data/logs
# Flask-SocketIO async backend for Linux containers
ASYNC_MODE: eventlet
# App bind settings (when using host networking, PORT must match gunicorn bind)
HOST: 0.0.0.0
PORT: 5000
# Change this in production
SECRET_KEY: change-me
# Keep CORS permissive for LAN kiosk browsers
CORS_ORIGINS: "*"
volumes:
- ./data:/data
restart: unless-stopped
# Optional: host-networked service for Linux hosts.
# Use: docker compose --profile hostnet up -d --build
# This avoids needing to pre-declare UDP ports (dynamic ports just work).
syncplayer-host:
profiles: ["hostnet"]
build: .
network_mode: host
environment:
DATA_DIR: /data
MEDIA_DIR: /data/media
LOG_DIR: /data/logs
ASYNC_MODE: eventlet
HOST: 0.0.0.0
PORT: 5000
SECRET_KEY: change-me
CORS_ORIGINS: "*"
volumes:
- ./data:/data
restart: unless-stopped

View File

@@ -1,5 +1,7 @@
Flask==3.0.2
Flask-SocketIO==5.3.6
# Production WSGI server
gunicorn==21.2.0
# Async backends (optional). On Windows/Python 3.14+ these often have no wheels / compatibility issues.
eventlet==0.35.2; platform_system != "Windows" and python_version < "3.14"
gevent==24.2.1; platform_system != "Windows" and python_version < "3.14"

39
wsgi.py Normal file
View File

@@ -0,0 +1,39 @@
"""WSGI entrypoint for production deployments.
This module is what Gunicorn imports.
We also start background tasks that are normally started in run.py:
- UDP trigger listener
- event expiry monitor
"""
from __future__ import annotations
import os
from app import create_app
from models import db
from sockets import start_event_monitor
from udp_listener import start_udp_listener
app = create_app()
# Ensure DB exists on first boot (SQLite-based default).
with app.app_context():
db.create_all()
def _start_background_tasks() -> None:
start_udp_listener(app)
start_event_monitor(app)
# With Gunicorn the module can be imported multiple times (worker processes).
# This app is designed for LAN/kiosk setups; we intentionally run a single worker
# by default to avoid duplicated UDP listeners.
if os.environ.get("SYNCPLAYER_START_TASKS", "1") in ("1", "true", "yes"):
_start_background_tasks()
# Note: create_app() calls socketio.init_app(app), which wraps app.wsgi_app.
# For Gunicorn, we can export the Flask app directly.