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

View file

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

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:
# 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"]

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:
# 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:

View file

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