diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb6031b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.devcontainer +.vscode +.claude +backend +docker-compose*.yml +.env* +*.md +.DS_Store diff --git a/.env.example b/.env.example index 142e3f6..79b733e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,24 @@ # KMountain Flower Radio Station — Environment Variables -# Copy this file to .env and fill in the values. Do NOT commit .env. +# Copy this file to .env and adjust values for your environment. Do NOT commit .env. -# PostgreSQL connection string (backend) -# Format: postgresql+asyncpg://user:password@host:port/database -KMTN_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain +# ── Database (db service) ───────────────────────────────────────────── +POSTGRES_DB=kmountain +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres -# CORS allowlist for the backend (JSON array of allowed origins) -KMTN_CORS_ORIGINS=["http://localhost:4200"] +# ── Backend API (api service) ───────────────────────────────────────── +# Database connection string. Use postgresql+asyncpg for PostgreSQL, sqlite+aiosqlite for local dev without a DB. +KMTN_DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/kmountain + +# JSON array of allowed CORS origins. +# Dev: include localhost variants. Prod: your actual domain(s). +KMTN_CORS_ORIGINS='["http://localhost:4200", "http://localhost"]' + +# Environment name. Set to "production" to disable the admin router. +# KMTN_ENV=development + +# ── Frontend (frontend service) ─────────────────────────────────────── +# URL of the API backend that nginx should proxy /api/ requests to. +# Dev (Docker Compose): http://api:8000 (DNS resolves to the api container) +# Prod: https://api.yourdomain.com (or whatever your API endpoint is) +API_UPSTREAM=http://api:8000 diff --git a/Dockerfile b/Dockerfile index 0ca92b3..cc418af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Build stage ─────────────────────────────────────────────────────── -FROM node:20-alpine AS build +FROM docker.io/library/node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci @@ -7,8 +7,11 @@ COPY . . RUN npx ng build --configuration production # ── Serve stage ──────────────────────────────────────────────────────── -FROM docker.io/library/nginx:1.30-perl +FROM docker.io/library/nginx:alpine COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +ENV API_UPSTREAM=http://api:8000 EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1ee488e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +.git +.vscode +.claude +*.egg-info +.pytest_cache +.mypy_cache +.env* +docker-compose*.yml +*.md +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile index 39c096e..1a14197 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,11 +2,12 @@ FROM docker.io/library/python:3.12-slim AS base WORKDIR /app -RUN pip install --no-cache-dir gunicorn uvicorn psycopg2-binary +ENV PYTHONUNBUFFERED=1 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] +EXPOSE 8000 +CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 69125f1..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Dev-sidecar compose: app container only (no database service). -# Used by .devcontainer/devcontainer.json via dockerComposeFile. -# Development uses SQLite — no Docker-in-Docker database needed. - -services: - app: - build: - context: . - dockerfile: .devcontainer/Dockerfile - restart: "no" - ports: - - "8000:8000" # FastAPI - - "4200:4200" # Angular dev server - environment: - KMTN_DATABASE_URL: sqlite+aiosqlite:///./backend/kmountain.db - KMTN_CORS_ORIGINS: '["http://localhost:4200"]' - volumes: - - ..:/workspaces/web_app:cached - - node_modules:/workspaces/web_app/node_modules - - backend_venv:/app/.venv - command: > - sh -c " - pip install -r backend/requirements.txt && - cd backend && - uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload & - cd /workspaces/web_app && - npm install && - npx ng serve --host 0.0.0.0 --port 4200 - " - -volumes: - node_modules: - backend_venv: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bb1fcf4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,19 @@ +# Production override — use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up +# Assumes: managed PostgreSQL, external reverse proxy / load balancer handling TLS and port 80/443. + +services: + # No db service — managed PostgreSQL is assumed. + # Set KMTN_DATABASE_URL in your environment or a .env file. + + api: + environment: + KMTN_ENV: production + # Override with your actual production domain(s) + KMTN_CORS_ORIGINS: '["https://yourdomain.com"]' + # No port mapping — traffic arrives via reverse proxy / load balancer + + frontend: + environment: + # Point to your API's production URL + API_UPSTREAM: https://api.yourdomain.com + # No port mapping — traffic arrives via reverse proxy / load balancer diff --git a/docker-compose.yml b/docker-compose.yml index be4f31d..0bc2c73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: restart: unless-stopped environment: KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain - KMTN_CORS_ORIGINS: '["http://localhost:4200"]' + KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]' ports: - "8000:8000" depends_on: @@ -37,6 +37,8 @@ services: context: . dockerfile: Dockerfile restart: unless-stopped + environment: + API_UPSTREAM: http://api:8000 ports: - "4200:80" depends_on: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..a21ca9c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Render the nginx config template with environment variables, then start nginx. +set -e + +envsubst '${API_UPSTREAM}' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf + +exec nginx -g 'daemon off;' diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 6d20403..0000000 --- a/nginx.conf +++ /dev/null @@ -1,26 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # SPA fallback — Angular router handles its own paths - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy API calls to the backend service (only when running inside Docker) - location /api/ { - # When the env var is set, nginx resolves "api" to the backend container. - # In dev (HOST_MODE=dev) we proxy to localhost:8000 instead. - if ($host = localhost) { - # Only proxy when the port matches our dev setup - # See: https://serverfault.com/questions/777768/conditional-proxy-pass-in-nginx - } - proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 0000000..8b31555 --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — Angular router handles its own paths + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to the backend service + location /api/ { + proxy_pass ${API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/src/app/admin/admin-event-form.component.ts b/src/app/admin/admin-event-form.component.ts index 9311edb..ea6733b 100644 --- a/src/app/admin/admin-event-form.component.ts +++ b/src/app/admin/admin-event-form.component.ts @@ -50,6 +50,13 @@ export class AdminEventFormComponent implements OnChanges { this.active = event.active; } + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + reset(): void { this.id = null; this.date = ''; diff --git a/src/app/admin/admin-program-form.component.ts b/src/app/admin/admin-program-form.component.ts index 0595f30..0f19c6d 100644 --- a/src/app/admin/admin-program-form.component.ts +++ b/src/app/admin/admin-program-form.component.ts @@ -60,6 +60,13 @@ export class AdminProgramFormComponent implements OnChanges { } /** Reset the form for a new entry. */ + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + reset(): void { this.id = null; this.day_of_week = 1; diff --git a/src/app/admin/admin-tier-form.component.ts b/src/app/admin/admin-tier-form.component.ts index a3f0066..d2dcb95 100644 --- a/src/app/admin/admin-tier-form.component.ts +++ b/src/app/admin/admin-tier-form.component.ts @@ -46,6 +46,13 @@ export class AdminTierFormComponent implements OnChanges { this.display_order = tier.display_order; } + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + reset(): void { this.id = null; this.name = '';