From c9ad4fc5d088df449cfeecb4cf6bf009e5d80703 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 06:57:25 -0500 Subject: [PATCH] R021/R022/R026: Docker, CI/CD, deployment example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile (multi-stage): - Stage 1: Node 22 builds frontend (npm ci + npm run build) - Stage 2: Python 3.12 installs backend deps - Stage 3: Slim runtime with ffmpeg + deno (yt-dlp needs both) - Non-root user (mediarip), healthcheck, PYTHONUNBUFFERED - Volumes: /downloads (media), /data (SQLite DB) docker-compose.example.yml: - Caddy reverse proxy with automatic TLS via Let's Encrypt - Separate Caddyfile.example for domain configuration - Health-dependent startup ordering - Environment variables for admin setup CI/CD (.github/workflows/): - ci.yml: backend lint+test, frontend typecheck+test, Docker smoke build. Runs on PRs and pushes to main. - publish.yml: multi-platform build (amd64+arm64), pushes to ghcr.io/xpltd/media-rip on v*.*.* tags. Semantic version tags (v1.0.0 → latest + 1.0.0 + 1.0 + 1). Auto GitHub Release. .dockerignore: excludes dev artifacts, .gsd/, node_modules/, .venv/ --- .dockerignore | 56 ++++++++--------- .github/workflows/ci.yml | 73 +++++++++-------------- .github/workflows/publish.yml | 61 +++++++++++++++++++ Caddyfile.example | 4 ++ Dockerfile | 109 +++++++++++++--------------------- docker-compose.example.yml | 71 ++++++++++++---------- frontend/vite.config.ts | 2 +- 7 files changed, 202 insertions(+), 174 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 Caddyfile.example diff --git a/.dockerignore b/.dockerignore index 5398fe0..6f7a4cb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,37 +1,33 @@ -# Dependencies -node_modules/ -.venv/ +# Ignore Python / build artifacts __pycache__/ *.pyc - -# Build artifacts -frontend/dist/ +.venv/ +venv/ *.egg-info/ +dist/ +build/ -# Development files -.git/ +# Ignore Node artifacts +node_modules/ +frontend/dist/ + +# Ignore dev/test files .gsd/ -.planning/ -.github/ -.vscode/ -*.md -!README.md - -# Test files -backend/tests/ -frontend/src/tests/ -frontend/vitest.config.ts - -# OS files -.DS_Store -Thumbs.db - -# Docker -docker-compose*.yml -Dockerfile -.dockerignore - -# Misc +.bg-shell/ +*.log +coverage/ +.cache/ +tmp/ .env .env.* -*.log + +# Ignore git +.git/ +.gitignore + +# Ignore IDE +.idea/ +.vscode/ +*.code-workspace +*.swp +*.swo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22cd3a9..74f00e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,15 @@ +# CI — runs on all PRs and pushes to main name: CI on: - pull_request: - branches: [main, master] push: branches: [main, master] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + pull_request: + branches: [main, master] jobs: backend: - name: Backend (Python) + name: Backend (lint + test) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,69 +18,55 @@ jobs: with: python-version: "3.12" cache: pip - cache-dependency-path: backend/requirements.txt - name: Install dependencies - working-directory: backend run: | - python -m pip install --upgrade pip + cd backend pip install -r requirements.txt - pip install pytest pytest-asyncio pytest-anyio httpx ruff + pip install ruff pytest pytest-asyncio httpx anyio[trio] - - name: Lint (ruff) - working-directory: backend - run: ruff check app/ + - name: Lint + run: cd backend && ruff check . - - name: Type check (optional) - working-directory: backend - continue-on-error: true - run: ruff check app/ --select=E,W,F - - - name: Test (pytest) - working-directory: backend - run: python -m pytest tests/ -v --tb=short + - name: Test + run: cd backend && python -m pytest tests/ -q -m "not integration" frontend: - name: Frontend (Vue 3) + name: Frontend (typecheck + test) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm cache-dependency-path: frontend/package-lock.json - name: Install dependencies - working-directory: frontend - run: npm ci + run: cd frontend && npm ci - - name: Type check (vue-tsc) - working-directory: frontend - run: npx vue-tsc --noEmit + - name: Typecheck + run: cd frontend && npx vue-tsc --noEmit - - name: Test (vitest) - working-directory: frontend - run: npx vitest run - - - name: Build - working-directory: frontend - run: npm run build + - name: Test + run: cd frontend && npx vitest run docker: - name: Docker Build + name: Docker build (smoke test) runs-on: ubuntu-latest needs: [backend, frontend] steps: - uses: actions/checkout@v4 - - name: Build image - run: docker build -t media-rip:ci . + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Smoke test - run: | - docker run -d --name mediarip-test -p 8000:8000 media-rip:ci - sleep 5 - curl -f http://localhost:8000/api/health - docker stop mediarip-test + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: media-rip:ci-test + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5bc4a6e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +# Publish — builds and pushes Docker image on version tags +name: Publish + +on: + push: + tags: ["v*.*.*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: xpltd/media-rip + +jobs: + publish: + name: Build & push image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU (for arm64) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..093385b --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,4 @@ +# Replace YOUR_DOMAIN with your actual domain +YOUR_DOMAIN { + reverse_proxy media-rip:8000 +} diff --git a/Dockerfile b/Dockerfile index bbc7c61..08c3952 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,93 +1,66 @@ -# media.rip() Docker Build +# media.rip() — multi-stage Docker build +# Stage 1: Build frontend (Node) +# Stage 2: Install backend deps (Python) +# Stage 3: Slim runtime with ffmpeg # -# Multi-stage build: -# 1. frontend-build: Install npm deps + build Vue 3 SPA -# 2. backend-deps: Install Python deps into a virtual env -# 3. runtime: Copy built assets + venv into minimal image -# -# Usage: -# docker build -t media-rip . -# docker run -p 8080:8000 -v ./downloads:/downloads media-rip +# Image: ghcr.io/xpltd/media-rip +# Platforms: linux/amd64, linux/arm64 -# ══════════════════════════════════════════ -# Stage 1: Build frontend -# ══════════════════════════════════════════ -FROM node:20-slim AS frontend-build - -WORKDIR /build -COPY frontend/package.json frontend/package-lock.json ./ -RUN npm ci --no-audit --no-fund +# ── Stage 1: Frontend build ────────────────────────────────────────── +FROM node:22-slim AS frontend-builder +WORKDIR /build/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci --ignore-scripts COPY frontend/ ./ RUN npm run build -# ══════════════════════════════════════════ -# Stage 2: Install Python dependencies -# ══════════════════════════════════════════ -FROM python:3.12-slim AS backend-deps +# ── Stage 2: Python dependencies ───────────────────────────────────── +FROM python:3.12-slim AS python-deps WORKDIR /build - -# Install build tools needed for some pip packages (bcrypt, etc.) -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - && rm -rf /var/lib/apt/lists/* - COPY backend/requirements.txt ./ -RUN python -m venv /opt/venv && \ - /opt/venv/bin/pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt -# ══════════════════════════════════════════ -# Stage 3: Runtime image -# ══════════════════════════════════════════ +# ── Stage 3: Runtime ───────────────────────────────────────────────── FROM python:3.12-slim AS runtime -LABEL org.opencontainers.image.title="media.rip()" -LABEL org.opencontainers.image.description="Self-hostable yt-dlp web frontend" -LABEL org.opencontainers.image.source="https://github.com/jlightner/media-rip" +# Install ffmpeg (required by yt-dlp for muxing/transcoding) +# Install deno (required by yt-dlp for YouTube JS interpretation) +RUN apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg curl unzip && \ + curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh && \ + apt-get purge -y curl unzip && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* -# Install runtime dependencies only -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - curl \ - && rm -rf /var/lib/apt/lists/* +# Copy Python packages from deps stage +COPY --from=python-deps /install /usr/local -# Install yt-dlp (latest stable) -RUN pip install --no-cache-dir yt-dlp +# Create non-root user +RUN useradd --create-home --shell /bin/bash mediarip -# Copy virtual env from deps stage -COPY --from=backend-deps /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -# Set up application directory +# Application code WORKDIR /app +COPY backend/ ./ -# Copy backend source -COPY backend/app ./app +# Copy built frontend into backend static dir +COPY --from=frontend-builder /build/frontend/dist ./static -# Copy built frontend into static serving directory -COPY --from=frontend-build /build/dist ./static +# Create default directories +RUN mkdir -p /downloads /data && \ + chown -R mediarip:mediarip /app /downloads /data -# Create directories for runtime data -RUN mkdir -p /downloads /themes /data +USER mediarip -# Default environment -ENV MEDIARIP__SERVER__HOST=0.0.0.0 \ - MEDIARIP__SERVER__PORT=8000 \ - MEDIARIP__SERVER__DB_PATH=/data/mediarip.db \ - MEDIARIP__DOWNLOADS__OUTPUT_DIR=/downloads \ - MEDIARIP__THEMES_DIR=/themes \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 - -# Volumes for persistent data -VOLUME ["/downloads", "/themes", "/data"] +# Environment defaults +ENV MEDIARIP__DOWNLOADS__OUTPUT_DIR=/downloads \ + MEDIARIP__DATABASE__PATH=/data/mediarip.db \ + PYTHONUNBUFFERED=1 EXPOSE 8000 -# Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:8000/api/health || exit 1 + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 -# Run with uvicorn -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 9d7507a..2b38658 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,51 +1,62 @@ -# media.rip() — Secure Deployment with Caddy (Auto-TLS) +# media.rip() — Docker Compose example with Caddy reverse proxy +# +# This is the recommended deployment configuration. +# Caddy automatically provisions TLS certificates via Let's Encrypt. # # Usage: -# 1. Copy this file to docker-compose.yml -# 2. Copy .env.example to .env and fill in your domain + admin password -# 3. docker compose up -d +# 1. Replace YOUR_DOMAIN with your actual domain +# 2. Set a strong admin password hash (see below) +# 3. Run: docker compose up -d # -# Caddy automatically obtains and renews TLS certificates from Let's Encrypt. -# The admin panel is protected behind HTTPS with Basic auth. +# Generate a bcrypt password hash: +# docker run --rm python:3.12-slim python -c \ +# "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())" services: - mediarip: - image: ghcr.io/jlightner/media-rip:latest - volumes: - - ./downloads:/downloads - - ./themes:/themes - - mediarip-data:/data - environment: - - MEDIARIP__SESSION__MODE=isolated - - MEDIARIP__ADMIN__ENABLED=true - - MEDIARIP__ADMIN__USERNAME=${ADMIN_USERNAME:-admin} - - MEDIARIP__ADMIN__PASSWORD_HASH=${ADMIN_PASSWORD_HASH} - - MEDIARIP__PURGE__ENABLED=true - - MEDIARIP__PURGE__MAX_AGE_HOURS=168 + media-rip: + image: ghcr.io/xpltd/media-rip:latest + container_name: media-rip restart: unless-stopped + volumes: + - downloads:/downloads + - data:/data + # Optional: custom themes + # - ./themes:/themes:ro + # Optional: config file + # - ./config.yaml:/app/config.yaml:ro + environment: + # Admin panel (optional — remove to disable) + MEDIARIP__ADMIN__ENABLED: "true" + MEDIARIP__ADMIN__USERNAME: "admin" + MEDIARIP__ADMIN__PASSWORD_HASH: "${ADMIN_PASSWORD_HASH}" + # Session mode: isolated (default), shared, or open + # MEDIARIP__SESSION__MODE: "isolated" + expose: + - "8000" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] interval: 30s timeout: 5s retries: 3 - # Not exposed directly — Caddy handles external traffic - expose: - - "8000" + start_period: 10s caddy: image: caddy:2-alpine + container_name: media-rip-caddy + restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - - caddy-config:/config - restart: unless-stopped + - caddy_data:/data + - caddy_config:/config depends_on: - - mediarip + media-rip: + condition: service_healthy volumes: - mediarip-data: - caddy-data: - caddy-config: + downloads: + data: + caddy_data: + caddy_config: diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4d7ff0a..50c53ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ port: 5173, proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://localhost:8001', changeOrigin: true, }, },