# Clinic OS — production stack. # # Port policy on this VPS: every app binds to loopback only and nginx on the # host terminates TLS. 8120/8121 (skinbyfizza), 8000/5432/6543 (supabase), # 3000 (agent-crm) and 5678 (n8n) are taken, so the web app takes 8130. # Postgres is not published at all — it is reachable only on this compose # network, which is one fewer thing exposed than the neighbours manage. name: clinicos services: db: image: postgres:17-alpine container_name: clinicos_db restart: unless-stopped environment: # POSTGRES_USER is the owner role migrations run as. The app connects as # clinic_app, created by migrate.mjs as NOSUPERUSER, because Postgres lets # a superuser bypass RLS no matter what FORCE says. POSTGRES_USER: clinic_owner POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: clinic_os volumes: - clinicos_pgdata:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U clinic_owner -d clinic_os'] interval: 5s timeout: 5s retries: 20 start_period: 10s networks: [clinicos] app: build: context: . dockerfile: Dockerfile image: clinicos-app container_name: clinicos_app restart: unless-stopped depends_on: db: condition: service_healthy environment: NODE_ENV: production DATABASE_URL: postgres://clinic_owner:${POSTGRES_PASSWORD}@db:5432/clinic_os DATABASE_URL_APP: postgres://clinic_app:${APP_DB_PASSWORD:?set APP_DB_PASSWORD in .env}@db:5432/clinic_os SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET in .env} # Uploaded files. A named volume, not a bind mount: nothing outside the # container should be able to read patient photos off the host by path. MEDIA_ROOT: /var/lib/clinacy/media # Optional. With no SMTP host the app reports that it cannot send email # and /forgot explains the manual route instead of showing a dead form. SMTP_URL: ${SMTP_URL:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_SECURE: ${SMTP_SECURE:-} MAIL_FROM: ${MAIL_FROM:-} APP_URL: ${APP_URL:-} REPORT_CRON_SECRET: ${REPORT_CRON_SECRET:-} volumes: - clinicos_media:/var/lib/clinacy/media ports: - '127.0.0.1:8130:3000' networks: [clinicos] # One-shot. `docker compose run --rm migrate` applies outstanding migrations. migrate: image: clinicos-app profiles: [tools] depends_on: db: condition: service_healthy environment: DATABASE_URL: postgres://clinic_owner:${POSTGRES_PASSWORD}@db:5432/clinic_os DATABASE_URL_APP: postgres://clinic_app:${APP_DB_PASSWORD}@db:5432/clinic_os command: ['node', 'scripts/migrate.mjs'] networks: [clinicos] # One-shot. `docker compose run --rm seed` fills the demo clinic. seed: image: clinicos-app profiles: [tools] depends_on: db: condition: service_healthy environment: DATABASE_URL: postgres://clinic_owner:${POSTGRES_PASSWORD}@db:5432/clinic_os DATABASE_URL_APP: postgres://clinic_app:${APP_DB_PASSWORD}@db:5432/clinic_os DEMO_PASSWORD: ${DEMO_PASSWORD:?set DEMO_PASSWORD in .env} command: ['node', 'scripts/seed-demo.mjs'] networks: [clinicos] volumes: clinicos_pgdata: clinicos_media: networks: clinicos: name: clinicos_net