Azilos

Installing Azilos for a Team

Audience: a team installing Azilos on a host of its own and keeping it running: its settings, its backups and its upgrades (B-023). It builds on the one-command install, docs/operations/install-compose.md (ADR-475), which brings up Postgres, platform-api and the dashboard on one host. Read that first; this guide does not repeat it.

The rule of docs/operations/mvp-runbook.md applies here too: every command below was run as written, and a step that was not run says not run. How this was checked says where and how.

Once it is installed, §7 is the path a team follows to modernize a repository: from the dashboard's Run action (ADR-478) to the approval gate and the pull request. Apply validation and Deploy run on the stack's own Docker daemon (install-compose.md, The Docker daemon, ADR-535).

1. Keep the settings in one file, outside the repository

Every setting is an environment variable (install-compose.md, Settings). On a team's host, keep them in one file that only the account running Azilos can read, outside the checkout, and give it to every docker compose command with --env-file. Nothing then has to be typed on a command line, where a shell's history would keep it.

sudo install -d -m 700 /etc/azilos
sudo install -m 600 /dev/null /etc/azilos/azilos.env

Then write the file, for example:

AZILOS_VERSION=v0.1.0
AZILOS_POSTGRES_PASSWORD='<postgres-password>'
AZILOS_APP_DB_PASSWORD='<app-password>'
AZILOS_OPERATOR_ID=<your-operator-id>
AZILOS_TENANT_ID=acme
AZILOS_ADMIN_PRINCIPAL_ID=<admin-principal-id>
AI_GATEWAY_KIND=azilos
AZILOS_CLOUD_API_KEY='<your-azilos-api-key>'
AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED=true

Create the Azilos API key at https://api.azilos.com/account before you install: the API does not start without it. Under the image's NODE_ENV=production, platform-api exits at start-up unless AI_GATEWAY_KIND=azilos, AZILOS_CLOUD_API_KEY (or AZILOS_CLOUD_API_KEY_FILE, below) and AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED=true are all set, and its log names each one that is missing (ADR-684, B-046). Start-up makes no call to Azilos Cloud. §7, AI from Azilos Cloud, describes what the key is used for.

AZILOS_VERSION is the release Azilos runs: its image, ghcr.io/valintes/azilos:<version>, is pulled the first time (ADR-678), and §4 changes it to upgrade. Unpack the same release's azilos-deploy-<version>.tar.gz, from https://github.com/Valintes/azilos-releases/releases, into a directory of its own, for example /opt/azilos; it holds deploy/ and scripts/provision-app-role.sql.

Quote a value with single quotes when it holds anything but letters and digits. Compose reads a single-quoted value as written: a password with a space, a " and a % in it worked in the rehearsal.

Which settings are secrets:

SettingSecretWhere it is used
AZILOS_POSTGRES_PASSWORDyesPostgres's azilos superuser; the app-role and setup steps.
AZILOS_APP_DB_PASSWORDyesThe azilos_app role platform-api connects as (ADR-001).
AZILOS_GIT_SOURCE_PROVIDER_CREDENTIALyesReading your Git remotes (§7).
AZILOS_APPLY_TRANSFORMATION_GIT_CREDENTIALyesPushing the Apply branch and opening its pull request (§7).
AZILOS_CLOUD_API_KEYyesYour Azilos API key: every AI request (§7). Required to start.
everything else in install-compose.md's SettingsnoNames, ports, URLs and the identity provider's public settings.

No secret is ever written into deploy/compose.yaml, into the image or into a URL. Never commit the file, and never copy it into the checkout.

Every secret platform-api and its commands read (AZILOS_APP_DB_PASSWORD, both Git credentials and their per-tenant forms, and AZILOS_CLOUD_API_KEY) can instead come from a file: set <NAME>_FILE to the file's path, for example a Docker secret mounted at /run/secrets/<name>, and leave <NAME> unset (ADR-624). The value is then not visible to docker inspect or in the process environment. A trailing newline in the file is dropped. Setting both forms, or naming a file that cannot be read, stops the API at start with the variable's name and the path, never the secret.

With Compose, add deploy/compose.secret-files.yaml to every command (ADR-625). It mounts one host directory, AZILOS_SECRETS_DIR, read-only at /run/secrets/azilos in the API only, and passes on the _FILE form of both Git credentials and AZILOS_CLOUD_API_KEY. The API runs as uid 1000, so each file must be readable by it:

sudo install -d -m 700 -o 1000 /etc/azilos/secrets
sudo install -m 400 -o 1000 /dev/null /etc/azilos/secrets/cloud-api-key

Write the key into that file, remove AZILOS_CLOUD_API_KEY from /etc/azilos/azilos.env, and add:

AZILOS_SECRETS_DIR=/etc/azilos/secrets
AZILOS_CLOUD_API_KEY_FILE=/run/secrets/azilos/cloud-api-key

Then bring Azilos up with --file deploy/compose.yaml --file deploy/compose.secret-files.yaml.

The two database passwords move with their own overlay, deploy/compose.db-password-files.yaml (ADR-626). It takes both from a second host directory, AZILOS_DB_PASSWORDS_DIR, which must hold the files postgres-password and app-db-password. Each file is mounted read-only into only the services that use it: the Postgres password into postgres, app-role and setup, the azilos_app password into app-role, setup and api. Both files must exist and be readable by uid 1000:

sudo install -d -m 700 -o 1000 /etc/azilos/db-passwords
sudo install -m 400 -o 1000 /dev/null /etc/azilos/db-passwords/postgres-password
sudo install -m 400 -o 1000 /dev/null /etc/azilos/db-passwords/app-db-password

Write each password into its file, remove AZILOS_POSTGRES_PASSWORD and AZILOS_APP_DB_PASSWORD from /etc/azilos/azilos.env, add AZILOS_DB_PASSWORDS_DIR=/etc/azilos/db-passwords, and add --file deploy/compose.db-password-files.yaml to every command. On an install that already runs, write the passwords it uses today: Postgres reads its password only when it creates its volume, so the overlay changes no password. An empty or unreadable file stops the step that reads it. The two overlays can be used together. The per-tenant Git credentials still pass as variables.

From here on, every command is run from the directory that holds deploy/ (/opt/azilos, or the repository root of a checkout), and --env-file /etc/azilos/azilos.env stands in front of --file deploy/compose.yaml. Bringing Azilos up is then:

docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach

To run a commit that is not a release, build the image from a checkout instead: set AZILOS_VERSION=local, add --file deploy/compose.build.yaml after --file deploy/compose.yaml in every command, and add --build to up (install-compose.md, Building the image from a checkout). When building, if this host reaches the npm registry through a proxy that intercepts TLS, or through a mirror served under a private authority, the build stops at its first RUN with SELF_SIGNED_CERT_IN_CHAIN: the build's containers do not trust that authority. Give it to the build with deploy/compose.private-ca-build.yaml and the authority's PEM file (ADR-575):

AZILOS_EXTRA_CA_CERTS_FILE=/etc/azilos/company-ca.pem BUILDX_BAKE_ENTITLEMENTS_FS=0 \
  docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml \
  --file deploy/compose.build.yaml --file deploy/compose.private-ca-build.yaml up --detach --build

The file reaches only the steps that download packages, as a BuildKit secret; no image layer records it. Compose builds through Bake, which reads a file outside the repository only when allowed, and BUILDX_BAKE_ENTITLEMENTS_FS=0 allows it for this command. That file affects the build only: the containers that run trust the authority through deploy/compose.private-ca.yaml (§6, §7), which you add to the same command when they need it. A proxy the build must go through is Docker's own setting (the proxies of the Docker client's config.json), not Azilos's.

2. Back up the database

Everything Azilos keeps is in the Postgres database: tenants, memberships, projects, runs, their audit log, and the row-level security that keeps tenants apart (ADR-001). The repositories it modernizes stay where they are. One command writes a backup of all of it, while Azilos keeps running. Keep backups in a directory only that account can read, not in the checkout:

sudo install -d -m 700 /var/backups/azilos
docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml \
  exec --no-TTY postgres pg_dump --username azilos --dbname azilos --format custom \
  > /var/backups/azilos/azilos-$(date +%F).dump

The file holds every tenant's data. Copy it off the host as well: a backup on the same disk is lost with the disk.

To see what a backup holds without restoring it:

docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml \
  exec --no-TTY postgres pg_restore --list < /var/backups/azilos/azilos-<date>.dump

In the rehearsal, it listed 29 TABLE DATA entries, one per table.

The azilos_app role is not in the backup. A role belongs to the Postgres server, not to one database; the restore below creates it again first.

3. Restore a backup

A backup is restored into an empty database: on a new host, or on this one after its database volume is deleted. It does not merge into a database Azilos is already using; on one that already has the tables, pg_restore --exit-on-error stops at the first table that exists.

  1. On this host only: stop everything and delete the database volume. This deletes every tenant in the current database for good. Check first that the backup lists its tables (§2).

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml down --volumes
    
  2. Start Postgres and create the azilos_app role in the new, empty database, and nothing else:

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach app-role
    

    Do not start the whole stack yet: its setup step would create an empty schema, and the restore would then stop at the first table.

  3. Restore:

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml \
      exec --no-TTY postgres pg_restore --username azilos --dbname azilos --exit-on-error \
      < /var/backups/azilos/azilos-<date>.dump
    

    It prints nothing when it succeeds.

  4. Start the rest:

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach
    

    setup finds the restored tenant and leaves it as it is: its log ends with Tenant "acme" already exists; left as it is. First-time setup is complete.

The restore brings back the tables, their rows, their row-level security and every azilos_app grant. In the rehearsal, before and after: the same tenant, membership and audit rows, row-level security on all 29 tables, and 69 grants to azilos_app. Connected as azilos_app, the restored memberships were invisible without a tenant binding and visible with app.tenant_id set to their tenant, as before the backup.

On a new host, AZILOS_APP_DB_PASSWORD may differ from the old host's: step 2 creates the role with the new password. AZILOS_TENANT_ID and AZILOS_ADMIN_PRINCIPAL_ID should name a tenant that is in the backup; any other tenant is created as a new, empty one.

4. Move to a newer version

A newer version is a newer release: a tag with its own image and its own azilos-deploy-<version>.tar.gz (ADR-673, ADR-678). setup brings the schema up to date on the way up: provisioning is idempotent and additive (ADR-088), so it creates what the newer version adds and leaves existing tables, rows and tenants as they are.

  1. Back up first (§2). It is the way back.

  2. Unpack the newer release's azilos-deploy-<version>.tar.gz over the directory that holds deploy/, so its compose files match its image, and set AZILOS_VERSION in /etc/azilos/azilos.env to the newer version.

  3. Pull and restart:

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach
    

    Compose pulls the newer image, because its tag is new, and recreates the services that run it. From a checkout instead, check out the newer commit and run step 3 with --file deploy/compose.build.yaml and --build (§1).

  4. Check it came up: setup's log ends with First-time setup is complete., and GET http://127.0.0.1:3000/readiness answers 200 with "status":"ready".

    docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml logs setup
    

To go back to the earlier version, set AZILOS_VERSION back and unpack its release again, then restore the backup from step 1 (§3). Running an earlier version on a schema a newer one has extended is not supported.

Moving to a version with ADR-738 from one without it: AI code fixes are then off for every project that already exists, whatever the tenant consented to, until an admin turns them on for that project (§7, From the dashboard, step 3). Nothing else about a project changes, and a fix that was already proposed can still be approved or rejected.

Not run as a whole: no release has been published yet, and no rehearsal has yet moved an install from one version to a newer one (see How this was checked). What was observed is step 4's setup on a database that already had the schema and the tenant.

5. What leaves the network, and when

This section is read from the code (ADR-486 names the files); it describes no command to run.

Pulling the image. up pulls ghcr.io/valintes/azilos:<AZILOS_VERSION> from GitHub's container registry the first time a version runs, and postgres:16 and docker:28.5.1-dind from Docker Hub. Nothing about your tenants is in those requests.

Building the image (from a checkout, with deploy/compose.build.yaml). up --build pulls node:22-bookworm-slim from Docker Hub, installs pnpm and the workspace's packages from registry.npmjs.org, and installs git from the Debian package mirrors (install-compose.md, Building the image from a checkout). Nothing about your tenants is in those requests.

The stack as deploy/compose.yaml runs it. The API's container receives the database settings, the dashboard origin, the Docker daemon's, and a Git source with no remote allowed; then OIDC_ISSUER, OIDC_AUDIENCE and §7's settings only when you set them. Every capability below is off without its own setting, so the stack as shipped makes these connections only:

FromToWhat is sentWhen
platform-apiPostgres, inside the stackeverything Azilos storesalways
platform-apiyour identity provider (OIDC_ISSUER)plain GETs for its discovery document and signing keysthe discovery document once, on the first request with a token; the keys then, and again when the provider changes them
a user's browserthe API and, with the dashboard's OIDC set, your identity providerthe user's requests; the sign-in flowwhile someone uses the dashboard

No telemetry leaves the host: traces and logs are written to the containers' standard output only.

Capabilities that send data when the operator turns them on. Each one is off until its own settings are in the settings file (§7), and most also need the tenant's consent, recorded through its consent route or the deployment's *_CONSENTED_TENANT_IDS list. They are listed so a team knows what it is agreeing to before it enables any of them.

CapabilityTurned on byDestinationWhat leavesWhen
Reading repositoriesSOURCE_PROVIDER_KIND=git, GIT_SOURCE_PROVIDER_REMOTESonly the Git remotes on that allowlistthe tenant's read credential; code is downloaded, not uploadedcreating a project, Understand, validation
Writing the Apply branchAPPLY_TRANSFORMATION_TARGET_KIND=git, write credential, consentthe same allowlisted remotesa new branch with Azilos's commitsApply, an approved AI fix
Opening a pull requestthe Apply target above and a per-project opt-inhttps://api.github.com, or GITHUB_API_BASE_URLthe branch names and the modernization report as the PR bodyPOST .../apply/pull-request
AI from Azilos CloudAI_GATEWAY_KIND=azilos, an Azilos API key and acknowledgment, consenthttps://api.azilos.com or AZILOS_CLOUD_API_URL; then its vendorUnderstand insight: project metadata. Document: project facts. AI code fix: source files and build outputeach AI route, per its own consent
Package lookupsIMPORT_VERIFICATION_TARGET_KIND=npm-public-registry, consentregistry.npmjs.org, unpkg.comdependency names and versions, in the URLPlan, import verification, AI fix
Vulnerability scanVULNERABILITY_SCAN_TARGET_KIND=osv-public-database, consentapi.osv.devdependency names, versions and ecosystemsPOST .../vulnerability-scan
Apply validation, DeployDEPLOYMENT_TARGET_KIND=docker, the stack's own daemon (ADR-535)the image registry each image names (Docker Hub for node:<major>)image pulls by the daemon; the containers themselves get no network unless APPLY_VALIDATION_NETWORK or DEPLOYMENT_BUILD_NETWORK says otherwiseApply, validation, AI fix re-validation, Deploy

