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
- Docker with Compose v2, and network access to
ghcr.ioto pull Azilos's image (ADR-678). - A Docker that may run a privileged container. The stack's own daemon,
docker, is one (see The Docker daemon). A team that does not allow one runs a rootless daemon instead (see A rootless daemon). - The files a release ships beside its image:
azilos-deploy-<version>.tar.gzfrom https://github.com/Valintes/azilos-releases/releases (ADR-683), unpacked, which holdsdeploy/andscripts/provision-app-role.sql. A checkout of the source repository holds the same files. - Ports
3000and8080free on the host's loopback, or other ports chosen below. - An Azilos API key, created at https://api.azilos.com/account before you install. The API does not start without one (ADR-684, B-046).
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:
- starts
postgres(Postgres 16, data in theazilos_postgres-datavolume) anddocker(the daemon Apply validation and Deploy use) and waits until each is ready. Neither publishes a port on the host; - runs
app-role:scripts/provision-app-role.sqlwithpsql, which creates the least-privilegeazilos_approle once (ADR-001) and does nothing on later runs; - runs
setup:first-time-setup.jsprovisions the schema (ADR-088), then the tenantAZILOS_TENANT_IDwithAZILOS_ADMIN_PRINCIPAL_IDas itsadmin, both audited underAZILOS_OPERATOR_ID(ADR-079); - starts
api(platform-api, connected asazilos_app) onhttp://127.0.0.1:3000anddashboardonhttp://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.
| Variable | Required | Used for |
|---|---|---|
AZILOS_POSTGRES_PASSWORD | yes¹ | Postgres's own azilos superuser. Only app-role and setup use it. |
AZILOS_APP_DB_PASSWORD | yes¹ | The azilos_app role, which platform-api connects as. |
AZILOS_OPERATOR_ID | yes | Names you in the tenant's audit log for the setup. |
AZILOS_TENANT_ID | yes | The first tenant. |
AZILOS_ADMIN_PRINCIPAL_ID | yes | Its first admin: the sub your identity provider gives that person. |
AZILOS_VERSION | yes | The release to run, for example v0.1.0: the image ghcr.io/valintes/azilos:<it>. |
AI_GATEWAY_KIND | yes | azilos: AI runs through Azilos Cloud (ADR-684). |
AZILOS_CLOUD_API_KEY | yes² | Your Azilos API key, from https://api.azilos.com/account. |
AI_GATEWAY_AZILOS_DATA_PROCESSING_ACKNOWLEDGED | yes | true: see ai-vendor-data-processing.md. |
AZILOS_API_PORT | no | Host port for the API. Default 3000. |
AZILOS_DASHBOARD_PORT | no | Host port for the dashboard. Default 8080. |
AZILOS_BIND_ADDRESS | no | Host address both ports are published on. Default 127.0.0.1, this machine only. |
AZILOS_PUBLIC_API_URL | no | The API's URL as a browser reaches it. Default http://localhost:<api port>. |
AZILOS_PUBLIC_DASHBOARD_ORIGIN | no | The dashboard's origin, allowed by the API's CORS. Default http://localhost:<port>. |
AZILOS_DASHBOARD_OIDC_ISSUER | no | The dashboard's identity provider (ADR-037). Read when the dashboard starts. |
AZILOS_DASHBOARD_OIDC_CLIENT_ID | no | The dashboard's client id at that provider. Read when the dashboard starts. |
AZILOS_DASHBOARD_CONNECT_ORIGINS | no | The only origins the dashboard may call, comma-separated. Default: any (see below). |
OIDC_ISSUER, OIDC_AUDIENCE | no | Passed on to platform-api when set. Without them it serves no tenant route. |
| a run's settings | no | Passed on to platform-api when set: installation-guide.md §7 lists them (ADR-536). |
AZILOS_IMAGE | no | Another 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.
- Only the API reaches it.
dockeris on a network of its own,daemon, which onlyapishares. It is not on the network Postgres and the dashboard use, and it publishes no port on the host. - Over TLS, with a client certificate. The daemon listens on port 2376 and generates its own
certificate authority, server and client certificates on first start. The client certificate is
in the
azilos_docker-certs-clientvolume, mounted read-only intoapiat/certs/client.apihasDOCKER_HOST=tcp://docker:2376,DOCKER_TLS_VERIFY=1andDOCKER_CERT_PATH=/certs/client. Without the certificate the daemon refuses the connection. - Privileged. Docker-in-Docker needs a privileged container: it can do on the host what root can. That is the price of keeping repository scripts off the host's own daemon. A rootless daemon, below, is the other choice.
- One shared directory. Validation bind-mounts a checkout into its containers by path, and the
daemon resolves that path in its own filesystem. So Apply clones into the
azilos_workspacevolume, mounted at/var/lib/azilos/workspacein bothapianddocker(GIT_SOURCE_MUTATION_CLONE_DIR).dockermakes it owned by uid1000, the API's user, each time it starts. - Its own images. What the daemon pulls and builds stays in the
azilos_docker-datavolume. The first validation of a project pulls thenode:<major>image it needs from Docker Hub. - Turned on. The stack sets
DEPLOYMENT_TARGET_KIND=dockerforapi. Apply validation and Deploy still need their other settings (an identity provider, Apply's Git settings), as the runbook describes.
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
| Variable | Required | What it is |
|---|---|---|
AZILOS_DAEMON_MEMORY_LIMIT | yes | The memory all validation and Deploy containers share, for example 6g. It has no default. |
AZILOS_DAEMON_PIDS_LIMIT | no | The processes they share. Default 8192. |
It is a choice, not the default, because it costs something:
- Limits are shared, not per container. A rootless daemon without systemd applies no
--memory,--cpusor--pids-limitto a container: Docker accepts them and ignores them. The two limits above are on the daemon container, so they bound all runs together.- A script that takes all the memory is killed, and its validation fails. The daemon keeps running.
- A script that starts processes without end makes other validations and deployments fail to
start until its own validation reaches
APPLY_VALIDATION_TIMEOUT_MSand is removed. After that the daemon works again without a restart. - The host, Postgres and the API are outside those limits and are not affected.
- The host must allow it.
- Unprivileged user namespaces, the
/dev/net/tundevice, and a kernel of 5.11 or later. - Ubuntu 24.04 and later restrict user namespaces through AppArmor by default. There the daemon
does not start until the host allows them
(
sysctl kernel.apparmor_restrict_unprivileged_userns=0, or an AppArmor profile). This was not tried on such a host (ADR-775).
- Unprivileged user namespaces, the
- It is still not a default container. It runs with no seccomp filter and no masked system
paths, and has
/dev/net/tun: a nested container runtime needs them. It starts as root only to give the workspace and the certificate directory to uid1000, then drops to that user. - The API is told. The file sets
CONTAINER_DAEMON_ROOTLESS=trueforapi, and validation containers then run as uid0. On a rootless daemon that is uid1000on the host, which owns the checkout. Without the setting every validation fails at its first write. - Images are pulled again. The rootless daemon keeps its images in a new volume,
azilos_docker-data-rootless. The oldazilos_docker-datavolume is left as it is; remove it once the change is settled.
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
- The trial provider and the dashboard. The trial provider above cannot sign anyone in to the
dashboard (ADR-474). A team's own provider can:
installation-guide.md§6 (ADR-487). - A mirror for the daemon. The daemon pulls from Docker Hub, through a team's proxy and
trusting its private authority when they are set (ADR-538), as the containers validation runs on
it do (ADR-537):
installation-guide.md§7, Behind a proxy, a private authority or your own registry. A mirror of Docker Hub is not supported. - TLS (B-023). Settings in a file, backups, restores and upgrades are in
docs/operations/installation-guide.md(ADR-485).
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.