# ============================================================================= # LocalAI + Keycloak + nginx-certbot + Terraform Provisioner # ============================================================================= # Copy .env.example to .env and fill in values before starting. # # Start order: # 1. docker compose up -d postgres keycloak # 2. docker compose run --rm terraform-provisioner # 3. docker compose up -d # ============================================================================= x-restart: &restart restart: unless-stopped networks: localai: # ============================================================================= # CONFIGS — all config files are inlined here and mounted into containers. # Compose interpolates ${VAR} inside content: blocks from the .env file. # To update a config: edit below and restart the affected service (no rebuild). # ============================================================================= configs: # --------------------------------------------------------------------------- # nginx: one server block per subdomain — no path tricks needed # --------------------------------------------------------------------------- nginx_localai_conf: content: | server { listen 80; listen [::]:80; server_name ${DOMAIN_WEBUI:-localhost}; client_max_body_size 512M; location / { proxy_pass http://openwebui:8080; proxy_http_version 1.1; 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 $$http_x_forwarded_proto; proxy_set_header Upgrade $$http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; } } server { listen 80; listen [::]:80; server_name ${DOMAIN_LOCALAI:-localhost}; client_max_body_size 512M; location / { proxy_pass http://localai:8080; proxy_http_version 1.1; 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 $$http_x_forwarded_proto; proxy_set_header Upgrade $$http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; } } server { listen 80; listen [::]:80; server_name ${DOMAIN_AUTH:-localhost}; location / { proxy_pass http://keycloak:8080; proxy_http_version 1.1; 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 $$http_x_forwarded_proto; proxy_read_timeout 120s; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } } # --------------------------------------------------------------------------- # Terraform: main.tf — realm, client, roles, and user resources. # seed_users values are declared in terraform.tfvars below, not here. # --------------------------------------------------------------------------- terraform_main_tf: content: | terraform { required_providers { keycloak = { source = "keycloak/keycloak" version = "~> 5.0" } } } provider "keycloak" { client_id = "admin-cli" username = var.keycloak_admin_user password = var.keycloak_admin_password url = "http://keycloak:8080" realm = "master" } variable "keycloak_admin_user" { type = string } variable "keycloak_admin_password" { type = string sensitive = true } variable "localai_client_secret" { type = string sensitive = true } variable "localai_base_url" { type = string } variable "openwebui_base_url" { type = string } variable "openwebui_client_secret" { type = string sensitive = true } variable "seed_users" { type = list(object({ username = string email = string first_name = string last_name = string password = string is_admin = bool })) } resource "keycloak_realm" "localai" { realm = "localai" enabled = true display_name = "LocalAI" login_theme = "keycloak" registration_allowed = false reset_password_allowed = true remember_me = true verify_email = false login_with_email_allowed = true duplicate_emails_allowed = false } resource "keycloak_openid_client" "localai" { realm_id = keycloak_realm.localai.id client_id = "localai" name = "LocalAI" enabled = true access_type = "CONFIDENTIAL" standard_flow_enabled = true direct_access_grants_enabled = false service_accounts_enabled = false client_secret = var.localai_client_secret valid_redirect_uris = [ "$${var.localai_base_url}/api/auth/oidc/callback", ] web_origins = [ var.localai_base_url, ] } resource "keycloak_role" "localai_admin" { realm_id = keycloak_realm.localai.id name = "localai-admin" description = "LocalAI administrator" } resource "keycloak_role" "localai_user" { realm_id = keycloak_realm.localai.id name = "localai-user" description = "LocalAI regular user" } resource "keycloak_user" "seed_users" { for_each = { for u in var.seed_users : u.username => u } realm_id = keycloak_realm.localai.id username = each.value.username email = each.value.email first_name = each.value.first_name last_name = each.value.last_name enabled = true initial_password { value = each.value.password temporary = false } } resource "keycloak_user_roles" "seed_roles" { for_each = { for u in var.seed_users : u.username => u } realm_id = keycloak_realm.localai.id user_id = keycloak_user.seed_users[each.key].id role_ids = each.value.is_admin ? [ keycloak_role.localai_admin.id, keycloak_role.localai_user.id, ] : [ keycloak_role.localai_user.id, ] } resource "keycloak_openid_client" "openwebui" { realm_id = keycloak_realm.localai.id client_id = "openwebui" name = "Open WebUI" enabled = true access_type = "CONFIDENTIAL" standard_flow_enabled = true direct_access_grants_enabled = false service_accounts_enabled = false client_secret = var.openwebui_client_secret valid_redirect_uris = [ "$${var.openwebui_base_url}/oauth/oidc/callback", ] web_origins = [ var.openwebui_base_url, ] } resource "keycloak_openid_user_realm_role_protocol_mapper" "openwebui_realm_roles" { realm_id = keycloak_realm.localai.id client_id = keycloak_openid_client.openwebui.id name = "realm-roles" claim_name = "roles" multivalued = true add_to_id_token = true add_to_access_token = true } output "oidc_issuer" { value = "http://keycloak:8080/realms/$${keycloak_realm.localai.realm}" } # --------------------------------------------------------------------------- # Terraform: terraform.tfvars — seed user accounts. # Compose interpolates ${...} here, so these values come from .env. # Add or remove objects in the list to control which accounts are created. # is_admin=true grants the localai-admin role in Keycloak. # --------------------------------------------------------------------------- terraform_tfvars: content: | seed_users = [ { username = "${TF_SEED_ADMIN_USERNAME:-localai-admin}" email = "${TF_SEED_ADMIN_EMAIL:-admin@example.com}" first_name = "LocalAI" last_name = "Admin" password = "${TF_SEED_ADMIN_PASSWORD:-change_me_admin}" is_admin = true }, { username = "${TF_SEED_USER_USERNAME:-localai-user}" email = "${TF_SEED_USER_EMAIL:-user@example.com}" first_name = "LocalAI" last_name = "User" password = "${TF_SEED_USER_PASSWORD:-change_me_user}" is_admin = false }, ] # --------------------------------------------------------------------------- # Terraform: entrypoint — waits for Keycloak, copies read-only configs to # the writable /tf workdir, then runs init + apply. # --------------------------------------------------------------------------- terraform_entrypoint: content: | #!/bin/sh set -e echo "Waiting for Keycloak..." until wget -qO- http://keycloak:8080/realms/master > /dev/null 2>&1; do sleep 5 done echo "Keycloak ready." cp /tf-config/main.tf /tf/main.tf cp /tf-config/terraform.tfvars /tf/terraform.tfvars cd /tf terraform init -input=false terraform apply -input=false -auto-approve echo "Done. OIDC issuer: $(terraform output -raw oidc_issuer)" # ============================================================================= # SERVICES # ============================================================================= services: # --------------------------------------------------------------------------- # 1. PostgreSQL — backing store for Keycloak # --------------------------------------------------------------------------- postgres: image: postgres:16-alpine container_name: localai-postgres <<: *restart environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keycloak_secret} volumes: - ./volumes/postgres/data:/var/lib/postgresql/data networks: - localai healthcheck: test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] interval: 10s timeout: 5s retries: 10 start_period: 20s # --------------------------------------------------------------------------- # 2. Keycloak # --------------------------------------------------------------------------- keycloak: image: quay.io/keycloak/keycloak:25.0 container_name: localai-keycloak <<: *restart command: start-dev environment: KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin_secret} KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-keycloak_secret} KC_HOSTNAME: https://${DOMAIN_AUTH:-localhost} KC_PROXY_HEADERS: xforwarded KC_HTTP_ENABLED: "true" volumes: - ./volumes/keycloak/data:/opt/keycloak/data - ./volumes/keycloak/themes:/opt/keycloak/themes networks: - localai depends_on: postgres: condition: service_healthy healthcheck: test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q '200 OK' <(cat <&3)"] interval: 15s timeout: 10s retries: 15 start_period: 60s # --------------------------------------------------------------------------- # 3. Terraform provisioner (one-shot) # --------------------------------------------------------------------------- terraform-provisioner: image: hashicorp/terraform:1.9 container_name: localai-terraform restart: "no" entrypoint: ["sh", "/entrypoint.sh"] environment: TF_VAR_keycloak_admin_user: ${KEYCLOAK_ADMIN_USER:-admin} TF_VAR_keycloak_admin_password: ${KEYCLOAK_ADMIN_PASSWORD:-admin_secret} TF_VAR_localai_client_secret: ${LOCALAI_OIDC_CLIENT_SECRET:-localai_oidc_secret} TF_VAR_localai_base_url: https://${DOMAIN_LOCALAI:-localhost} TF_VAR_openwebui_client_secret: ${OPENWEBUI_OIDC_CLIENT_SECRET:-openwebui_oidc_secret} TF_VAR_openwebui_base_url: https://${DOMAIN_WEBUI:-localhost} volumes: - ./volumes/terraform/tf:/tf configs: - source: terraform_main_tf target: /tf-config/main.tf mode: 0444 - source: terraform_tfvars target: /tf-config/terraform.tfvars mode: 0444 - source: terraform_entrypoint target: /entrypoint.sh mode: 0555 networks: - localai depends_on: keycloak: condition: service_healthy # --------------------------------------------------------------------------- # 4. LocalAI # --------------------------------------------------------------------------- localai: image: localai/localai:latest-gpu-nvidia-cuda-12 container_name: localai-app <<: *restart deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] environment: LOCALAI_AUTH: "true" LOCALAI_OIDC_ISSUER: https://${DOMAIN_AUTH:-localhost}/realms/localai LOCALAI_OIDC_CLIENT_ID: localai LOCALAI_OIDC_CLIENT_SECRET: ${LOCALAI_OIDC_CLIENT_SECRET:-localai_oidc_secret} LOCALAI_BASE_URL: https://${DOMAIN_LOCALAI:-localhost} LOCALAI_ADMIN_EMAIL: ${LOCALAI_ADMIN_EMAIL:-admin@example.com} LOCALAI_API_KEY: ${LOCALAI_API_KEY:-} LOCALAI_REGISTRATION_MODE: invite LOCALAI_DISABLE_LOCAL_AUTH: "true" LOCALAI_MODELS_PATH: /models LOCALAI_LOG_LEVEL: debug LOCALAI_AUTH_DATABASE_URL: postgres://keycloak:${POSTGRES_PASSWORD:-keycloak_secret}@postgres:5432/localai?sslmode=disable LOCALAI_PARALLEL_REQUESTS: "true" volumes: - ./volumes/localai/models:/models - ./volumes/localai/data:/data - ./volumes/localai/backends:/backends networks: - localai depends_on: keycloak: condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8080/version || exit 1"] interval: 30s timeout: 10s retries: 5 start_period: 30s # --------------------------------------------------------------------------- # 5. Open WebUI — chat frontend # --------------------------------------------------------------------------- openwebui: image: ghcr.io/open-webui/open-webui:main container_name: localai-openwebui <<: *restart environment: WEBUI_URL: https://${DOMAIN_WEBUI:-localhost} WEBUI_SECRET_KEY: ${OPENWEBUI_SECRET_KEY:-change_me_webui_secret} OPENAI_API_BASE_URL: http://localai:8080/v1 OPENAI_API_KEY: ${LOCALAI_API_KEY:-} OPENID_PROVIDER_URL: https://${DOMAIN_AUTH:-localhost}/realms/localai/.well-known/openid-configuration OAUTH_CLIENT_ID: openwebui OAUTH_CLIENT_SECRET: ${OPENWEBUI_OIDC_CLIENT_SECRET:-openwebui_oidc_secret} OAUTH_PROVIDER_NAME: Keycloak OAUTH_SCOPES: openid email profile OAUTH_ROLES_CLAIM: roles OAUTH_ALLOWED_ROLES: localai-user,localai-admin OAUTH_ADMIN_ROLES: localai-admin ENABLE_OAUTH_SIGNUP: "true" ENABLE_LOGIN_FORM: "false" volumes: - ./volumes/openwebui/data:/app/backend/data networks: - localai depends_on: localai: condition: service_healthy keycloak: condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"] interval: 30s timeout: 10s retries: 5 start_period: 30s # --------------------------------------------------------------------------- # 6. nginx — reverse proxy # --------------------------------------------------------------------------- nginx: image: nginx:alpine container_name: localai-nginx <<: *restart ports: - "80:80" configs: - source: nginx_localai_conf target: /etc/nginx/conf.d/default.conf mode: 0444 networks: - localai depends_on: localai: condition: service_healthy keycloak: condition: service_healthy