Before enabling AI, read docs/operations/ai-vendor-data-processing.md: it is the operator's responsibility. The AI code fix sends the contents of the repository's files to Azilos Cloud and on to the vendor it calls. It does so only for a project an admin has turned AI code fixes on for (ADR-738), on top of the tenant's consent: a project nobody named sends nothing.

With APPLY_VALIDATION_NETWORK=default, the validation containers install the repository's dependencies from the registry its package manager is configured for, by default registry.npmjs.org, and run the repository's own build and test scripts with that network.

6. Connect your own identity provider

Without an identity provider, platform-api serves no tenant route, and the dashboard cannot sign anyone in. The local provider of install-compose.md is for a trial only. A team connects the OpenID Connect provider it already uses (Keycloak, Entra ID, Okta, Auth0, ...). Azilos speaks only standard OIDC discovery and signed JWTs, so it needs no vendor-specific setting (ADR-002, ADR-084).

B-029's rehearsal (ADR-587) ran these steps against a real provider, Keycloak 26.0, with an issuer on loopback rather than HTTPS; How this was checked says how. A provider under a private authority (below) was not run.

What the provider must give

One application registration at the provider, for the dashboard, does everything. It must be:

The dashboard asks for the scope openid only, and sends the access token to the API, never the ID token. That access token must:

ClaimMust be
(format)a signed JWT, with an asymmetric algorithm (RS*, PS*, ES* or EdDSA). A provider that issues opaque access tokens is refused
issthe provider's issuer, character for character as its discovery document gives it
audOIDC_AUDIENCE, alone or in a list. Most providers need an audience mapper or an API registration for this
exppresent and in the future
subthe user's stable id at the provider. It is who Azilos grants roles to, and it must not start with operator: (ADR-079)
tenant_idthe Azilos tenant the user belongs to, for example acme. The name is fixed. Most providers add it with a claim mapper

A token missing any of these is answered 401 invalid credential. A provider platform-api cannot reach, or one that answers its discovery request with an error or a redirect, is answered 503 identity provider unavailable, and the API's log names the cause (ADR-388).

What the host must give

The settings

Add four lines to the settings file of §1. None of them is a secret: they name the provider and the application, and anyone who loads the dashboard can read the two it is given.

OIDC_ISSUER=https://id.example.com/realms/azilos
OIDC_AUDIENCE=azilos-platform-api
AZILOS_DASHBOARD_OIDC_ISSUER=https://id.example.com/realms/azilos
AZILOS_DASHBOARD_OIDC_CLIENT_ID=azilos-dashboard

The dashboard reads its settings when it starts (ADR-675), so bring the stack up again, without --build:

docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach

Do not add deploy/compose.trial-identity.yaml: it replaces OIDC_ISSUER and OIDC_AUDIENCE with the local provider's.

The first admin, and everyone else

AZILOS_ADMIN_PRINCIPAL_ID (install-compose.md) is that person's sub, as the provider puts it in their access token, and not their user name or email address. Many providers use a generated id there. Find it before the first up: the setup reads it only while the tenant does not exist, and a wrong value leaves a tenant whose only admin nobody can sign in as.

Everyone else signs in the same way, and is refused (forbidden) until they have a role in their tenant: viewer, operator or admin (ADR-073). An admin grants one with grant-tenant-role, as install-compose.md shows, using the person's sub. The dashboard's Members view lists who has which role; it does not grant.

Signing in

Open the dashboard and choose Sign in. The browser goes to the provider, then back to /callback, and the dashboard shows the tenant's projects. If it stops instead, its message names the step:

The dashboard saysCheck
AZILOS_DASHBOARD_OIDC_ISSUER is not configured ... the two AZILOS_DASHBOARD_OIDC_* settings, then up, and the dashboard's /config.json shows them
Failed to reach OIDC discovery endpoint ...the issuer's address from the browser, and that the provider allows the dashboard's origin (CORS)
OIDC provider returned an error: ...the provider's own error code, for example PKCE or the scope refused. A wrong redirect URI stops on the provider's page instead
Token endpoint responded with status ...that the client is public, allows PKCE, and allows the dashboard's origin
signed in, but each view answers invalid credentialthe access token's aud, iss and tenant_id against the table above
signed in, but a view answers forbiddenthe person lacks the role it needs, AZILOS_ADMIN_PRINCIPAL_ID is not their sub, or tenant_id names no tenant

