Azilos

Install Azilos on One Host With Docker Compose

Audience: an operator who wants Azilos running on one machine, from a release (or a checkout of this repository), without the runbook's manual setup (ADR-475, B-019). It brings up Postgres, platform-api and the dashboard, and does the first-time setup (docs/operations/mvp-runbook.md §2–§3) on the way up. For a trial, it can also run the runbook's local identity provider (ADR-476). A team connecting its own identity provider follows docs/operations/installation-guide.md §6. Apply validation and Deploy run their containers on a Docker daemon of the stack's own (ADR-535), never the host's.

Prerequisites

The command

From the directory that holds deploy/ (the unpacked release, or the repository root), with AZILOS_VERSION set to the release you unpacked:

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=alice \
AI_GATEWAY_KIND=azilos AZILOS_CLOUD_API_KEY='<your-azilos-api-key>' \
AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED=true \
  docker compose --file deploy/compose.yaml up --detach

The image runs with NODE_ENV=production, and there api exits at start-up unless all three of AI_GATEWAY_KIND=azilos, AZILOS_CLOUD_API_KEY (or AZILOS_CLOUD_API_KEY_FILE) and AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED=true are set (ADR-684). Its log names every one that is missing. Start-up does not call Azilos Cloud, so a key Azilos Cloud would reject is found by the first AI request, not here.

Compose pulls ghcr.io/valintes/azilos:<AZILOS_VERSION> the first time, then, in order:

  1. starts postgres (Postgres 16, data in the azilos_postgres-data volume) and docker (the daemon Apply validation and Deploy use) and waits until each is ready. Neither publishes a port on the host;
  2. runs app-role: scripts/provision-app-role.sql with psql, which creates the least-privilege azilos_app role once (ADR-001) and does nothing on later runs;
  3. runs setup: first-time-setup.js provisions the schema (ADR-088), then the tenant AZILOS_TENANT_ID with AZILOS_ADMIN_PRINCIPAL_ID as its admin, both audited under AZILOS_OPERATOR_ID (ADR-079);
  4. starts api (platform-api, connected as azilos_app) on http://127.0.0.1:3000 and dashboard on http://127.0.0.1:8080.

If step 2 or 3 fails, the API and the dashboard are not started. Read why with docker compose --file deploy/compose.yaml logs app-role setup.

