Spaces:
Paused
Paused
| # Stack gọn cho demo / Fly.io (<50 users): Postgres + API + Vue + Nginx | |
| # ML (speaking/shadowing) chạy inline trong API — không cần Redis, MinIO, Celery, PgBouncer. | |
| services: | |
| db: | |
| image: postgres:17-alpine | |
| environment: | |
| POSTGRES_USER: linguaielts | |
| POSTGRES_PASSWORD: ${DB_PASSWORD} | |
| POSTGRES_DB: linguaielts | |
| volumes: | |
| - pgdata:/var/lib/postgresql/data | |
| healthcheck: | |
| test: ["CMD-SHELL", "pg_isready -U linguaielts"] | |
| interval: 10s | |
| timeout: 5s | |
| retries: 5 | |
| api: | |
| build: ./backend | |
| user: "0" | |
| command: > | |
| sh -c "mkdir -p /app/uploads/avatars && chown -R appuser:appuser /app/uploads && | |
| for i in 1 2 3 4 5 6 7 8 9 10; do | |
| su appuser -s /bin/sh -c 'alembic upgrade head' && break; | |
| echo \"Waiting for database ($$i/10)…\"; | |
| sleep 4; | |
| done && | |
| exec su appuser -s /bin/sh -c 'exec gunicorn app.main:app -w 2 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 300 --access-logfile -'" | |
| environment: | |
| DATABASE_URL: postgresql+asyncpg://linguaielts:${DB_PASSWORD}@db:5432/linguaielts | |
| PGBOUNCER_ENABLED: "false" | |
| REDIS_URL: "" | |
| REDIS_REQUIRED: "false" | |
| CELERY_ENABLED: "false" | |
| SECRET_KEY: ${SECRET_KEY} | |
| OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} | |
| FRONTEND_ORIGIN: ${FRONTEND_ORIGIN} | |
| SENTRY_DSN: ${SENTRY_DSN:-} | |
| ENVIRONMENT: production | |
| AUTO_CREATE_TABLES: "false" | |
| STORAGE_BACKEND: ${STORAGE_BACKEND:-local} | |
| CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-} | |
| CLOUDINARY_API_KEY: ${CLOUDINARY_API_KEY:-} | |
| CLOUDINARY_API_SECRET: ${CLOUDINARY_API_SECRET:-} | |
| AVATAR_UPLOAD_DIR: uploads/avatars | |
| METRICS_ENABLED: "true" | |
| METRICS_TOKEN: ${METRICS_TOKEN} | |
| ML_PRELOAD_ON_STARTUP: "true" | |
| AUTH_HTTPONLY_REFRESH: "true" | |
| AUTH_COOKIE_SECURE: "false" | |
| REQUIRE_EMAIL_VERIFICATION: "false" | |
| SMTP_HOST: ${SMTP_HOST:-} | |
| SMTP_PORT: ${SMTP_PORT:-587} | |
| SMTP_USER: ${SMTP_USER:-} | |
| SMTP_PASSWORD: ${SMTP_PASSWORD:-} | |
| SMTP_FROM: ${SMTP_FROM:-} | |
| SMTP_USE_TLS: ${SMTP_USE_TLS:-true} | |
| PASSWORD_RESET_EXPIRE_HOURS: ${PASSWORD_RESET_EXPIRE_HOURS:-24} | |
| GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} | |
| GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} | |
| GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost/auth/google/callback} | |
| depends_on: | |
| db: | |
| condition: service_healthy | |
| volumes: | |
| - ./backend/data:/app/data | |
| - uploads:/app/uploads | |
| expose: | |
| - "8000" | |
| healthcheck: | |
| test: ["CMD", "curl", "-f", "http://localhost:8000/health"] | |
| interval: 30s | |
| timeout: 10s | |
| retries: 3 | |
| start_period: 120s | |
| restart: unless-stopped | |
| frontend: | |
| build: | |
| context: ./fronted | |
| args: | |
| VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-} | |
| VITE_GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost/auth/google/callback} | |
| VITE_AUDIO_CDN_BASE: /api/audio | |
| VITE_AUDIO_CDN_EXT: .mp3 | |
| VITE_CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-dq955rfxi} | |
| environment: | |
| BACKEND_INTERNAL_URL: http://api:8000 | |
| expose: | |
| - "80" | |
| restart: unless-stopped | |
| gateway: | |
| image: nginx:alpine | |
| ports: | |
| - "80:80" | |
| volumes: | |
| - ./nginx.docker.conf:/etc/nginx/conf.d/default.conf:ro | |
| depends_on: | |
| api: | |
| condition: service_healthy | |
| frontend: | |
| condition: service_started | |
| healthcheck: | |
| test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] | |
| interval: 30s | |
| timeout: 5s | |
| retries: 3 | |
| restart: unless-stopped | |
| volumes: | |
| pgdata: | |
| uploads: | |