# Portainer Stack Definition for School Compare # # Portainer environment variables (set in Portainer UI -> Stack -> Environment): # DB_USERNAME — PostgreSQL username # DB_PASSWORD — PostgreSQL password # DB_DATABASE_NAME — PostgreSQL database name # ADMIN_API_KEY — Backend admin API key # TYPESENSE_API_KEY — Typesense admin API key # TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend) # AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs) services: # ── PostgreSQL ──────────────────────────────────────────────────────── sc_database: container_name: sc_postgres image: postgis/postgis:18-3.6-alpine environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} POSTGRES_DB: ${DB_DATABASE_NAME} volumes: - postgres_data:/var/lib/postgresql shm_size: 128mb networks: backend: {} macvlan: ipv4_address: 10.0.1.189 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 start_period: 10s restart: unless-stopped # ── FastAPI Backend ─────────────────────────────────────────────────── backend: image: privaterepo.sitaru.org/tudor/school_compare-backend:latest container_name: schoolcompare_backend environment: DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} PYTHONUNBUFFERED: 1 ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme} TYPESENSE_URL: http://typesense:8108 TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme} depends_on: sc_database: condition: service_healthy networks: - backend restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:80/api/data-info"] interval: 30s timeout: 10s retries: 3 start_period: 30s # ── Next.js Frontend ────────────────────────────────────────────────── frontend: image: privaterepo.sitaru.org/tudor/school_compare-frontend:latest container_name: schoolcompare_nextjs environment: - NODE_ENV=production - NEXT_PUBLIC_API_URL=http://localhost:8000/api - FASTAPI_URL=http://backend:80/api - TYPESENSE_URL=http://typesense:8108 - TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme} depends_on: backend: condition: service_healthy networks: backend: {} macvlan: ipv4_address: 10.0.1.150 restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] interval: 30s timeout: 10s retries: 3 start_period: 40s # ── Typesense Search Engine ─────────────────────────────────────────── typesense: image: typesense/typesense:30.1 container_name: schoolcompare_typesense environment: TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme} TYPESENSE_DATA_DIR: /data volumes: - typesense_data:/data networks: - backend restart: unless-stopped healthcheck: test: ["CMD-SHELL", "cat < /dev/tcp/localhost/8108"] interval: 15s timeout: 5s retries: 5 start_period: 10s # ── Airflow API Server + UI ─────────────────────────────────────────── airflow-api-server: image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest container_name: schoolcompare_airflow_api command: airflow api-server --port 8080 ports: - "8080:8080" environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags AIRFLOW__CORE__LOAD_EXAMPLES: "false" AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/ AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512" AIRFLOW__API_AUTH__JWT_ISSUER: airflow AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin" AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs PG_HOST: sc_database PG_PORT: "5432" PG_USER: ${DB_USERNAME} PG_PASSWORD: ${DB_PASSWORD} PG_DATABASE: ${DB_DATABASE_NAME} TYPESENSE_URL: http://typesense:8108 TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme} volumes: - airflow_logs:/opt/airflow/logs depends_on: sc_database: condition: service_healthy networks: - backend restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/api/v2/monitor/health"] interval: 30s timeout: 10s retries: 5 start_period: 60s # ── Airflow Scheduler ────────────────────────────────────────────── airflow-scheduler: image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest container_name: schoolcompare_airflow_scheduler command: airflow scheduler environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags AIRFLOW__CORE__LOAD_EXAMPLES: "false" AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/ AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512" AIRFLOW__API_AUTH__JWT_ISSUER: airflow AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs PG_HOST: sc_database PG_PORT: "5432" PG_USER: ${DB_USERNAME} PG_PASSWORD: ${DB_PASSWORD} PG_DATABASE: ${DB_DATABASE_NAME} TYPESENSE_URL: http://typesense:8108 TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme} volumes: - airflow_logs:/opt/airflow/logs depends_on: sc_database: condition: service_healthy networks: - backend restart: unless-stopped # ── Airflow DB Init (one-shot) ─────────────────────────────────────── airflow-init: image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest container_name: schoolcompare_airflow_init command: bash -c "airflow db migrate && airflow dags delete school_data_daily -y 2>/dev/null; airflow dags delete school_data_monthly_ofsted -y 2>/dev/null; airflow dags delete school_data_annual_ees -y 2>/dev/null; airflow dags reserialize" environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags AIRFLOW__CORE__LOAD_EXAMPLES: "false" AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/ AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512" AIRFLOW__API_AUTH__JWT_ISSUER: airflow depends_on: sc_database: condition: service_healthy networks: - backend restart: "no" networks: backend: driver: bridge macvlan: external: name: macvlan volumes: postgres_data: typesense_data: airflow_logs: