R021/R022/R026: Docker, CI/CD, deployment example

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/
This commit is contained in:
xpltd 2026-03-19 06:57:25 -05:00
parent cbaec9ad36
commit c9ad4fc5d0
7 changed files with 202 additions and 174 deletions

View file

@ -1,37 +1,33 @@
# Dependencies # Ignore Python / build artifacts
node_modules/
.venv/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
# Build artifacts venv/
frontend/dist/
*.egg-info/ *.egg-info/
dist/
build/
# Development files # Ignore Node artifacts
.git/ node_modules/
frontend/dist/
# Ignore dev/test files
.gsd/ .gsd/
.planning/ .bg-shell/
.github/ *.log
.vscode/ coverage/
*.md .cache/
!README.md tmp/
# Test files
backend/tests/
frontend/src/tests/
frontend/vitest.config.ts
# OS files
.DS_Store
Thumbs.db
# Docker
docker-compose*.yml
Dockerfile
.dockerignore
# Misc
.env .env
.env.* .env.*
*.log
# Ignore git
.git/
.gitignore
# Ignore IDE
.idea/
.vscode/
*.code-workspace
*.swp
*.swo

View file

@ -1,18 +1,15 @@
# CI — runs on all PRs and pushes to main
name: CI name: CI
on: on:
pull_request:
branches: [main, master]
push: push:
branches: [main, master] branches: [main, master]
pull_request:
concurrency: branches: [main, master]
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
backend: backend:
name: Backend (Python) name: Backend (lint + test)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -21,69 +18,55 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
cache: pip cache: pip
cache-dependency-path: backend/requirements.txt
- name: Install dependencies - name: Install dependencies
working-directory: backend
run: | run: |
python -m pip install --upgrade pip cd backend
pip install -r requirements.txt 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) - name: Lint
working-directory: backend run: cd backend && ruff check .
run: ruff check app/
- name: Type check (optional) - name: Test
working-directory: backend run: cd backend && python -m pytest tests/ -q -m "not integration"
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
frontend: frontend:
name: Frontend (Vue 3) name: Frontend (typecheck + test)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "22"
cache: npm cache: npm
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install dependencies - name: Install dependencies
working-directory: frontend run: cd frontend && npm ci
run: npm ci
- name: Type check (vue-tsc) - name: Typecheck
working-directory: frontend run: cd frontend && npx vue-tsc --noEmit
run: npx vue-tsc --noEmit
- name: Test (vitest) - name: Test
working-directory: frontend run: cd frontend && npx vitest run
run: npx vitest run
- name: Build
working-directory: frontend
run: npm run build
docker: docker:
name: Docker Build name: Docker build (smoke test)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend, frontend] needs: [backend, frontend]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build image - name: Set up Docker Buildx
run: docker build -t media-rip:ci . uses: docker/setup-buildx-action@v3
- name: Smoke test - name: Build image
run: | uses: docker/build-push-action@v6
docker run -d --name mediarip-test -p 8000:8000 media-rip:ci with:
sleep 5 context: .
curl -f http://localhost:8000/api/health push: false
docker stop mediarip-test tags: media-rip:ci-test
cache-from: type=gha
cache-to: type=gha,mode=max

61
.github/workflows/publish.yml vendored Normal file
View file

@ -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

4
Caddyfile.example Normal file
View file

@ -0,0 +1,4 @@
# Replace YOUR_DOMAIN with your actual domain
YOUR_DOMAIN {
reverse_proxy media-rip:8000
}

View file