The dashboard keeps the access token in memory only (ADR-037), and does not refresh it yet. Reloading the page signs the user out. Once the token's exp has passed, the next request the dashboard makes signs the user out instead, with Your sign-in has expired. Sign in again. (ADR-563). The token's lifetime is the provider's setting. A run started from the dashboard acts with the token its starter had, so a run that outlives it stops with credential_rejected (ADR-570): sign in again and resume it from the phase that stopped. Give tokens a lifetime longer than a run takes, validation included.

7. Modernize a repository: the Run path

This is the path from a repository to a reviewed pull request, all from the dashboard: add the project, press Run, approve the AI's fix, and the run ends with the branch deployed, documented and proposed as a pull request. Each phase is one of the API's routes, run in order by the same sequence the run-modernization command runs (ADR-477, B-020). What follows needs §6's identity provider: the trial provider of install-compose.md cannot sign anyone in to the dashboard.

The settings a run needs

Add them to the settings file of §1. Compose passes each one on to the API only when it is set (ADR-536); a capability whose settings are missing stays off, and the run says where it stopped.

# Where the repositories are: a name for each, and its HTTPS URL. A project's "source location"
# is one of these names. Nothing else can be read.
GIT_SOURCE_PROVIDER_REMOTES=billing=https://git.example.com/acme/billing.git
AZILOS_GIT_SOURCE_PROVIDER_CREDENTIAL='<read token>'

# Apply: commit the upgrades to a new branch in the same repository.
APPLY_TRANSFORMATION_TARGET_KIND=git
APPLY_TRANSFORMATION_CONSENTED_TENANT_IDS=acme
AZILOS_APPLY_TRANSFORMATION_GIT_CREDENTIAL='<token that may push branches and open pull requests>'

# Plan: newer versions from the npm registry, and the Node LTS major the runtime moves to.
IMPORT_VERIFICATION_TARGET_KIND=npm-public-registry
IMPORT_VERIFICATION_CONSENTED_TENANT_IDS=acme
NODE_LTS_MAJOR_VERSION=22

# Apply validation: the containers that install, build and test may reach the registry.
APPLY_VALIDATION_NETWORK=default

Give each token only what it needs (ADR-611). Azilos never pushes to the default branch and never force-pushes, but that is Azilos's own code. The token can do whatever its scope allows, so a leaked one should be worth as little as possible:

acme stands for your tenant (AZILOS_TENANT_ID). Optional, with the same file:

Limits and timeouts (ADR-697). A stop that names one of these says to raise it. Each is a whole number; unset keeps the default.

SettingWhat it boundsDefault
APPLY_VALIDATION_TIMEOUT_MSone validation's install, build and test, in milliseconds900000 (15 minutes)
MAX_CONCURRENT_APPLY_VALIDATIONS_PER_TENANTvalidations a tenant runs at once1
DEPLOYMENT_BUILD_TIMEOUT_MSDeploy's image build, in milliseconds120000 (2 minutes)
MAX_CONCURRENT_DEPLOYMENTS_PER_TENANTdeployments a tenant runs at once2
GIT_SUBPROCESS_TIMEOUT_MSone Git command against the remote, in milliseconds300000 (5 minutes)
SOURCE_MAX_FILE_BYTESthe largest file a run reads from the repository, in bytes8388608 (8 MiB)
SOURCE_MAX_FILE_COUNTthe files a run reads from the repository100000
SOURCE_MAX_TOTAL_BYTESall the files a run reads from the repository, in bytes2147483648 (2 GiB)
AI_CODE_FIX_MAX_PROPOSALS_PER_APPLY_ATTEMPTAI fix proposals for one Apply attempt3
AI_TENANT_TOKEN_BUDGETAI tokens a tenant may use in one windownone: no budget
AI_TENANT_TOKEN_BUDGET_WINDOW_MSthat window, in milliseconds2592000000 (30 days)

Each *_CONSENTED_TENANT_IDS list is only a fallback: an admin decides for the tenant in the dashboard's AI Usage & Consent view, and that decision wins. platform-api reads every setting when it starts, so apply a change with:

docker compose --env-file /etc/azilos/azilos.env --file deploy/compose.yaml up --detach

The stack keeps its Git cache and Apply's clones in the workspace volume, which the Docker daemon shares (ADR-535, ADR-536).

AI from Azilos Cloud

The AI routes (the AI fix, Document's synthesis, the Understand insight) use AI bought as prepaid credits at azilos.com (ADR-593, ADR-594). Each request is charged against the balance at the token counts the vendor reports. Azilos Cloud calls the vendor with its own key, so this install holds no vendor key.

Your account is at https://api.azilos.com/account (ADR-601 – ADR-605):

  1. Sign in with GitHub. The first sign-in creates the account. There is no password.
  2. Under Buy credits, pick a pack. Paddle's checkout takes the payment, and the credits show in the balance once Paddle confirms it: reload the page.
  3. Under API keys, choose Create an API key. The key is shown once, so copy it into the settings below. Azilos Cloud keeps only its hash.

The same page shows the balance, the newest ledger entries, and the usage per key and per day. Under Low-balance email, give an address and a number of credits: when a request leaves the balance below it, Azilos Cloud sends one email, and another only after you buy credits or change it (ADR-672). Empty both fields to remove it. Revoke a key there when an install no longer needs it, or when it may have leaked. A revoked key stops working at once, and the install's next AI request stops the run with credential_rejected. One key per install shows which install spends the balance. The dashboard's AI usage view shows this install's balance too, to a tenant admin (ADR-622).

In the same settings file:

AI_GATEWAY_KIND=azilos
# The prompts, and the repository's files in an AI fix request, go to Azilos Cloud and on to the
# vendor it calls (§5). Set this once you have read docs/operations/ai-vendor-data-processing.md.
AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED=true
# Secret: the API key from your account page, https://api.azilos.com/account.
AZILOS_CLOUD_API_KEY=<your Azilos API key>

AZILOS_CLOUD_API_URL changes where the requests go; the default is https://api.azilos.com. The account page is at /account under the same address. Without AI_GATEWAY_REQUEST_TIMEOUT_MS, a request to Azilos Cloud waits up to 150 seconds, as Azilos Cloud itself waits up to 120 seconds for the vendor, and the balance read up to 30 seconds (ADR-629). When it is set, both use it; a value below 150 seconds abandons the slowest answers while Azilos Cloud still holds their credit. The former vendor settings (AI_GATEWAY_CLOUD_VENDOR, AI_GATEWAY_CLOUD_MODEL, AZILOS_AI_GATEWAY_CREDENTIAL, AZILOS_AI_GATEWAY_ANTHROPIC_CREDENTIAL) are not read (ADR-737). Without the key or the acknowledgment, the AI routes answer ... is not configured.

A run stops with:

When the credits run out

New work needs credits (ADR-686, ADR-687). Before it starts a run or a step, the API reads the balance from Azilos Cloud. That covers the dashboard's Run and Resume from, POST .../modernization-run, and each step's own POST under /projects/<project-id>: Understand (with its structure and insight), Model, Assessment, Plan, Transform, validation, Deploy, Apply, Apply validation, the AI fix and its re-validation, import verification, the vulnerability scan, Document and the pull request. The answer depends on what it reads:

What Azilos Cloud answersThe request is answered
A positive balanceas before: the work starts
A balance of zero or less402 no Azilos credits left: add credits at https://api.azilos.com/account
The key is rejected (401)502 Azilos Cloud rejected this install's API key: check it at https://api.azilos.com/account
Nothing usable, and no positive balance read in the last 24 hours503 Azilos Cloud could not be reached to check the credits left: try again shortly
Nothing usable, but a positive balance was read in the last 24 hoursas before: the work starts

A refusal writes a warn line, new work refused: ai credits, with the tenant, the route and the reason (no_credits, credential_rejected or unreachable), and no audit event.

Behind a proxy, a private authority or your own registry

Apply validation installs the repository's packages in containers, and so does the lockfile regeneration Apply runs first. On a network that intercepts TLS, or that reaches the registry only through a proxy or a mirror, give those containers what the host itself uses (ADR-537):

# The proxy the package managers use, and the hosts they reach directly. Secret if it carries a
# user and password.
CONTAINER_HTTPS_PROXY=http://proxy.example.com:3128
CONTAINER_NO_PROXY=localhost,.example.com

# A registry that serves the public npm packages, used instead of registry.npmjs.org.
CONTAINER_NPM_REGISTRY_URL=https://npm.example.com/repository/npm/

For a private authority, add deploy/compose.private-ca.yaml as in §6, with AZILOS_EXTRA_CA_CERTS_FILE: it now also mounts the file into the docker service, and the API mounts it read-only into each of those containers, where Node, and so npm, pnpm, Yarn and Corepack, trust it beside the public authorities. The proxy and registry reach the containers through an env file only the API's user can read, removed once the command ends, never on a command line. npm, pnpm and Yarn 1 read them as npm_config_* variables, Yarn 2 and later as YARN_* ones, Corepack as COREPACK_NPM_REGISTRY and the proxy variables. The registry's own credentials are not supported: it must serve packages without a login. APPLY_VALIDATION_NETWORK=default is still needed for the containers to have a network at all.

The docker service pulls node:<major> through the same proxy: Compose gives it CONTAINER_HTTPS_PROXY and CONTAINER_NO_PROXY as HTTPS_PROXY and NO_PROXY. With deploy/compose.private-ca.yaml, it also trusts the authority for those pulls, beside the public ones (ADR-538). It still pulls from Docker Hub: a mirror of Docker Hub is not supported. Not run behind a real proxy or mirror; ADR-537's and ADR-538's tests run the mechanism. ADR-539's integration test runs npm install in a validation container against a registry served under a test authority: it fails with SELF_SIGNED_CERT_IN_CHAIN without the authority, installs with it, and installs through a proxy too. Give the registry by name: through a proxy, npm refused a registry addressed by IP address in that test.

Deploy builds the repository's own Dockerfile, which Azilos never edits (ADR-043), so the same settings are offered to that build rather than imposed on it (ADR-540): each is a BuildKit secret a RUN step may mount, azilos_extra_ca_certificates (the authority's file), azilos_https_proxy, azilos_no_proxy and azilos_npm_registry. A step that does not mount one never sees it, and no image layer or docker history records it. A Dockerfile whose install should work behind the network mounts them on that step:

RUN --mount=type=secret,id=azilos_extra_ca_certificates,target=/etc/azilos/ca.pem \
    --mount=type=secret,id=azilos_https_proxy,env=HTTPS_PROXY \
    --mount=type=secret,id=azilos_no_proxy,env=NO_PROXY \
    --mount=type=secret,id=azilos_npm_registry,env=npm_config_registry \
    NODE_EXTRA_CA_CERTS=/etc/azilos/ca.pem npm ci

Without the settings, the same step still builds: an absent secret leaves its variable unset and its file missing, and Node starts without the extra authority. The build still has no network unless DEPLOYMENT_BUILD_NETWORK=default (ADR-090); a deployed container never has one. BuildKit's cache does not take the secrets into account, so a step built before a setting changed is reused as it was. ADR-540's test builds such a Dockerfile on a real daemon; not run behind a real proxy.

From the dashboard

  1. Consent. As an admin, open AI Usage & Consent and grant what the tenant agrees to: AI code fixes, and, if you did not list the tenant above, import verification and the vulnerability scan.
  2. Add the project. In Projects, enter the remote's name (billing) as the source location and choose Add project. Azilos clones it and lists the project. A name that is not in GIT_SOURCE_PROVIDER_REMOTES is refused.
  3. Turn on what this project may do. As an admin, open the project.
    • AI code fixes for this project (ADR-738): choose Turn on. The tenant's consent in step 1 says the team accepts AI code fixes at all; this says this repository's failing files and build output may be sent. Without it, a run that meets a regression skips the fix (ai project opt-in required), names this setting, and ends with the regression still there. The same decision by API is PUT /projects/<project-id>/ai-code-fix/opt-in with { "enabled": true }. Not run in a browser: the section and the route are covered by their tests (ADR-738, Verification).
    • Pull request (optional): choose Turn on in its section. Without it, the run ends after Document and opens nothing.
  4. Run. As an operator or admin, choose Run at the top of the project. The section shows Running: <phase> while it works: Understand, Model, Assessment, the vulnerability scan, Plan, Transform, validation, import verification, Apply, then Apply validation, which installs, builds and tests the original commit and the new branch in containers on the stack's daemon. The first validation pulls node:<major> into the daemon; expect minutes, not seconds.
  5. Decide the fix. When the branch fails what the original passed, the run asks the AI for a fix and stops at the approval gate, showing the proposal's explanation and the files it edits. An admin chooses Approve and continue: the edits are committed to the branch, validated again, and the run goes on. Reject records the rejection, commits nothing, and ends the run.
  6. The end. With a green validation, the run deploys the branch on the daemon, writes the modernization report and, with pull requests on, opens one from the Apply branch into the branch the run started from, with the report as its description. The section says finished, and the pull request section links it.

When a run stops instead, the section says at which phase and why, with Resume from <phase> once the cause is fixed, and Run again to start over. An inconclusive validation (nothing passed on the original commit) ends the run without deploying: Azilos never deploys a branch it could not check. docs/operations/mvp-runbook.md §9 lists each stop and what resumes it.

The project's Run history, below the run section, lists its runs 20 at a time, newest first, across restarts (ADR-546): when each started, who started it and how it ended. Older runs appends the next 20 while older ones remain (ADR-577); GET /projects/<project-id>/modernization-runs answers the same pages, with nextBefore to pass back as ?before= (ADR-576). A run that wrote a report links to the report section, which holds the project's latest report only; once a later run applies a new branch, stores a failed Apply attempt or writes its own report, the earlier run says report superseded by a later run instead (ADR-571). While a later run is in Apply, or was interrupted or failed during it, the earlier run keeps its link with the note (a later run's unfinished Apply may have replaced it) (ADR-572). The note goes once that run has stopped and the latest Apply attempt started before it did, so it stored none (ADR-580). When the project's latest Apply attempt started after a run ended, whether a later run or a direct POST /projects/<project-id>/apply made it, that run says report superseded by a later Apply (ADR-573). When an AI fix on that attempt was approved after a run ended, as the AI code fix section's Approve does, it says report superseded by a later AI fix approval (ADR-574); a decision there reloads the history, which starts again from its first page (ADR-579). A run that opened a pull request links it; the latest one recorded is also marked open, closed or merged (or unknown, when GitHub does not answer), read when the page loads (ADR-549).

GET /projects/<project-id>/modernization-run also answers why, as stopReason (ADR-541): a stable code, a one-line explanation, the fix (the setting to change or the command to run) and the guide section that describes it, for a stop and for a validation that proved nothing on the original commit. The codes are not_configured, consent_missing, ai_project_opt_in_missing (ADR-738), token_budget_spent, source_unreachable, source_timeout, docker_daemon_unreachable (ADR-543, ADR-556), registry_certificate_untrusted, registry_unreachable, validation_timed_out, install_failed, validation_inconclusive, and plan_changed, apply_branch_moved and apply_branch_missing (ADR-557), apply_drift, apply_no_branch and permission_denied (ADR-558), credential_rejected (ADR-570, and for the Azilos API key ADR-594), ai_credits_exhausted (ADR-594), ai_key_daily_limit_reached (ADR-628), ai_service_failed (ADR-637), Apply's import_verification_warnings, nothing_to_apply and apply_failed (ADR-567), the source's and the repository's source_location_not_permitted, source_location_not_found, source_too_large, source_file_not_utf8, manifest_unparseable, package_json_missing and runtime_target_missing (ADR-568), the limits' validation_limit_reached, deployment_limit_reached, deployment_active and rate_limited (ADR-569), and the AI fix proposal's ai_fix_pending, ai_fix_decided, ai_fix_unapprovable, ai_fix_attempt_replaced, ai_fix_validation_replaced, ai_fix_not_revalidated, ai_fix_not_approved and ai_fix_attempt_limit (ADR-559), ai_fix_unusable and ai_fix_push_refused (ADR-569), and Deploy's deploy_timed_out, deploy_build_failed and deploy_start_failed (ADR-564) and deploy_not_ready (ADR-565), whose explanation names each readiness blocker, and the pull request's pull_request_base_unknown, pull_request_not_validated, pull_request_validation_stale, pull_request_regressed, pull_request_host_unreachable, pull_request_host_rejected, pull_request_remote_not_allowed, pull_request_remote_unsupported and pull_request_host_failed (ADR-566); a stop Azilos does not recognize is unclassified, with classified: false and the phase's own message. The command-line runner prints the same reason as Reason: and Fix: lines when it is classified. The dashboard's run section shows it below the run's status (ADR-542): Why: with the code, Fix:, and the guide section's path; an unrecognized stop as Not classified: with its raw message.

What this path does not do yet

B-029's rehearsal (ADR-587) ran §7 on this stack from Run to the pull request, on a clean host with a test GitHub repository; How this was checked records it. A run that Rejects the fix, and a stop resumed with Resume from, were not run there.

How this was checked

ADR-485's session ran §1 to §3's commands as written, from the repository root, against deploy/compose.yaml unchanged: /etc/azilos/azilos.env created with install, holding single-quoted passwords with a space, a " and a % in them, and backups in /var/backups/azilos. It also ran §3's restore a second time onto the restored database, which stopped as §3 says (relation "ai_code_fix_consents" already exists). Its sandbox could not build the image: its network proxy is not trusted inside image builds (as for ADR-475 and ADR-476), and building through it was not allowed. So every step that needs only Postgres (up --detach app-role, pg_dump, pg_restore --list, down --volumes, pg_restore) ran as written, and setup was run as the same first-time-setup.js, built on the host, in a node:22-bookworm-slim container on the stack's network. The figures in §2 and §3 are from that run. The image rebuilds in §3 step 4 and §4 were not run there.

CI's integration job runs tests/integration/install-compose.integration.test.ts, which builds the real image and runs the backup and the restore of §2 and §3 as written (without the settings file, which only feeds Compose the same variables): it takes a backup, deletes the volume, restores into the new one, brings the stack up again, and checks the tenant, its admin, its audit rows, row-level security on every table and /readiness.

ADR-575's session, on a host whose way out to the npm registry intercepts TLS, ran §1's build with and without deploy/compose.private-ca-build.yaml, with --no-cache. Without it, npm install --global pnpm stopped with SELF_SIGNED_CERT_IN_CHAIN. With it, that step passed. The build stage's pnpm install --frozen-lockfile passed too, in a docker build given the same secret, and docker history showed no certificate. The runtime stage's apt-get could not reach the Debian mirror there: that host's proxy accepts HTTPS only, and its build containers cannot resolve deb.debian.org. So the image was still not built there, and the runtime stage's pnpm install --prod was not run with the secret.

Before B-029, §6 and §7 were read from the code: ADR-487's and ADR-536's sessions could not build the image or reach a provider from their sandboxes. CI's integration job also checks that docker compose config passes §7's settings on to api and to no other service, and leaves each out when unset (tests/integration/install-compose.integration.test.ts, the install compose file's run settings), and that the API's user owns the Git cache's root in the workspace volume.

B-029's rehearsal (ADR-587), 2026-10-22, Azilos b6df199. It ran §1, §6 and §7 as written on a clean host: Ubuntu 26.04 under WSL 2 on the owner's Windows machine (4.9 GB of memory), with Ubuntu's Docker Engine 29.1.3 and Compose 2.40.3 installed for the rehearsal, no image cached, and a checkout of master. The browser was Chrome on the same machine.