Deployment¶
This guide covers deploying YAAI to production. No repo clone needed -- everything installs from PyPI.
Important
This page is for production deployments. For local development, see Server Setup.
Production checklist¶
Before going live, make sure you've set these:
ENVIRONMENT=production-- the server refuses to start with default credentials in production modeBASE_URL-- your public URL (e.g.https://yaai.mycompany.com). Used for CORS, OAuth redirects, and service account audience.DATABASE_URL-- point to a managed PostgreSQL instance with strong credentials (notchangeme)AUTH_JWT_SECRETandSESSION_SECRET-- required in production; the server refuses to start without them (a missing secret would silently log users out on every restart and break multiple instances). Generate withopenssl rand -base64 32.BOOTSTRAP_ADMIN_PASSWORD-- recommended for the first start (min. 12 characters); effectively required on Cloud Run, where you cannot read files inside an instance. Details: Configuration.RATE_LIMIT_TRUSTED_PROXY_HOPS-- set to the number of reverse proxies in front of YAAI, otherwise all users share one rate-limit bucket (1when Cloud Run is accessed directly, typically2behind an external HTTPS load balancer; see Configuration).- API docs --
/docs,/redocand/openapi.jsonare disabled in production. SetAPI_DOCS_ENABLED=trueto expose them.
Docker Compose¶
The recommended way to deploy. Create these two files -- no repo clone required.
Dockerfile¶
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN pip install "yaai-monitoring[server]"
EXPOSE 8000
CMD ["uvicorn", "yaai.server.main:app", "--host", "0.0.0.0", "--port", "8000"]
Tip
For Google Cloud SQL support, use "yaai-monitoring[server,gcp]" instead.
docker-compose.yml¶
Username + password login with API key auth for service accounts. An admin account is created automatically on first startup. Set BOOTSTRAP_ADMIN_PASSWORD or read the generated password from admin-initial-password.txt in the container working directory (see Configuration).
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: aimon
POSTGRES_PASSWORD: change-me-in-production
POSTGRES_DB: aimonitoring
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aimon -d aimonitoring"]
interval: 5s
timeout: 3s
retries: 5
migrate:
build: .
command: ["python", "-m", "yaai.server.migrate"]
environment:
DATABASE_URL: postgresql+asyncpg://aimon:change-me-in-production@db:5432/aimonitoring
restart: "no"
depends_on:
db:
condition: service_healthy
yaai:
build: .
ports:
- "8000:8000"
environment:
BASE_URL: http://localhost:8000
DATABASE_URL: postgresql+asyncpg://aimon:change-me-in-production@db:5432/aimonitoring
ENVIRONMENT: production
AUTH_ENABLED: "true"
AUTH_JWT_SECRET: <generate with: openssl rand -base64 32>
SESSION_SECRET: <generate with: openssl rand -base64 32>
BOOTSTRAP_ADMIN_PASSWORD: <at least 12 characters>
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
pgdata:
Browser users sign in with Google. Local auth is automatically disabled when Google OAuth is enabled. API key auth still works for service accounts.
Note
Google OAuth requires the frontend and backend to share the same origin (same host and port). Docker Compose handles this -- the server serves both the API and the frontend on port 8000.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: aimon
POSTGRES_PASSWORD: change-me-in-production
POSTGRES_DB: aimonitoring
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aimon -d aimonitoring"]
interval: 5s
timeout: 3s
retries: 5
migrate:
build: .
command: ["python", "-m", "yaai.server.migrate"]
environment:
DATABASE_URL: postgresql+asyncpg://aimon:change-me-in-production@db:5432/aimonitoring
restart: "no"
depends_on:
db:
condition: service_healthy
yaai:
build: .
ports:
- "8000:8000"
environment:
BASE_URL: https://yaai.mycompany.com
DATABASE_URL: postgresql+asyncpg://aimon:change-me-in-production@db:5432/aimonitoring
ENVIRONMENT: production
AUTH_ENABLED: "true"
AUTH_JWT_SECRET: <generate with: openssl rand -base64 32>
SESSION_SECRET: <generate with: openssl rand -base64 32>
# Google OAuth
AUTH_OAUTH_GOOGLE_ENABLED: "true"
AUTH_OAUTH_GOOGLE_CLIENT_ID: your-client-id
AUTH_OAUTH_GOOGLE_CLIENT_SECRET: your-client-secret
AUTH_OAUTH_GOOGLE_ALLOWED_DOMAINS: mycompany.com
AUTH_OAUTH_GOOGLE_AUTO_CREATE_USERS: "true"
AUTH_OAUTH_GOOGLE_OWNER_EMAILS: admin@mycompany.com
# AUTH_OAUTH_GOOGLE_VIEWER_EMAILS: analyst@mycompany.com
# Google service account auth (SDK clients)
AUTH_SERVICE_ACCOUNTS_GOOGLE_ENABLED: "true"
AUTH_SERVICE_ACCOUNTS_GOOGLE_ALLOWED_EMAILS: ml-pipeline@my-project.iam.gserviceaccount.com
# AUTH_SERVICE_ACCOUNTS_GOOGLE_AUDIENCE: https://yaai.mycompany.com # defaults to BASE_URL
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
pgdata:
Set the OAuth redirect URI in Google Cloud Console to:
Start the server¶
Open your BASE_URL -- you should see the login screen. The one-shot migrate service applies pending database migrations before the server starts; the server itself never migrates and refuses to start if the schema is behind.
Tip
Already cloned the repo? You can also deploy directly with the included docker-compose.yml and Dockerfile.dev. Copy .env.example to .env, set your values, and run docker compose up -d. The repo's Dockerfile builds the frontend from source and includes a healthcheck.
Without Docker¶
Install the server package and run uvicorn directly. You need Python 3.11+ and a PostgreSQL instance.
Set environment variables (via .env file, shell exports, or your process manager):
export BASE_URL=https://yaai.mycompany.com
export DATABASE_URL=postgresql+asyncpg://user:password@your-db-host:5432/aimonitoring
export ENVIRONMENT=production
export AUTH_ENABLED=true
export AUTH_JWT_SECRET=$(openssl rand -base64 32)
export SESSION_SECRET=$(openssl rand -base64 32)
Apply database migrations (on first install and after every upgrade), then start the server:
The server checks on startup that the schema is up to date, creates the admin account, and serves both the API and the frontend.
Database migrations¶
python -m yaai.server.migrate is only a pass-through to the Alembic CLI. It adds the alembic.ini shipped with the package and connects to the database configured for YAAI (DATABASE_URL, or the Cloud SQL connector when CLOUD_SQL_INSTANCE is set). Without arguments it runs upgrade head; all arguments are passed to Alembic unchanged:
python -m yaai.server.migrate # upgrade to the latest revision
python -m yaai.server.migrate current # show the database revision
python -m yaai.server.migrate history # list all revisions
python -m yaai.server.migrate downgrade -1 # revert the latest migration
python -m yaai.server.migrate downgrade <rev> # revert to a specific revision
python -m yaai.server.migrate upgrade head --sql # print the SQL instead of running it
python -m yaai.server.migrate --help
A downgrade must run with the newer application version: only it contains the migration scripts (and their downgrade() steps) for its own revisions. To roll back a release, first downgrade the database with the new image to the head revision of the old release (python -m yaai.server.migrate history shows it), then deploy the old image.
Google Cloud SQL¶
If your PostgreSQL runs on Google Cloud SQL, YAAI can connect using the Cloud SQL Python Connector with IAM authentication -- no IP allow-lists, SSL certificates, or Auth Proxy needed.
Install the GCP extras¶
This adds the Cloud SQL Connector, pg8000 (for migrations and the startup schema check), and google-auth.
Configure connection¶
Set these environment variables instead of DATABASE_URL:
CLOUD_SQL_INSTANCE=my-project:us-central1:yaai-db
CLOUD_SQL_USER=yaai-server@my-project.iam.gserviceaccount.com
CLOUD_SQL_DATABASE=aimonitoring
CLOUD_SQL_IAM_AUTH=true
CLOUD_SQL_IP_TYPE=public # public, private, or psc
| Variable | Required | Default | Description |
|---|---|---|---|
CLOUD_SQL_INSTANCE |
Yes | -- | Instance connection name (project:region:instance) |
CLOUD_SQL_USER |
Yes | -- | IAM user email (full service account email) |
CLOUD_SQL_DATABASE |
No | aimonitoring |
Database name |
CLOUD_SQL_IAM_AUTH |
No | true |
Use IAM authentication (set false for password auth) |
CLOUD_SQL_IP_TYPE |
No | public |
Connection type: public, private, or psc |
When CLOUD_SQL_INSTANCE is set, the connector handles all database connections automatically. DATABASE_URL is ignored.
Run migrations as a Cloud Run Job¶
The server never migrates on startup, so on GCP run migrations as a Cloud Run Job that uses the same image as the service and executes python -m yaai.server.migrate. The job connects through the Cloud SQL Connector exactly like the server (same CLOUD_SQL_* variables), so no Auth Proxy is needed.
Run the job with the same service account and database user as the service. PostgreSQL tables are owned by the user that creates them, so a different migration user would either lock the server out of new tables or be unable to alter the existing ones. If you want a dedicated migration service account anyway, you must set up the roles yourself (e.g. make the migration user a member of the server's role or transfer ownership, and use ALTER DEFAULT PRIVILEGES for new objects).
# One-time setup: job with the same service account and settings as the service
gcloud run jobs create yaai-migrate \
--image europe-west1-docker.pkg.dev/my-project/yaai/yaai:TAG \
--command python --args=-m,yaai.server.migrate \
--service-account yaai-server@my-project.iam.gserviceaccount.com \
--set-env-vars CLOUD_SQL_INSTANCE=my-project:us-central1:yaai-db,CLOUD_SQL_USER=yaai-server@my-project.iam.gserviceaccount.com,CLOUD_SQL_DATABASE=aimonitoring \
--region us-central1
Note
With CLOUD_SQL_IP_TYPE=private or psc the job needs the same network access as the service: configure Direct VPC egress (--network/--subnet) or a Serverless VPC connector (--vpc-connector) on the job as well. With public no extra networking is required.
In your CD pipeline, update and run the job before deploying the new service revision. --wait makes the step fail if the migration fails, so the old revision keeps serving. Example GitHub Actions steps:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.DEPLOY_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- name: Run database migrations
run: |
gcloud run jobs update yaai-migrate --image "$IMAGE" --region us-central1
gcloud run jobs execute yaai-migrate --region us-central1 --wait
- name: Deploy service
run: gcloud run deploy yaai --image "$IMAGE" --region us-central1
- name: Deploy worker
run: gcloud run worker-pools deploy yaai-worker --image "$IMAGE" --region us-central1
The last step is only needed if you run a scheduled jobs worker. It keeps the worker on the same image as the migrated schema and the API.
Rollbacks and backward-compatible migrations¶
On startup the server fails fast if the database is empty or behind the application. If the database is ahead (it was already migrated by a newer version, e.g. while the old revision still serves traffic or after a traffic rollback), the server only logs a warning and keeps starting. This means migrations must be backward compatible with the previous application version (expand/contract): add columns/tables first, and remove or rename them only in a later release once no older version uses them. python -m yaai.server.migrate run with an older image against a newer database fails because Alembic cannot locate the database revision.
If you need to revert the schema instead (e.g. a migration broke something), execute the job with the new image and override its arguments, then roll back the service:
gcloud run jobs execute yaai-migrate --region us-central1 --wait \
--args=-m,yaai.server.migrate,downgrade,<old-head-revision>
A Cloud Run Job is preferred over a Cloud Function: it reuses the already built application image (including Alembic and the migration files), so there is no separate packaging or dependency set to maintain, and it can run longer than function timeouts allow.
Google service account auth for SDK clients¶
SDK clients on GCP workloads can authenticate using Google Application Default Credentials instead of API keys:
AUTH_SERVICE_ACCOUNTS_GOOGLE_ENABLED=true
AUTH_SERVICE_ACCOUNTS_GOOGLE_ALLOWED_EMAILS=ml-pipeline@my-project.iam.gserviceaccount.com
# Audience defaults to BASE_URL -- only set if you need a different value
# AUTH_SERVICE_ACCOUNTS_GOOGLE_AUDIENCE=https://yaai.mycompany.com
from yaai import YaaiClient
# No api_key → automatically uses Google ADC
async with YaaiClient("https://yaai.mycompany.com/api/v1") as client:
model = await client.create_model("my-model")
Tip
SDK clients using Google SA auth need the gcp extra: pip install "yaai-monitoring[gcp]". Clients using API keys only need the base package.
Security¶
- Security headers -- every response carries a Content-Security-Policy (
script-src 'self', no framing, no plugins),X-Content-Type-Options,X-Frame-Options,Referrer-PolicyandPermissions-Policy;Strict-Transport-Securityis added whenBASE_URLishttps://or the request arrives over HTTPS. Disable withSECURITY_HEADERS_ENABLED=falseonly if your reverse proxy sets equivalent headers. When/docsis enabled it gets a relaxed CSP that allowscdn.jsdelivr.net. - Rate limiting -- keyed by client IP. Behind a proxy set
RATE_LIMIT_TRUSTED_PROXY_HOPS(see Configuration); YAAI then uses the N-th entry from the right ofX-Forwarded-For, ignoring client-supplied entries on the left. - Session tokens -- the web UI keeps the access token in memory only and the refresh token in an
httpOnly,SameSite=Laxcookie (yaai_refresh, path/api/v1/auth,SecurewhenBASE_URLishttps://), so an XSS cannot read a long-lived token. After a page reload the UI restores the session withPOST /api/v1/auth/refresh, which rotates the cookie (the previous token stays valid for 30 seconds so a reload or second tab racing the rotation is not logged out). This requires the UI and API to share one origin (the default: the server serves the UI). Keep dependencies updated and do not add third-party scripts to the frontend.
Running multiple instances¶
YAAI is designed as a single instance, but can run several (for example Cloud Run with max-instances > 1) if you take care of the following:
| Concern | Behavior | What to do |
|---|---|---|
AUTH_JWT_SECRET, SESSION_SECRET |
Required in production; tokens and OAuth state signed by one instance must validate on all others | Use the same values on every instance (Secret Manager) |
| Rate limiting | Counters live in memory per instance, so the effective limit is multiplied by the instance count | Set RATE_LIMIT_STORAGE_URI=redis://host:6379 (install the Redis client, e.g. pip install redis; it is not a YAAI dependency) |
| Client IP | Behind a proxy every user may share its IP | Set RATE_LIMIT_TRUSTED_PROXY_HOPS (see Configuration) |
| Google token verification caches | Kept in memory per instance; only a performance cache | Nothing, each instance fills its own. Login (including Google OAuth) is stateless and works on any instance, no session affinity needed |
| Scheduled drift jobs | Every instance with SCHEDULER_ENABLED=true runs its own in-memory scheduler, so each cron job would fire on every such instance |
Set SCHEDULER_ENABLED=false on all API instances and run the scheduled jobs in a single worker |
Scheduled jobs worker¶
The worker is a headless process (no HTTP server) that runs all scheduled drift jobs and therefore also creates the drift notifications. It uses the same image and configuration as the API; only the command differs:
Run the API with SCHEDULER_ENABLED=false on any number of instances and exactly one worker. The worker always runs the scheduler and checks that the database schema is up to date on startup, so run the migration first. It stops cleanly on SIGTERM/SIGINT.
Job changes made through the API reach the worker within 60 seconds (it re-reads the active jobs from the database on that interval). POST /jobs/{id}/trigger and the backfill endpoint still run inside the API request. A PostgreSQL advisory lock makes an overlapping execution of the same job skip; it does not prevent a job from running twice one after another, which is why there must be a single worker. The lock is skipped on non-PostgreSQL databases, and a job needs one database connection for its whole run.
On Cloud Run use a worker pool with one instance and the same service account and CLOUD_SQL_* settings as the API service:
gcloud run worker-pools deploy yaai-worker \
--image europe-west1-docker.pkg.dev/my-project/yaai/yaai:TAG \
--command python --args=-m,yaai.server.worker \
--service-account yaai-server@my-project.iam.gserviceaccount.com \
--set-env-vars CLOUD_SQL_INSTANCE=my-project:us-central1:yaai-db,CLOUD_SQL_USER=yaai-server@my-project.iam.gserviceaccount.com,CLOUD_SQL_DATABASE=aimonitoring \
--instances 1 \
--region us-central1
Worker pools have no URL and no CPU throttling, so no extra flags are needed. Deploy the worker after the migration job, together with the API. For docker compose see the worker service in docker-compose.yml.
Important
Cloud Run, single service: if the API service itself runs the scheduler (SCHEDULER_ENABLED=true, the default), it needs CPU outside of requests. Deploy it with "CPU always allocated" (--no-cpu-throttling) and --min-instances=1, otherwise cron jobs are silently missed. To scale the API beyond one instance, move the scheduler to the worker pool above instead.
Troubleshooting¶
Server refuses to start in production mode
: You're running with ENVIRONMENT=production but haven't changed the default changeme password in DATABASE_URL. Use strong credentials.
Server refuses to start: "AUTH_JWT_SECRET must be set in production" / "SESSION_SECRET must be set in production"
: Set both secrets (openssl rand -base64 32) or run with ENVIRONMENT=development.
"Database schema is not up to date" on startup
: The server does not apply migrations automatically. Run python -m yaai.server.migrate (Docker Compose: make sure the migrate service is defined and completed; Cloud Run: execute the migration job) and start the server again.
Migrations fail
: Check that PostgreSQL is running and DATABASE_URL (or the CLOUD_SQL_* variables) is correct.
Google OAuth login redirects fail
: Verify BASE_URL matches the origin users access in their browser, and that the OAuth redirect URI in Google Cloud Console matches {BASE_URL}/api/v1/auth/oauth/google/callback.
Cloud SQL connection fails
: Check that the service account has both Cloud SQL Client and Cloud SQL Instance User roles. Verify the IAM database user was created with gcloud sql users list --instance=yaai-db.
"Could not automatically determine credentials"
: No GCP credentials found. Run gcloud auth application-default login locally, or attach a service account to your Cloud Run / GKE workload.