Observed, with docker compose --file deploy/compose.yaml logs app-role setup (ADR-475's rehearsal; see How this was checked):

app-role-1  | CREATE ROLE
setup-1     | Schema provisioned: 29 table(s) created or already present, and every azilos_app grant applied.
setup-1     | Tenant "acme" provisioned.
setup-1     | Granted the admin role over "acme" to "alice".
setup-1     | First-time setup is complete.

GET http://127.0.0.1:3000/readiness then answered 200 with "status":"ready".

Running the same command again is safe. The role and the schema are left as they are, and so is the tenant: setup prints Tenant "acme" already exists; left as it is. First-time setup is complete. It never grants or audits anything again. To add people or roles later, run grant-tenant-role (runbook §3) in the api container, which already has DATABASE_URL and AZILOS_APP_DB_PASSWORD:

docker compose --file deploy/compose.yaml exec --env AZILOS_OPERATOR_ID='<your-operator-id>' \
  api node apps/platform-api/dist/grant-tenant-role.js <tenant> <principal> <role>

Not run in ADR-475's rehearsal.

Settings

Secrets come only from the environment of whoever runs the command; none is written in deploy/compose.yaml or in the image. Keep them out of the repository.

VariableRequiredUsed for
AZILOS_POSTGRES_PASSWORDyes¹Postgres's own azilos superuser. Only app-role and setup use it.
AZILOS_APP_DB_PASSWORDyes¹The azilos_app role, which platform-api connects as.
AZILOS_OPERATOR_IDyesNames you in the tenant's audit log for the setup.
AZILOS_TENANT_IDyesThe first tenant.
AZILOS_ADMIN_PRINCIPAL_IDyesIts first admin: the sub your identity provider gives that person.
AZILOS_VERSIONyesThe release to run, for example v0.1.0: the image ghcr.io/valintes/azilos:<it>.
AI_GATEWAY_KINDyesazilos: AI runs through Azilos Cloud (ADR-684).
AZILOS_CLOUD_API_KEYyes²Your Azilos API key, from https://api.azilos.com/account.
AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGEDyestrue: see ai-vendor-data-processing.md.
AZILOS_API_PORTnoHost port for the API. Default 3000.
AZILOS_DASHBOARD_PORTnoHost port for the dashboard. Default 8080.
AZILOS_BIND_ADDRESSnoHost address both ports are published on. Default 127.0.0.1, this machine only.
AZILOS_PUBLIC_API_URLnoThe API's URL as a browser reaches it. Default http://localhost:<api port>.
AZILOS_PUBLIC_DASHBOARD_ORIGINnoThe dashboard's origin, allowed by the API's CORS. Default http://localhost:<port>.
AZILOS_DASHBOARD_OIDC_ISSUERnoThe dashboard's identity provider (ADR-037). Read when the dashboard starts.
AZILOS_DASHBOARD_OIDC_CLIENT_IDnoThe dashboard's client id at that provider. Read when the dashboard starts.
AZILOS_DASHBOARD_CONNECT_ORIGINSnoThe only origins the dashboard may call, comma-separated. Default: any (see below).
OIDC_ISSUER, OIDC_AUDIENCEnoPassed on to platform-api when set. Without them it serves no tenant route.
a run's settingsnoPassed on to platform-api when set: installation-guide.md §7 lists them (ADR-536).
AZILOS_IMAGEnoAnother image to run instead, for example a mirror's copy of the release.

¹ Or both as files in AZILOS_DB_PASSWORDS_DIR, with deploy/compose.db-password-files.yaml (ADR-626): installation-guide.md §1 describes it.

² Or as a file, AZILOS_CLOUD_API_KEY_FILE, with deploy/compose.secret-files.yaml (ADR-625).

Without AZILOS_VERSION, every command fails with required variable AZILOS_VERSION is missing a value.

Building the image from a checkout

To run a commit that is not a release, build the image from a checkout instead of pulling it: add deploy/compose.build.yaml after deploy/compose.yaml, set AZILOS_VERSION=local, and add --build (ADR-678). The image is then ghcr.io/valintes/azilos:local, on this host only: never pushed, never pulled. The build needs network access to registry.npmjs.org and the Debian package mirrors.

AZILOS_VERSION=local ... \
  docker compose --file deploy/compose.yaml --file deploy/compose.build.yaml up --detach --build

Use both files in every later command for that stack. Only setup builds the image; api and dashboard run it and never pull it (ADR-587).

The dashboard reads its settings when it starts (ADR-675), and the browser gets them from the dashboard's /config.json, so after changing AZILOS_PUBLIC_API_URL, AZILOS_API_PORT or the two dashboard OIDC settings, run the command again: up without --build restarts the dashboard with them, and the next page load uses them.

AZILOS_DASHBOARD_CONNECT_ORIGINS narrows the dashboard's Content-Security-Policy (ADR-530). Unset, the browser lets the dashboard's scripts call any http: or https: origin. Set, they may call only the dashboard's own origin and the ones listed, so an injected script cannot fetch its way to anywhere else. The dashboard calls three URLs: the API (AZILOS_PUBLIC_API_URL), your provider's discovery document under AZILOS_DASHBOARD_OIDC_ISSUER, and the token_endpoint that document names, which some providers serve from another origin. List the origin of each, for example https://azilos-api.example.com,https://id.example.com. A URL with a path is reduced to its origin. When one is missing, sign-in or the dashboard's first request fails with a connect-src error in the browser console. It is read when the dashboard starts, so up without --build applies it.

The first-time setup reads AZILOS_TENANT_ID and AZILOS_ADMIN_PRINCIPAL_ID only while the tenant does not exist. Changing them later creates another tenant on the next up.

The Docker daemon

Apply validation (ADR-191), lockfile regeneration (ADR-248) and Deploy (ADR-043) start Docker containers. In this stack they start them on docker, a Docker-in-Docker daemon (docker:28.5.1-dind) that runs as a service of its own, as the owner decided on 2026-10-18 (B-023). The host's Docker socket is never mounted into any container. A repository's install, build and test scripts therefore run on that daemon, not on the host's.

A rootless daemon

deploy/compose.rootless-daemon.yaml (ADR-775) runs the daemon without privileged, as docker:28.5.1-dind-rootless. The daemon and every container it starts then run as an unprivileged user of the host (uid 1000). A script that escapes its container, or anyone who reaches the daemon, is that user on the host, not root.

AZILOS_DAEMON_MEMORY_LIMIT=6g   docker compose --file deploy/compose.yaml --file deploy/compose.rootless-daemon.yaml   up --detach
VariableRequiredWhat it is
AZILOS_DAEMON_MEMORY_LIMITyesThe memory all validation and Deploy containers share, for example 6g. It has no default.
AZILOS_DAEMON_PIDS_LIMITnoThe processes they share. Default 8192.

It is a choice, not the default, because it costs something:

Pass both --file options to every later docker compose command, as with the other files in deploy/.

Ports. Deploy starts a project's container with no network (ADR-043), so it publishes no port, on the daemon or anywhere else. To see what the daemon runs:

docker compose --file deploy/compose.yaml exec docker docker ps --all

When it does not answer. If api cannot reach the daemon (the docker service stopped, or DOCKER_HOST pointing elsewhere), Apply validation and AI fix re-validation answer 503 with the docker daemon could not be reached, and a run stops there with the stop code docker_daemon_unreachable (ADR-543). Deploy records a failed attempt with the message Docker daemon is not reachable., and a run that stops there has the same code (ADR-556). Stop, and Deploy when it first closes an attempt left building past its deadline, answer the same 503 without deploying (ADR-808, ADR-809); a run stopped at Deploy that way has the same code (ADR-810). Start the service again with docker compose --file deploy/compose.yaml up --detach docker, then resume the run from the phase that stopped.

Docker version. The API's image takes its docker command from Debian bookworm's docker.io package (Docker 20.10, API version 1.41). Docker 29 refuses API versions below 1.44, so the daemon stays on Docker 28 until the image's CLI moves.

For a trial: the local identity provider

Not for real use. The local identity provider (ADR-208) mints a token for the first admin without any login, and anyone who can read the stack's logs can read that token. Use it only to try Azilos on your own machine. A team connects its own provider with OIDC_ISSUER and OIDC_AUDIENCE instead (installation-guide.md §6).

Add deploy/compose.trial-identity.yaml to the command, with the same variables as above (and deploy/compose.build.yaml before it when you build the image):

docker compose --file deploy/compose.yaml --file deploy/compose.trial-identity.yaml \
  up --detach

It sets platform-api's OIDC_ISSUER to http://127.0.0.1:3100 and OIDC_AUDIENCE to azilos-platform-api, and starts an identity service. That service shares the api container's network, so the issuer is a loopback address inside it, the only kind of http issuer platform-api accepts (ADR-084). The provider is not published on the host.

Read the token for AZILOS_ADMIN_PRINCIPAL_ID in AZILOS_TENANT_ID. It is the last line of:

docker compose --file deploy/compose.yaml --file deploy/compose.trial-identity.yaml \
  logs --no-log-prefix identity

Observed (ADR-476's rehearsal; see How this was checked):

Local identity provider listening at http://127.0.0.1:3100 (local use only; stop with Ctrl-C).
Set these on platform-api and on the modernization runner:
  OIDC_ISSUER=http://127.0.0.1:3100
  OIDC_AUDIENCE=azilos-platform-api
Bearer token for "alice" in tenant "acme" (valid for 8h, and only while this process runs):
<token>

In the rehearsal, with that token as Authorization: Bearer <token>, GET /memberships on the API answered 200 and listed the admin principal as admin of the tenant. Without a token, or with a forged signature, it answered 401. The dashboard cannot log in with this provider yet (ADR-474).

The token is valid for 8 hours, and only while identity runs. Every start prints a new one. restart api also restarts identity. If the api container restarts outside Compose, for example after a crash, identity can no longer be reached, and running up again does not fix that. Run docker compose --file deploy/compose.yaml --file deploy/compose.trial-identity.yaml restart identity, then read the new token.

Stop this stack with the same two --file options and down.

Stopping and starting over

docker compose --file deploy/compose.yaml down

stops everything and keeps the database volume. down --volumes also deletes the database, and every tenant in it, for good, as well as the daemon's images, its certificates and the shared workspace. Both commands need the required variables set, as above.

Not included yet

How this was checked

ADR-535's session could not build the image as written either: its network blocks the Debian package mirrors, so apt-get install fails in the build. It built a scratch copy of apps/platform-api/Dockerfile that trusted the session's proxy certificate and took the docker command from the docker:20.10.24-cli image instead of apt-get (the same Docker 20.10 CLI as bookworm's package), and brought the stack up from deploy/compose.yaml unchanged with up --detach --no-build. The docker service's daemon could not pull through that proxy, so node:22-alpine was loaded into it with docker save | docker compose exec -T docker docker load. With that, ADR-535's three tests in tests/integration/install-compose.integration.test.ts passed: the API reached Docker 28.5.1 with its client certificate and was refused without it, and Apply validation's checks ran on the daemon, a passing and a failing test script each reported as such. CI's integration job builds the real image and runs the test as written, pulls included.

ADR-475's session ran docker compose --file deploy/compose.yaml up --detach --build as written. The image build failed there, inside the build, because that session's network proxy uses a certificate the build's containers do not trust. So the image was built from a copy of apps/platform-api/Dockerfile that only trusted that certificate and left out the git package, which no step here uses, and the stack was brought up from deploy/compose.yaml unchanged with up --detach --no-build. The output above is from that run. CI's integration job runs tests/integration/install-compose.integration.test.ts, which builds the real image and runs the command as written.

ADR-476's session hit the same build failure with --build, so it rehearsed the trial identity provider the same way: a scratch copy of the Dockerfile that only trusted the proxy's certificate and left out the apt-get step, and both compose files unchanged with up --detach --no-build. The provider's output and the /memberships answers above are from that run, through a temporary copy of tests/integration/install-trial-identity.integration.test.ts changed only to use that image without --build. CI's integration job runs the test itself, which builds the real image.