@ -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: # Image: ghcr.io/xpltd/media-rip
# 1. frontend-build: Install npm deps + build Vue 3 SPA # Platforms: linux/amd64, linux/arm64
# 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
# ══════════════════════════════════════════ # ── Stage 1: Frontend build ──────────────────────────────────────────
# Stage 1: Build frontend FROM node:22-slim AS frontend-builder
# ══════════════════════════════════════════
FROM node:20-slim AS frontend-build
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# ══════════════════════════════════════════ # ── Stage 2: Python dependencies ─────────────────────────────────────
# Stage 2: Install Python dependencies FROM python:3.12-slim AS python-deps
# ══════════════════════════════════════════
FROM python:3.12-slim AS backend-deps
WORKDIR /build 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 ./ COPY backend/requirements.txt ./
RUN python -m venv /opt/venv && \ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# ══════════════════════════════════════════ # ── Stage 3: Runtime ─────────────────────────────────────────────────
# Stage 3: Runtime image
# ══════════════════════════════════════════
FROM python:3.12-slim AS runtime FROM python:3.12-slim AS runtime
LABEL org.opencontainers.image.title="media.rip()" # Install ffmpeg (required by yt-dlp for muxing/transcoding)
LABEL org.opencontainers.image.description="Self-hostable yt-dlp web frontend" # Install deno (required by yt-dlp for YouTube JS interpretation)
LABEL org.opencontainers.image.source="https://github.com/jlightner/media-rip" 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 # Copy Python packages from deps stage
RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=python-deps /install /usr/local
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install yt-dlp (latest stable) # Create non-root user
RUN pip install --no-cache-dir yt-dlp RUN useradd --create-home --shell /bin/bash mediarip
# Copy virtual env from deps stage # Application code
COPY --from=backend-deps /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Set up application directory
WORKDIR /app WORKDIR /app
COPY backend/ ./
# Copy backend source # Copy built frontend into backend static dir
COPY backend/app ./app COPY --from=frontend-builder /build/frontend/dist ./static
# Copy built frontend into static serving directory # Create default directories
COPY --from=frontend-build /build/dist ./static RUN mkdir -p /downloads /data && \
chown -R mediarip:mediarip /app /downloads /data
# Create directories for runtime data USER mediarip
RUN mkdir -p /downloads /themes /data
# Default environment # Environment defaults
ENV MEDIARIP__SERVER__HOST=0.0.0.0 \ ENV MEDIARIP__DOWNLOADS__OUTPUT_DIR=/downloads \
MEDIARIP__SERVER__PORT=8000 \ MEDIARIP__DATABASE__PATH=/data/mediarip.db \
MEDIARIP__SERVER__DB_PATH=/data/mediarip.db \ PYTHONUNBUFFERED=1
MEDIARIP__DOWNLOADS__OUTPUT_DIR=/downloads \
MEDIARIP__THEMES_DIR=/themes \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Volumes for persistent data
VOLUME ["/downloads", "/themes", "/data"]
EXPOSE 8000 EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ 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 ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View file

@ -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: # Usage:
# 1. Copy this file to docker-compose.yml # 1. Replace YOUR_DOMAIN with your actual domain
# 2. Copy .env.example to .env and fill in your domain + admin password # 2. Set a strong admin password hash (see below)
# 3. docker compose up -d # 3. Run: docker compose up -d
# #
# Caddy automatically obtains and renews TLS certificates from Let's Encrypt. # Generate a bcrypt password hash:
# The admin panel is protected behind HTTPS with Basic auth. # docker run --rm python:3.12-slim python -c \
# "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
services: services:
mediarip: media-rip:
image: ghcr.io/jlightner/media-rip:latest image: ghcr.io/xpltd/media-rip:latest
volumes: container_name: media-rip
- ./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
restart: unless-stopped 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: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
# Not exposed directly — Caddy handles external traffic start_period: 10s
expose:
- "8000"
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: media-rip-caddy
restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy_data:/data
- caddy-config:/config - caddy_config:/config
restart: unless-stopped
depends_on: depends_on:
- mediarip media-rip:
condition: service_healthy
volumes: volumes:
mediarip-data: downloads:
caddy-data: data:
caddy-config: caddy_data:
caddy_config:

View file

@ -13,7 +13,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8001',
changeOrigin: true, changeOrigin: true,
}, },
}, },