# ClassroomIO — pull-based stack (pre-built images, no local build). # # This is the same topology as docker-compose.yaml but the app services pull # published images from Docker Hub instead of building from source. You only need # THIS file + a .env — no repository checkout, no 8 GB dashboard build. # # CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env pull # CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d # # CIO_VERSION pins the release. Use a specific version (e.g. 1.4.2) in production; # "latest" = freshest main build (moves on every merge — rolling, may include unreleased code). # # KEEP IN SYNC with docker-compose.yaml: the api/dashboard/jobs `environment`, # healthchecks, depends_on, and volumes must match the build-based file. The only # intended difference is build: -> image:. services: postgres: image: postgres:16-alpine container_name: cio-postgres environment: POSTGRES_DB: ${POSTGRES_DB:-classroomio} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: # Require a successful query so we only pass once Postgres is out of recovery # (pg_isready can pass while DB is still in recovery, causing 57P03 on sign-in) test: [ 'CMD-SHELL', "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'" ] interval: 5s timeout: 5s retries: 15 start_period: 10s redis: image: redis:7-alpine container_name: cio-redis command: ['redis-server', '--appendonly', 'yes'] volumes: - redis-data:/data healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 10 start_period: 5s minio: image: minio/minio:latest container_name: cio-minio command: server /data --console-address ":9001" ports: - '9000:9000' - '9001:9001' environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} volumes: - minio-data:/data profiles: - minio minio-init: image: minio/mc:latest container_name: cio-minio-init depends_on: - minio entrypoint: > /bin/sh -c " echo 'Waiting for MinIO to be ready...'; sleep 5; mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin}; mc mb local/videos --ignore-existing; mc mb local/documents --ignore-existing; mc mb local/media --ignore-existing; mc anonymous set download local/media; echo 'MinIO buckets created'; exit 0; " environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} profiles: - minio api: image: classroomio/api:${CIO_VERSION:-latest} container_name: cio-api ports: - '3081:3081' depends_on: postgres: condition: service_healthy redis: condition: service_healthy healthcheck: # node:slim has no curl/wget; use Node's global fetch (Node 20+). test: [ 'CMD', 'node', '-e', "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ] interval: 10s timeout: 5s retries: 12 start_period: 30s environment: NODE_ENV: production PORT: 3081 PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081} TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-} DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio} REDIS_URL: redis://redis:6379 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-docker-local-client-id} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-docker-local-client-secret} SMTP_HOST: ${SMTP_HOST:-} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PORT: ${SMTP_PORT:-} SMTP_SENDER: ${SMTP_SENDER:-} # Object storage (S3-compatible). Works with the bundled MinIO, AWS S3, or Cloudflare R2 # — for R2, point OBJECT_STORAGE_ENDPOINT at your R2 endpoint. OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-} OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-} OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-} OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-} OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true} OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-} # Auth secrets — required. ./classroomio.sh generates them into .env; if you run # compose directly, set strong values yourself — the stack refuses to start otherwise. PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env} DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} LICENSE_KEY: ${LICENSE_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set a strong BETTER_AUTH_SECRET in .env} SKIP_DB_SETUP: ${SKIP_DB_SETUP:-} restart: unless-stopped dashboard: image: classroomio/dashboard:${CIO_VERSION:-latest} container_name: cio-dashboard ports: - '3082:3082' depends_on: api: condition: service_healthy environment: NODE_ENV: production PORT: 3082 ORIGIN: ${DASHBOARD_ORIGIN:-http://localhost:3082} PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true} PRIVATE_SERVER_URL: ${PRIVATE_SERVER_URL:-http://api:3081} PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env} PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost} PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app} ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-} CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-} CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-} CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-} CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-} CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-} CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-} # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} UPLOAD_MAX_LANDING_IMAGE_MB: ${UPLOAD_MAX_LANDING_IMAGE_MB:-} UPLOAD_MAX_THUMBNAIL_MB: ${UPLOAD_MAX_THUMBNAIL_MB:-} restart: unless-stopped # Background worker (BullMQ). Drains the Redis queue: video processing, transcription, # AI course generation, and emails. jobs: image: classroomio/jobs:${CIO_VERSION:-latest} container_name: cio-jobs depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: NODE_ENV: production REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio} # Object storage (workers stream object bytes). Mirrors the api service. OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-} OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-} OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-} OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-} OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-} OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true} OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-} # Bucket names match those created by minio-init above. OBJECT_STORAGE_BUCKET_VIDEOS: ${OBJECT_STORAGE_BUCKET_VIDEOS:-videos} OBJECT_STORAGE_BUCKET_DOCUMENTS: ${OBJECT_STORAGE_BUCKET_DOCUMENTS:-documents} OBJECT_STORAGE_BUCKET_MEDIA: ${OBJECT_STORAGE_BUCKET_MEDIA:-media} # Email (worker sends invites, notifications, etc.). SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_SENDER: ${SMTP_SENDER:-} # Transcription + OpenAI-backed agent runs. OPENAI_API_KEY: ${OPENAI_API_KEY:-} restart: unless-stopped volumes: postgres-data: redis-data: minio-data: