> For the complete documentation index, see [llms.txt](https://docs.sonarsource.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.sonarsource.com/sonarqube-server/server-installation/ai-agents/deploying-with-docker-compose.md).

# Deploying with Docker Compose

Deploy the Agent Orchestrator, the agent runtimes, and Vortex analysis with Docker Compose, for environments that do not run Kubernetes.

Docker Compose is the supported way to deploy the AI agents outside Kubernetes. You run the components on a container host alongside your existing SonarQube Server deployment, and point each side at the other.

The Agent Orchestrator, each agent runtime, and Vortex analysis run as long-lived services. Each runtime service handles one job at a time, which fixes your job capacity. Size the host for the number of concurrent jobs you want, and add runtime services to raise that number.

{% hint style="info" %}
Before you begin, read the [Before you start](/sonarqube-server/server-installation/ai-agents/before-you-start.md) page and complete the setup steps laid out on the [Setting up the sandbox runtime](/sonarqube-server/server-installation/ai-agents/sandbox-runtime.md) and [Setting up shared storage](/sonarqube-server/server-installation/ai-agents/shared-storage.md) pages. The sandbox runtime must be registered on a Linux container host before any job can start.
{% endhint %}

The Docker Compose deployment outlined on this page covers only the agent components. Your SonarQube Server instance, its database, and your storage backend are expected to already exist. Point the components at them rather than running them as part of this deployment.

## Services to define <a href="#services" id="services"></a>

| Service                   | Purpose                                                                                |
| ------------------------- | -------------------------------------------------------------------------------------- |
| Agent Orchestrator        | Coordinates jobs. Connects to the SonarQube Server database and to storage.            |
| Hunter Agent runtime      | Runs Hunter Agent jobs. Only if you use the Hunter Agent.                              |
| Remediation Agent runtime | Runs Remediation Agent jobs. Only if you use the Remediation Agent.                    |
| Vortex analysis           | Verifies proposed fixes. Required by the Remediation Agent.                            |
| Egress proxy              | Restricts what the runtime containers can reach.                                       |
| Signing key derivation    | A one-shot service that derives the per-component request-signing keys on every start. |

The key derivation service does work that the Helm chart does for you on Kubernetes, which is why you define it yourself here. Have each component depend on it with `service_completed_successfully`: a failed derivation then stops the start, rather than leaving you with components that cannot authenticate to each other.

## Planning the deployment <a href="#planning" id="planning"></a>

Settle these before you write the Compose file, because two of them determine how the services are addressed.

### Where SonarQube Server runs <a href="#planning-server-location" id="planning-server-location"></a>

The components and SonarQube Server call each other in both directions, and each needs an address for the other that resolves from where it runs. The [#example](#example "mention") runs SonarQube Server on the container host: the containers reach it at its host name, and SonarQube Server reaches the Orchestrator's and Vortex analysis's published ports at `localhost`. Only the Orchestrator and Vortex analysis are ever published. The runtimes are not. If SonarQube Server runs on another host, see [#separate-host](#separate-host "mention").

| Direction                                                                   | Why                                                                                     |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| SonarQube Server to the Orchestrator                                        | Triggers jobs, and polls component health for the **System** page.                      |
| SonarQube Server to Vortex analysis                                         | Polls its health, and requests analyses.                                                |
| The Orchestrator to SonarQube Server                                        | Reads project data, requests short-lived repository credentials, and publishes results. |
| The Orchestrator to the SonarQube Server database                           | Tracks job state.                                                                       |
| Vortex analysis to SonarQube Server                                         | Reads what it needs to analyze.                                                         |
| The Remediation Agent runtime to SonarQube Server, through the egress proxy | Fetches rule descriptions, and verifies fixes.                                          |

### Storage backend <a href="#planning-storage" id="planning-storage"></a>

Object storage is the default and the simpler of the two here, because the components exchange artifacts through short-lived presigned URLs rather than a shared mount. The [#example](#example "mention") below uses it. For a shared filesystem instead, see [#shared-filesystem](#shared-filesystem "mention").

### Private certificate authority <a href="#planning-ca" id="planning-ca"></a>

If SonarQube Server serves a certificate from your own authority, or outbound traffic passes through a TLS-inspecting proxy, every component needs to trust that authority. See [#custom-ca](#custom-ca "mention").

## How the components authenticate <a href="#authentication" id="authentication"></a>

The components do not authenticate to each other with tokens. You supply one shared secret, and each component signs and verifies requests with a key derived from it. A component never holds the keys for a hop it does not take part in.

Generate the secret once, and keep it as securely as any other credential:

```bash
openssl rand -hex 32
```

{% hint style="danger" %}
The secret must be at least 32 characters. SonarQube Server disables signature verification on a shorter one without reporting an error, which leaves the agentic endpoints unauthenticated.
{% endhint %}

### Deriving the keys <a href="#deriving-keys" id="deriving-keys"></a>

The Agent Orchestrator image ships the derivation script at `/derive-keys.sh`. Use it rather than deriving the keys another way. Kubernetes deployments derive with the same script, which means one secret produces the same keys on both, and moving between them needs no re-keying.

Call it once per start, writing each component's keys into a volume of its own:

```bash
/derive-keys.sh --secret-file <path to the secret> --label <name>=<output path> ...
```

Each component then mounts only its own volume, read-only. The one-shot service in the [#example](#example "mention") below shows the full set of labels.

| Component                 | Keys it holds                                                                                                                                                     | What it uses them for                                                                                                                                                                 |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SonarQube Server          | The secret itself, and `agentic-shared`                                                                                                                           | Verifies inbound calls from the Orchestrator, Vortex analysis, and the Remediation Agent runtime. Signs its own calls to the Orchestrator.                                            |
| Agent Orchestrator        | `agentic-shared`, `orchestrator-to-hunter`, `orchestrator-to-remediation`, `hunter-to-orchestrator`, `remediation-to-orchestrator`, `orchestrator-job-capability` | Verifies inbound calls from SonarQube Server and from both runtimes. Signs its own calls to SonarQube Server and its job pushes to each runtime. Issues each job's upload capability. |
| Hunter Agent runtime      | `orchestrator-to-hunter`, `hunter-to-orchestrator`                                                                                                                | Verifies job pushes from the Orchestrator, and signs its own requests back to it.                                                                                                     |
| Remediation Agent runtime | `orchestrator-to-remediation`, `remediation-to-orchestrator`, `remediation-to-sqs`                                                                                | Verifies job pushes from the Orchestrator, signs its own requests back to it, and signs its direct calls to SonarQube Server.                                                         |
| Vortex analysis           | `agentic-shared`                                                                                                                                                  | Signs its calls to SonarQube Server, and verifies SonarQube Server's calls to it.                                                                                                     |

{% hint style="warning" %}
Never mount the secret itself, or the `orchestrator-job-capability` key, into a runtime container. Runtimes run code influenced by a large language model, and either one would let a job mint credentials for hops it has no part in.
{% endhint %}

SonarQube Server needs two of these files. See [#key-handover](#key-handover "mention") for how to derive them, and [#server-properties](#server-properties "mention") for the properties that point at them.

### Rotating the secret <a href="#rotating" id="rotating"></a>

Recreate the containers after you change the secret:

```bash
docker compose up -d --force-recreate
```

Derivation runs again on `docker compose up`, but nothing else about the running containers changes, and Compose leaves them holding the keys derived from the old secret. They keep signing with a key no other component has, which surfaces as unexplained `401` responses rather than as a configuration error.

To rotate without downtime, put the previous secret in a file named after the secret file with a `.previous` suffix before you derive. The derivation writes a matching `.previous` key beside each key, and a verifier accepts both generations, which lets you restart components one at a time. Remove the previous generation once every container has been recreated.

SonarQube Server derives its own copy of the keys, so update it as well, see [#key-handover](#key-handover "mention").

## Pointing SonarQube Server at the components <a href="#server-properties" id="server-properties"></a>

{% hint style="danger" %}
An environment variable overrides a SonarQube Server property only when that property is a built-in system property, or is already present in `conf/sonar.properties`. None of the agentic properties are built-in system properties, which means setting only the environment variable has no effect: SonarQube Server starts as though the components were never deployed. Declare each property in `conf/sonar.properties` first.
{% endhint %}

Use the published addresses from [#planning](#planning "mention"), not container names. Container names resolve only on the container host's own Compose networks:

<details>

<summary>conf/sonar.properties</summary>

```properties
# Agent Orchestrator, one callback per agent
sonar.hunteragent.orchestrator.url=http://localhost:9091
sonar.remediationagent.orchestrator.url=http://localhost:9091

# Vortex analysis
sonar.vortex.enabled=true
sonar.vortex.analysis.url=http://localhost:9092

# Request signing. Point these at the secret file and the derived agentic-shared key.
sonar.agentic.signing.secretFile=
sonar.agentic.orchestrator.signingKeyPath=

# Vortex analysis context storage. Must be the same location Vortex analysis reads.
sonar.agentic.storage.type=S3
sonar.agentic.storage.bucket=
sonar.agentic.storage.region=
sonar.agentic.storage.endpoint=
sonar.agentic.storage.access-key=
sonar.agentic.storage.secret-key=
sonar.agentic.storage.path-style-access=true

# Agent job artifact storage, read by the agentic job log download
sonar.agentic.orchestrator.storage.type=S3
sonar.agentic.orchestrator.storage.bucket=
sonar.agentic.orchestrator.storage.region=

# The address a browser reaches SonarQube Server at
sonar.core.serverBaseURL=
```

</details>

These are the ports published by this bundle; use `ORCHESTRATOR_PUBLISH_PORT`/`VORTEX_PUBLISH_PORT` from `.env` instead if you set them.

On a container image, the configuration directory is read-only at container start. Mount a complete file over it instead. Declaring a property with an empty value is enough to make it overridable, which is what lets you keep addresses and credentials in your environment rather than in the mounted file. On a ZIP installation, edit `conf/sonar.properties` in place.

### Database <a href="#database" id="database"></a>

This bundle points the containers at the same external database you passed to `generate.py`. Point your SonarQube at it too, if it isn't already:

```properties
sonar.jdbc.url=jdbc:postgresql://db.example.com:5432/sonarqube?sslmode=verify-full&sslfactory=org.postgresql.ssl.DefaultJavaSSLFactory
sonar.jdbc.username=sonarqube
sonar.jdbc.password=<the --db-password you passed to generate.py>
```

The orchestrator connects with the same `sslmode=verify-full`: the database must serve TLS with a certificate valid for `db.example.com`. Your SonarQube validates it against its JVM truststore, so the CA behind it must be in the JDK's default `cacerts` or in the `-Djavax.net.ssl.trustStore` you set in `sonar.web.javaAdditionalOpts` and `sonar.ce.javaAdditionalOpts`.

### Network reachability <a href="#reachability" id="reachability"></a>

The containers expect to reach your SonarQube at `http://sonarqube.example.com:9000`. If that isn't where your install actually listens, regenerate this bundle with `--sonarqube-external-host`/`--sonarqube-external-port`/`--sonarqube-external-scheme` set correctly, or make sure SonarQube is bound and reachable there (a firewall or a bind to `127.0.0.1` only are the usual culprits).

### Storage <a href="#server-storage" id="server-storage"></a>

Set the same storage properties this bundle's containers use — `sonar.agentic.storage.*` and `sonar.agentic.orchestrator.storage.*` must point at the same backend and credentials/paths on both sides. Copy the values from this bundle's `docker-compose.yaml`/`.env` for the storage mode you generated with.

### TLS trust <a href="#tls-trust" id="tls-trust"></a>

TLS is disabled in this bundle; every hop is plain HTTP. See [#traffic](#traffic "mention").

For the full list of settings on both sides, see [Configuration reference](/sonarqube-server/server-installation/ai-agents/configuration-reference.md).

## Example Compose file <a href="#example" id="example"></a>

The example is a bundle produced by the Compose generator, which matches what the Helm chart ships for 2026.5.0. It was generated for the setup this page describes: an existing SonarQube Server (`--edition none`), its external PostgreSQL, S3 storage, and all three components, with TLS off; see [#traffic](#traffic "mention"). The command that produced it:

```bash
python3 generate.py --edition none \
  --db external --db-host db.example.com --db-password changeme \
  --storage s3 --s3-bucket agentic-bucket --s3-region eu-west-1 \
  --s3-access-key AKIAEXAMPLE --s3-secret-key changeme \
  --tls off --sonarqube-external-host sonarqube.example.com --sonarqube-external-port 9000 \
  --host-gateway off --out example
```

<details>

<summary>docker-compose.yaml</summary>

```yaml
# Generated by agentic-compose-generator/generate.py — do not hand-edit; regenerate instead.
name: agentic-none


x-context-storage-env: &context-storage-env
  SONAR_AGENTIC_STORAGE_TYPE: S3
  SONAR_AGENTIC_STORAGE_FILESYSTEM_BASE_DIR: ${AGENTIC_STORAGE_MOUNT:-/agentic-storage}
  SONAR_AGENTIC_STORAGE_BUCKET: ${AGENTIC_STORAGE_BUCKET:-}
  SONAR_AGENTIC_STORAGE_REGION: ${AGENTIC_STORAGE_REGION:-}
  SONAR_AGENTIC_STORAGE_ENDPOINT: ${AGENTIC_STORAGE_ENDPOINT:-}
  SONAR_AGENTIC_STORAGE_ACCESS_KEY: ${AGENTIC_STORAGE_ACCESS_KEY:-}
  SONAR_AGENTIC_STORAGE_SECRET_KEY: ${AGENTIC_STORAGE_SECRET_KEY:-}
  SONAR_AGENTIC_STORAGE_PATH_STYLE_ACCESS: ${AGENTIC_STORAGE_PATH_STYLE_ACCESS:-true}
  SONAR_AGENTIC_STORAGE_PRESIGN_TTL_SECONDS: ${AGENTIC_STORAGE_PRESIGN_TTL_SECONDS:-21600}

x-job-storage-env: &job-storage-env
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_TYPE: S3
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_FILESYSTEM_BASE_DIR: ${AGENTIC_STORAGE_MOUNT:-/agentic-storage}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_BUCKET: ${AGENTIC_STORAGE_BUCKET:-}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_REGION: ${AGENTIC_STORAGE_REGION:-}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_ENDPOINT: ${AGENTIC_STORAGE_ENDPOINT:-}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_ACCESS_KEY: ${AGENTIC_STORAGE_ACCESS_KEY:-}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_SECRET_KEY: ${AGENTIC_STORAGE_SECRET_KEY:-}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_PATH_STYLE_ACCESS: ${AGENTIC_STORAGE_PATH_STYLE_ACCESS:-true}
  SONAR_AGENTIC_ORCHESTRATOR_STORAGE_PRESIGN_TTL_SECONDS: ${AGENTIC_STORAGE_PRESIGN_TTL_SECONDS:-21600}


networks:
  # Routable: SonarQube, orchestrator, Vortex, and the orchestrator's outbound git/DevOps traffic.
  control:
  # The agent runtimes' only path to the LLM APIs and to SonarQube, via the egress proxy's allowlist.
  egress:
  # Database only, no route off-box.
  data:
    internal: true
  # One per runtime: the orchestrator's job dispatch to it, and its route to egress-proxy. Separate
  # so the two runtimes can't reach each other. SonarQube is on neither.
  hunter-jobnet:
    internal: true
  remediation-jobnet:
    internal: true
  # egress-proxy's route to SonarQube for the remediation runtime's rule-info/analysis calls.
  proxy-target:
    internal: true
  # tls-proxy's/agentic-proxy's route to the backing services and the host's published port.
  edge:


volumes:
  agentic_signing_orchestrator:
  agentic_signing_hunter:
  agentic_signing_remediation:
  agentic_signing_vortex:

services:
  signing-init:
    image: sonarsource/sonarqube-agent-orchestrator:2026.5.0
    user: "0:0"
    # Root only to own the key volumes it creates; it needs no capability on top of that.
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        set -eu
        [ $${#AGENTIC_SIGNING_SECRET} -ge 32 ] ||
          { echo "AGENTIC_SIGNING_SECRET is shorter than 32 characters — SonarQube requires at" \
            "least that many bytes and disables verification on a shorter one, silently. Use:" \
            "openssl rand -hex 32" >&2; exit 1; }
        printf '%s' "$$AGENTIC_SIGNING_SECRET" > /tmp/instance-secret
        true
        /derive-keys.sh --secret-file /tmp/instance-secret \
          --label agentic-shared=/run/agentic-signing/orchestrator/agentic-shared.key \
          --label orchestrator-job-capability=/run/agentic-signing/orchestrator/orchestrator-job-capability.key \
          --label hunter-to-orchestrator=/run/agentic-signing/orchestrator/hunter-to-orchestrator.key \
          --label remediation-to-orchestrator=/run/agentic-signing/orchestrator/remediation-to-orchestrator.key \
          --label orchestrator-to-hunter=/run/agentic-signing/orchestrator/orchestrator-to-hunter.key \
          --label orchestrator-to-hunter=/run/agentic-signing/hunter/orchestrator-to-hunter.key \
          --label hunter-to-orchestrator=/run/agentic-signing/hunter/hunter-to-orchestrator.key \
          --label orchestrator-to-remediation=/run/agentic-signing/orchestrator/orchestrator-to-remediation.key \
          --label orchestrator-to-remediation=/run/agentic-signing/remediation/orchestrator-to-remediation.key \
          --label remediation-to-orchestrator=/run/agentic-signing/remediation/remediation-to-orchestrator.key \
          --label remediation-to-sqs=/run/agentic-signing/remediation/remediation-to-sqs.key \
          --label agentic-shared=/run/agentic-signing/vortex/agentic-shared.key
    environment:
      AGENTIC_SIGNING_SECRET: ${AGENTIC_SIGNING_SECRET:?set AGENTIC_SIGNING_SECRET in .env, see README}
    volumes:
      - agentic_signing_orchestrator:/run/agentic-signing/orchestrator
      - agentic_signing_hunter:/run/agentic-signing/hunter
      - agentic_signing_remediation:/run/agentic-signing/remediation
      - agentic_signing_vortex:/run/agentic-signing/vortex

  orchestrator:
    image: sonarsource/sonarqube-agent-orchestrator:2026.5.0
    # Covers the graceful web drain plus the two db-scheduler shutdown phases (5m + 15m) after it.
    stop_grace_period: 2580s
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 1024M
    cpus: 1
    volumes:
      - agentic_signing_orchestrator:/run/agentic-signing:ro

    environment:
      <<: *job-storage-env
      # The URLs it presigns for a job must outlive the longest one (Hunter's 44400s deadline), so
      # it doesn't take AGENTIC_STORAGE_PRESIGN_TTL_SECONDS, which SonarQube and Vortex use.
      SONAR_AGENTIC_ORCHESTRATOR_STORAGE_PRESIGN_TTL_SECONDS: "45000"
      SERVER_PORT: "8080"
      CORE_DB_READ_WRITE_ENDPOINT: db.example.com:5432
      CORE_DB_NAME: sonarqube
      CORE_DB_USERNAME: "sonarqube"
      CORE_DB_PASSWORD: "changeme"
      SPRING_DATASOURCE_URL: jdbc:postgresql://db.example.com:5432/sonarqube?ApplicationName=agentic-orchestrator&sslmode=verify-full&sslfactory=org.postgresql.ssl.DefaultJavaSSLFactory
      AGENTIC_SONARQUBE_URL: http://sonarqube.example.com:9000
      AGENTIC_SONARQUBE_SIGNING_KEY_PATH: /run/agentic-signing/agentic-shared.key
      AGENTIC_GITHUB_API_BASE_URL: https://api.github.com
      AGENTIC_HUNTER_RUNTIME_PUSH_URL: http://hunter-runtime:8090/jobs
      AGENTIC_HUNTER_RUNTIME_SIGNING_KEY_PATH: /run/agentic-signing/orchestrator-to-hunter.key
      AGENTIC_REMEDIATION_RUNTIME_PUSH_URL: http://remediation-agent-runtime:8090/jobs
      AGENTIC_REMEDIATION_RUNTIME_SIGNING_KEY_PATH: /run/agentic-signing/orchestrator-to-remediation.key
      AGENTIC_INBOUND_HUNTER_VERIFICATION_KEY_PATH: /run/agentic-signing/hunter-to-orchestrator.key
      AGENTIC_INBOUND_REMEDIATION_VERIFICATION_KEY_PATH: /run/agentic-signing/remediation-to-orchestrator.key
      AGENTIC_INBOUND_VERIFICATION_KEY_PATH: /run/agentic-signing/agentic-shared.key
      AGENTIC_JOB_CAPABILITY_SIGNING_KEY_PATH: /run/agentic-signing/orchestrator-job-capability.key
    depends_on:
      signing-init:
        condition: service_completed_successfully

    ports:
      - "${ORCHESTRATOR_PUBLISH_PORT:-9091}:8080"
    networks: [control, data, hunter-jobnet, remediation-jobnet]
    healthcheck:
      # /readyz, as the Helm chart probes: /health aggregates the database and storage checks and
      # turns 503 for the whole drain on stop.
      test: ["CMD-SHELL", "curl -fsS http://localhost:8080/readyz"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 60s

  hunter-runtime:
    image: sonarsource/sonarqube-hunter-agent:2026.5.0
    runtime: runsc
    pids_limit: 512
    # Just above the image's own 44400s graceful-shutdown wait for a running job, so a stop doesn't
    # kill it before that wait ends.
    stop_grace_period: 44430s
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 8192M
    cpus: 2
    volumes:
      - agentic_signing_hunter:/run/agentic-signing:ro

    environment:
      SCRIPT_PATH: /home/agent/app/.venv/bin/detection-agent
      PLAYBOOK_KEY: appsec
      PLAYBOOK_VERSION: stable
      HTTPS_PROXY: http://egress-proxy:3128
      HTTP_PROXY: http://egress-proxy:3128
      NO_PROXY: localhost,127.0.0.1
      # Some HTTP clients read only the lower-case names.
      https_proxy: http://egress-proxy:3128
      http_proxy: http://egress-proxy:3128
      no_proxy: localhost,127.0.0.1
      AGENTIC_VERIFY_KEY_ID: orchestrator-to-hunter
      AGENTIC_VERIFY_KEY_PATH: /run/agentic-signing/orchestrator-to-hunter.key
      AGENT_ORCHESTRATOR_URL: http://orchestrator:8080
      AGENT_ORCHESTRATOR_SIGNING_KEY_PATH: /run/agentic-signing/hunter-to-orchestrator.key
    depends_on:
      egress-proxy:
        condition: service_healthy
      signing-init:
        condition: service_completed_successfully
    networks: [hunter-jobnet]
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8090/readyz"]
      interval: 5s
      timeout: 3s
      retries: 20

  remediation-agent-runtime:
    image: sonarsource/sonarqube-remediation-agent:2026.5.0
    runtime: runsc
    pids_limit: 512
    # Just above the image's own 3600s graceful-shutdown wait for a running job, so a stop doesn't
    # kill it before that wait ends.
    stop_grace_period: 3630s
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 8192M
    cpus: 4
    volumes:
      - agentic_signing_remediation:/run/agentic-signing:ro

    environment:
      REMEDIATION_SCRIPT_PATH: /home/agent/app/.venv/lib/python3.13/site-packages/remediation_agent/main.py
      # egress-proxy's remediation-only listener: the one its SonarQube rules match on.
      HTTPS_PROXY: http://egress-proxy:3129
      HTTP_PROXY: http://egress-proxy:3129
      NO_PROXY: localhost,127.0.0.1
      # Some HTTP clients read only the lower-case names.
      https_proxy: http://egress-proxy:3129
      http_proxy: http://egress-proxy:3129
      no_proxy: localhost,127.0.0.1
      REMEDIATION_RULE_INFO_ENDPOINT: http://sonarqube.example.com:9000/api/rules/show
      REMEDIATION_ANALYSIS_ENDPOINT: http://sonarqube.example.com:9000/api/v2/a3s/private/analyses
      REMEDIATION_AGENTIC_SIGNING_KEY_PATH: /run/agentic-signing/remediation-to-sqs.key
      AGENTIC_VERIFY_KEY_ID: orchestrator-to-remediation
      AGENTIC_VERIFY_KEY_PATH: /run/agentic-signing/orchestrator-to-remediation.key
      AGENT_ORCHESTRATOR_URL: http://orchestrator:8080
      AGENT_ORCHESTRATOR_SIGNING_KEY_PATH: /run/agentic-signing/remediation-to-orchestrator.key
    depends_on:
      egress-proxy:
        condition: service_healthy
      signing-init:
        condition: service_completed_successfully

    networks: [remediation-jobnet]
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8090/readyz"]
      interval: 5s
      timeout: 3s
      retries: 20

  egress-proxy:
    image: ubuntu/squid:6.6-24.04_edge@sha256:8a3baed477e2c282ab8aa5edad442f69873246964f225c5c2ae8364b6610963c
    entrypoint: ["/bin/sh", "/usr/local/bin/render-and-run.sh"]
    # The image's proxy user. The entrypoint renders squid.conf into /tmp and runs Squid in the
    # foreground, logging to stdout, so nothing else on the root filesystem needs to be writable.
    user: "13:13"
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 128M
    cpus: 0.25
    environment:
      AGENTIC_LLM_ALLOWED_DOMAINS: api.anthropic.com
      AGENTIC_STORAGE_ALLOWED_DOMAIN: agentic-bucket.s3.eu-west-1.amazonaws.com
      AGENTIC_STORAGE_ALLOWED_PORT: "443"
      AGENTIC_SQS_PROXY_SCHEME: http
      AGENTIC_SQS_PROXY_HOST: sonarqube.example.com
      AGENTIC_SQS_PROXY_PORT: 9000
      AGENTIC_ORCHESTRATOR_PROXY_HOST: orchestrator
      AGENTIC_ORCHESTRATOR_PROXY_PORT: "8080"
    volumes:
      - ./egress-proxy/squid.conf.template:/etc/squid/squid.conf.template:ro
      - ./egress-proxy/entrypoint.sh:/usr/local/bin/render-and-run.sh:ro

    networks: [hunter-jobnet, remediation-jobnet, egress, proxy-target]
    healthcheck:
      # The image ships no curl; bash's /dev/tcp is enough to see the listener is up.
      test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/3128"]
      interval: 5s
      timeout: 3s
      retries: 20

  vortex:
    image: sonarsource/sonar-vortex:2026.5.0
    # Lets in-flight analyses finish on a stop.
    stop_grace_period: 180s
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 8192M
    cpus: 2
    volumes:
      - agentic_signing_vortex:/run/agentic-signing:ro

    environment:
      <<: *context-storage-env
      VORTEX_ANALYSIS_SONARQUBE_URL: http://sonarqube.example.com:9000
      AGENTIC_ORCHESTRATOR_SIGNING_KEY_PATH: /run/agentic-signing/agentic-shared.key
    depends_on:
      signing-init:
        condition: service_completed_successfully

    ports:
      - "${VORTEX_PUBLISH_PORT:-9092}:8080"
    networks: [control]
    healthcheck:
      # The Vortex image ships neither curl nor wget, so probe with bash's /dev/tcp instead.
      test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /health HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q ' 200 '"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 600s
```

</details>

The host names, bucket, and credentials in the example are placeholders, and the signing secret is redacted. Run the generator with your own values rather than editing the generated files.

### Environment file <a href="#env-file" id="env-file"></a>

Compose reads `.env` from the same directory automatically. Keep it out of version control, and restrict who can read it: it holds the signing secret and the storage credentials.

<details>

<summary>.env</summary>

```properties
# Generated by agentic-compose-generator/generate.py — holds the signing secret and, if using
# --storage s3, bucket credentials. Do not commit this file. Regenerating the bundle keeps the
# secrets below; pass --rotate-secrets to replace them.

AGENTIC_SIGNING_SECRET=<generate with: openssl rand -hex 32>
AGENTIC_STORAGE_BUCKET=agentic-bucket
AGENTIC_STORAGE_REGION=eu-west-1
AGENTIC_STORAGE_ENDPOINT=
AGENTIC_STORAGE_ACCESS_KEY='AKIAEXAMPLE'
AGENTIC_STORAGE_SECRET_KEY='changeme'
AGENTIC_STORAGE_PATH_STYLE_ACCESS=false
AGENTIC_STORAGE_PRESIGN_TTL_SECONDS=21600
```

</details>

Replace the `AGENTIC_SIGNING_SECRET` placeholder with a secret you generate as described in [#authentication](#authentication "mention"). The placeholder text is long enough to pass the 32-character check, so the deployment starts with it if you forget.

{% hint style="warning" %}
Values you set here and in `docker-compose.yaml` become plain container environment variables. Anyone with Docker access on the host can read them with `docker inspect` or `docker compose config`. Treat access to the container host as access to these credentials.
{% endhint %}

## Restricting outbound access <a href="#egress" id="egress"></a>

The runtime containers sit on networks with no route off the host. Everything they reach, they reach through the egress proxy, which is the one place you enforce and audit what a job can do.

The proxy allows exactly these things, and denies the rest:

| Allow                 | From                                                                         | To                                                                    |
| --------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Your LLM providers    | Either runtime                                                               | The domains in `AGENTIC_LLM_ALLOWED_DOMAINS`.                         |
| Your storage endpoint | Either runtime                                                               | The bucket host in `AGENTIC_STORAGE_ALLOWED_DOMAIN`, when one is set. |
| SonarQube Server      | The Remediation Agent runtime only, on the proxy's dedicated `3129` listener | Rule descriptions and fix verification.                               |
| Upload URL renewal    | Either runtime                                                               | The Orchestrator, on that one route.                                  |

Scoping the SonarQube Server rule to one runtime and one listener matters: the Hunter Agent runtime has no reason to reach SonarQube Server, and both runtimes reach the same proxy, so the rule pairs the listener with the source rather than relying on the port alone.

Rather than a static file, the proxy renders its configuration from a template at container start, so the allowlist comes from environment variables instead of being hand-edited into `squid.conf`:

<details>

<summary>egress-proxy/squid.conf.template</summary>

```
visible_hostname egress-proxy
http_port 3128
# The remediation runtime's own listener, the only one the SonarQube rules below match on. Both
# runtimes reach this proxy, so nothing stops hunter-runtime from dialling it: the rules pair it
# with remediation_client rather than relying on the port alone.
http_port 3129

# Allowlist: an agent runtime may reach ONLY the configured LLM endpoints, plus the storage bucket
# host when one is set. @@LLM_ACL_LINES@@ and @@STORAGE_ACL_LINES@@ are replaced at container start
# from AGENTIC_LLM_ALLOWED_DOMAINS and AGENTIC_STORAGE_ALLOWED_DOMAIN/_PORT. A denied host looks like an LLM
# failure in the agent's own logs — check the proxy's logs first.
acl SSL_ports port 443
# Every port any rule below allows: 80/443, SonarQube's, the orchestrator's, and the storage
# endpoint's. `deny !Safe_ports` rejects anything else before any allow rule. @@SAFE_PORT_LINES@@
# is rendered at container start, one `acl Safe_ports port <n>` line per distinct port.
@@SAFE_PORT_LINES@@
acl CONNECT method CONNECT
@@LLM_ACL_LINES@@
@@STORAGE_ACL_LINES@@
# The ports an LLM host is reachable on. Safe_ports above also names SonarQube's, the
# orchestrator's and the storage endpoint's, so an allowlisted host would otherwise answer on any of
# those too.
acl web_ports port 80 443
# Link-local, where cloud metadata services live: no allowlisted name may resolve into it, so a
# mistaken or hijacked entry can't turn this proxy into a way to reach them. Only ever the last ACL
# of an allow rule: `dst` makes Squid resolve the destination, and Squid stops evaluating a rule at
# its first non-matching ACL, so only a host already on the allowlist is ever looked up here.
acl link_local dst 169.254.0.0/16

# SonarQube: remediation-agent-runtime's rule-info/analysis calls. Static, unlike the LLM
# allowlist above — this leg doesn't depend on AGENTIC_LLM_ALLOWED_DOMAINS. Only the remediation
# runtime may use it; hunter-runtime reaches this proxy too and must stay unable to reach
# SonarQube. Not anchored at ^: Compose's PTR answer is project-prefixed. Defined up here rather
# than beside its own rules further down, because one of those rules has to precede the CONNECT
# deny below and an acl must be defined before it is referenced.
acl remediation_client srcdom_regex -i remediation-agent-runtime
acl remediation_listener localport 3129
acl sqs_host dstdomain -n @@SQS_HOST@@
acl sqs_port port @@SQS_PORT@@
# The only two endpoints the runtime calls. urlpath_regex sees the path *and* the query string, so
# both tolerate a trailing "?..." (rules/show carries ?key=...). The `^[^?]*` prefix covers a
# SonarQube served under a web context, and can't cross a `?`, so `/anything?x=/rules/show` is
# still denied. Only a plain-http SonarQube can be held to these paths: an https one is a CONNECT
# tunnel, opaque to Squid, and gets the host-and-port rule below instead.
acl sqs_endpoints urlpath_regex ^[^?]*/rules/show(\?|$) ^[^?]*/a3s/private/analyses(/|\?|$)

# Ordering matters: http_access rules are matched in file order and the first match wins, so every
# allow has to sit above the deny that would otherwise swallow it.
http_access deny !Safe_ports
http_access allow CONNECT allowed_host SSL_ports !link_local
@@STORAGE_TUNNEL_RULE@@
@@SQS_TUNNEL_RULE@@
http_access deny CONNECT !SSL_ports
http_access allow allowed_host web_ports !link_local
@@STORAGE_HTTP_RULE@@

# The same leg when SonarQube is plain http://sonarqube:9000 and no tunnel is involved.
http_access allow remediation_listener remediation_client sqs_host sqs_port sqs_endpoints

# Locator renewal: both runtimes pull a re-signed upload URL from the orchestrator when a
# presigned one expires mid-job. Plain HTTP, so Squid sees the request line and this admits the
# renewal route and nothing else the orchestrator serves. Defence in depth only, unlike the
# SonarQube leg above: the orchestrator shares a network with each runtime, so a client that ignores
# HTTP_PROXY reaches it directly and this rule never sees the request. The orchestrator's own
# checks (RUNNING job only, a single write locator, never a read locator) are the real control.
# The job id is left unconstrained: the orchestrator answers 404 for anything it does not own.
# Same non-anchored srcdom_regex reasoning as remediation_client above.
# urlpath_regex sees path *and* query string, and the job id now rides in the query, so the pattern
# requires a non-empty jobId but does not care where in the query it sits — a parameter added
# before or after it must not silently turn renewal into a 403.
acl runtime_client srcdom_regex -i (hunter-runtime|remediation-agent-runtime)
acl orchestrator_host dstdomain -n @@ORCHESTRATOR_HOST@@
acl orchestrator_port port @@ORCHESTRATOR_PORT@@
acl locator_renewal urlpath_regex ^/artifact-locators\?([^&]*&)*jobId=[^&]+
http_access allow runtime_client orchestrator_host orchestrator_port locator_renewal

http_access deny all

# A policy gateway, not a cache.
cache deny all
# Squid's default cache_mem is 256 MB even with caching denied: the pool still holds in-transit
# objects. Nothing is cached here, so keep it small.
cache_mem 16 MB
# Squid sizes its per-descriptor tables from RLIMIT_NOFILE at startup. Docker commonly hands out
# 1048576 descriptors, which makes that reservation ~170 MB before a single request. Two
# descriptors per proxied request, so this allows roughly 2000 concurrent ones.
max_filedescriptors 4096
# Don't tell the destination that a proxy is in the path, or which runtime is behind it.
via off
forwarded_for delete
# Presigned storage URLs carry credentials in the query string (signature, access key) — keep them
# out of the access log rather than relying on Squid's own default for this.
strip_query_terms on
# Squid starts as the proxy user and owns the container's stdout/stderr, so logs go straight there:
# inspect denials with `docker compose logs egress-proxy`. The root filesystem is read-only anyway.
access_log stdio:/dev/stdout squid
cache_log stdio:/dev/stderr
# Nothing signals Squid through its PID file (it runs in the foreground, one per container), and
# the default /run/squid.pid isn't writable on the read-only root.
pid_filename none
# The ICMP pinger needs NET_RAW, which the container drops; nothing here uses its measurements.
pinger_enable off
```

</details>

<details>

<summary>egress-proxy/entrypoint.sh</summary>

```sh
#!/bin/sh
# Renders the egress allowlist into squid.conf, then execs Squid on it in the foreground. Runs as
# the image's proxy user on a read-only root, so the config goes to the /tmp tmpfs; the base image's
# own entrypoint needs root to set up its log tails and cache dirs, and neither is used here.
set -eu

TEMPLATE=/etc/squid/squid.conf.template
TARGET=/tmp/squid.conf
LLM_DOMAINS="${AGENTIC_LLM_ALLOWED_DOMAINS:-}"
STORAGE_DOMAIN="${AGENTIC_STORAGE_ALLOWED_DOMAIN:-}"
SQS_HOST="${AGENTIC_SQS_PROXY_HOST:-sonarqube}"
SQS_PORT="${AGENTIC_SQS_PROXY_PORT:-9000}"
SQS_SCHEME="${AGENTIC_SQS_PROXY_SCHEME:-http}"
STORAGE_PORT="${AGENTIC_STORAGE_ALLOWED_PORT:-}"
ORCHESTRATOR_HOST="${AGENTIC_ORCHESTRATOR_PROXY_HOST:-orchestrator}"
ORCHESTRATOR_PORT="${AGENTIC_ORCHESTRATOR_PROXY_PORT:-8080}"

if [ -z "$LLM_DOMAINS" ]; then
  echo "egress-proxy: AGENTIC_LLM_ALLOWED_DOMAINS is empty — every outbound request would be denied," \
    "which surfaces as an unexplained LLM failure inside the agent. Set it in .env." >&2
  exit 1
fi

# Same charset as the domain check below, plus digits-only for the port — an unvalidated value
# here would otherwise reach awk's gsub as the replacement text, breaking squid.conf and taking
# down ALL egress, not just this leg.
case "$SQS_HOST" in
  *[!A-Za-z0-9.-]*)
    echo "egress-proxy: refusing malformed AGENTIC_SQS_PROXY_HOST '$SQS_HOST'" >&2
    exit 1
    ;;
  *) ;;
esac
case "$SQS_PORT" in
  *[!0-9]*|"")
    echo "egress-proxy: refusing malformed AGENTIC_SQS_PROXY_PORT '$SQS_PORT'" >&2
    exit 1
    ;;
  *) ;;
esac
case "$SQS_SCHEME" in
  http|https) ;;
  *)
    echo "egress-proxy: refusing AGENTIC_SQS_PROXY_SCHEME '$SQS_SCHEME' (expected http or https)" >&2
    exit 1
    ;;
esac
# Optional, like the storage domain: blank when the stack has no S3 endpoint.
case "$STORAGE_PORT" in
  *[!0-9]*)
    echo "egress-proxy: refusing malformed AGENTIC_STORAGE_ALLOWED_PORT '$STORAGE_PORT'" >&2
    exit 1
    ;;
  *) ;;
esac
# Same validation for the orchestrator leg, and for the same reason: an unvalidated value reaches
# awk's gsub as replacement text and would break squid.conf, taking down ALL egress.
case "$ORCHESTRATOR_HOST" in
  *[!A-Za-z0-9.-]*)
    echo "egress-proxy: refusing malformed AGENTIC_ORCHESTRATOR_PROXY_HOST '$ORCHESTRATOR_HOST'" >&2
    exit 1
    ;;
  *) ;;
esac
case "$ORCHESTRATOR_PORT" in
  *[!0-9]*|"")
    echo "egress-proxy: refusing malformed AGENTIC_ORCHESTRATOR_PROXY_PORT '$ORCHESTRATOR_PORT'" >&2
    exit 1
    ;;
  *) ;;
esac

# Storage is optional (blank while the stack still uses the local volume) but comes as a pair: its
# rules below are scoped to the storage port, so a domain without one could never be reached.
if [ -n "$STORAGE_DOMAIN" ] && [ -z "$STORAGE_PORT" ]; then
  echo "egress-proxy: AGENTIC_STORAGE_ALLOWED_DOMAIN is set but AGENTIC_STORAGE_ALLOWED_PORT is empty" >&2
  exit 1
fi

# One `acl <name> dstdomain -n <host>` line per entry — repeating the ACL name unions the values.
# A leading dot makes Squid's dstdomain match every subdomain, e.g. `.amazonaws.com` would allow any
# bucket on AWS — refuse it, since the whole point is an exact-host allowlist, not a wildcard.
# -n, here and on every other dstdomain ACL: without it, a request by bare IP makes Squid look up
# the address's name, and a reverse lookup of an address the client picks is a DNS channel out.
domain_acls() {
  acl=$1
  domains=$2
  for domain in $(printf '%s' "$domains" | tr ',' ' '); do
    [ -n "$domain" ] || continue
    case "$domain" in
      .*)
        echo "egress-proxy: refusing wildcard domain '$domain' — a leading '.' matches every subdomain" >&2
        exit 1
        ;;
      *[!A-Za-z0-9.-]*)
        echo "egress-proxy: refusing malformed domain '$domain' (from AGENTIC_LLM_ALLOWED_DOMAINS or AGENTIC_STORAGE_ALLOWED_DOMAIN)" >&2
        exit 1
        ;;
      *) ;;
    esac
    printf 'acl %s dstdomain -n %s\n' "$acl" "$domain"
  done
}
acl_lines=$(domain_acls allowed_host "$LLM_DOMAINS")

# The storage host gets its own ACL so each allowlist is reachable on its own ports only: the LLM
# hosts on 80/443, the storage host on the port its endpoint names. Without a storage domain, no
# rule renders at all — a valueless ACL would make Squid warn on every start. The tunnel rule takes
# no SSL_ports: an https endpoint may sit on any port (MinIO's 9000, say), and storage_port alone
# already holds it to that one; the rule precedes `deny CONNECT !SSL_ports`, so it still applies.
storage_acl_lines="# No storage host: the stack keeps job artifacts on a local volume."
storage_tunnel_rule=""
storage_http_rule=""
if [ -n "$STORAGE_DOMAIN" ]; then
  storage_acl_lines="$(domain_acls storage_host "$STORAGE_DOMAIN")
acl storage_port port ${STORAGE_PORT}"
  storage_tunnel_rule="http_access allow CONNECT storage_host storage_port !link_local"
  storage_http_rule="http_access allow storage_host storage_port !link_local"
fi

# One line per distinct port: the same port twice (SonarQube on 443, say) would only be noise.
safe_port_lines=""
for port in $(printf '%s\n' 80 443 "$SQS_PORT" "$ORCHESTRATOR_PORT" ${STORAGE_PORT:+"$STORAGE_PORT"} | sort -un); do
  safe_port_lines="${safe_port_lines}acl Safe_ports port ${port}
"
done

# An https SonarQube is a CONNECT tunnel, so Squid can't see the path and can hold it only to its
# host and port. A plain-http one gets no tunnel rule at all: its path-restricted rule is the only
# way in.
if [ "$SQS_SCHEME" = https ]; then
  sqs_tunnel_rule="http_access allow CONNECT remediation_listener remediation_client sqs_host sqs_port
"
else
  sqs_tunnel_rule="# No CONNECT rule for SonarQube: it is plain http, reached only via sqs_endpoints below.
"
fi

awk -v repl="$acl_lines" -v safe_ports="$safe_port_lines" -v sqs_tunnel="$sqs_tunnel_rule" \
  -v storage_acls="$storage_acl_lines" -v storage_tunnel="$storage_tunnel_rule" -v storage_http="$storage_http_rule" \
  -v sqs_host="$SQS_HOST" -v sqs_port="$SQS_PORT" \
  -v orch_host="$ORCHESTRATOR_HOST" -v orch_port="$ORCHESTRATOR_PORT" '
  { if ($0 == "@@LLM_ACL_LINES@@") { print repl; next }
    if ($0 == "@@STORAGE_ACL_LINES@@") { print storage_acls; next }
    if ($0 == "@@STORAGE_TUNNEL_RULE@@") { if (storage_tunnel != "") print storage_tunnel; next }
    if ($0 == "@@STORAGE_HTTP_RULE@@") { if (storage_http != "") print storage_http; next }
    if ($0 == "@@SAFE_PORT_LINES@@") { printf "%s", safe_ports; next }
    if ($0 == "@@SQS_TUNNEL_RULE@@") { printf "%s", sqs_tunnel; next }
    gsub(/@@SQS_HOST@@/, sqs_host); gsub(/@@SQS_PORT@@/, sqs_port);
    gsub(/@@ORCHESTRATOR_HOST@@/, orch_host); gsub(/@@ORCHESTRATOR_PORT@@/, orch_port); print }' \
  "$TEMPLATE" > "$TARGET"

echo "egress-proxy: allowlisting $(printf '%s' "$LLM_DOMAINS" | tr ',' ' ') on 80/443${STORAGE_DOMAIN:+, $STORAGE_DOMAIN on $STORAGE_PORT}"

exec squid -N -f "$TARGET" "$@"
```

</details>

Mount both files as shown in the [#example](#example "mention"), and set the domains through the `egress-proxy` service's environment.

{% hint style="danger" %}
Create both files before the first start. A bind mount whose source is missing is created as an empty directory. The proxy then starts with a directory where its configuration should be, and fails with an error that points at Squid rather than at the missing file.
{% endhint %}

{% hint style="danger" %}
Do not give `AGENTIC_LLM_ALLOWED_DOMAINS` or `AGENTIC_STORAGE_ALLOWED_DOMAIN` a leading dot. A leading dot matches every subdomain: `.example.com` allows any host under that domain rather than the one you intended. The entrypoint script refuses one and exits, rather than starting with an accidental wildcard allow.
{% endhint %}

Where SonarQube Server is served over HTTPS, the Remediation Agent runtime's calls to it arrive as a `CONNECT` tunnel, which the proxy cannot see inside, so it can only hold the tunnel to the configured host and port. That leaves the runtime itself to trust the certificate, see [#custom-ca](#custom-ca "mention").

A denied request surfaces inside the agent as a provider failure rather than as a policy error. Check the proxy first when you diagnose one:

```bash
docker compose logs -f egress-proxy
```

## Vortex analysis <a href="#vortex-analysis" id="vortex-analysis"></a>

Define Vortex analysis as a long-lived service alongside the Orchestrator, with access to the shared storage location. It authenticates to SonarQube Server with a signing key derived from the shared `AGENTIC_SIGNING_SECRET`, not a token. Then point SonarQube Server at it, see [Configuration reference](/sonarqube-server/server-installation/ai-agents/configuration-reference.md).

Allow a slow first start. Vortex analysis runs a JVM analyzer with Node and .NET alongside it, so it reports healthy later than the other components do.

Docker Compose has no dependency validation, so nothing stops you from starting the Remediation Agent runtime without Vortex analysis. If you do, the agent runs in a degraded state and its fixes are not verified. See [Installation overview](/sonarqube-server/server-installation/ai-agents/installation-overview.md#vortex-analysis) for why the Remediation Agent needs it, its storage and lifecycle requirements, and how to confirm it's running.

## Running the runtimes sandboxed <a href="#sandboxing" id="sandboxing"></a>

Each runtime service must request the sandbox runtime and set its own resource limits. Docker cannot cap a container's disk usage on its own, so the sandbox provides that through a bounded writable overlay, configured when you install it on the host.

```yaml
services:
  remediation-agent-runtime:
    runtime: runsc
    pids_limit: 512
    mem_limit: 8192M
    cpus: 4
```

Set `pids_limit` on every runtime. It bounds how many processes a job can create, in code paths a large language model influences.

{% hint style="danger" %}
Do not override `runtime` to an unsandboxed value. If `runsc` is not registered on the host, the container refuses to start rather than falling back, which is the intended behavior. Fix the host instead of removing the sandbox.
{% endhint %}

Size each service's `cpus`, `mem_limit`, and the sandbox's writable overlay to match its Kubernetes resource requests and limits. See [Capacity planning](/sonarqube-server/server-installation/ai-agents/capacity-planning.md) for the current defaults for the Hunter Agent runtime, the Remediation Agent runtime, and Vortex analysis.

## Job timeouts and shutdown <a href="#timeouts" id="timeouts"></a>

Four settings bound how long a job may run, and they have to stay in order. Three are environment variables on the runtime, carrying a default from its image; the fourth is on the Orchestrator.

| Setting                                                                                        | Set on           | Bounds                                                                                  | Hunter default     | Remediation default |
| ---------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------- | ------------------ | ------------------- |
| `SCRIPT_TIMEOUT_SECONDS`                                                                       | The runtime      | How long the agent itself may run before the runtime stops it.                          | `43200` (12 hours) | `900` (15 minutes)  |
| `SHUTDOWN_GRACE_SECONDS`                                                                       | The runtime      | How long shutdown waits for an in-flight job to finish.                                 | `43800`            | `2100`              |
| `LIVENESS_JOB_MAX_SECONDS`                                                                     | The runtime      | When a job counts as stuck rather than slow, at which point the container is restarted. | `46800`            | `2700`              |
| `sonar.agentic.orchestrator.watcher.hunter-timeout-seconds` and `.remediation-timeout-seconds` | The Orchestrator | How long the Orchestrator waits before marking a job failed.                            | `44400`            | `3600`              |

Keep them in ascending order on each runtime, and keep the Orchestrator's timeout above the runtime's script timeout. A grace period below the script timeout means a job in flight at shutdown is killed rather than finished; an Orchestrator timeout below the script timeout means the Orchestrator gives up on a job that is still working.

### Stopping without losing a job <a href="#stop-grace" id="stop-grace"></a>

Each component drains on shutdown. The Orchestrator runs a graceful web drain followed by two scheduler shutdown phases (5 minutes and 15 minutes), and each runtime waits for its running job to finish and upload its results.

Compose allows 10 seconds for that by default. Set `stop_grace_period` above each component's own drain, as the example does:

```yaml
services:
  orchestrator:
    stop_grace_period: 2580s
  hunter-runtime:
    stop_grace_period: 44430s
  remediation-agent-runtime:
    stop_grace_period: 3630s
  vortex:
    stop_grace_period: 180s
```

The runtime values sit just above each image's own graceful-shutdown wait for a running job (44400 seconds for the Hunter Agent, 3600 seconds for the Remediation Agent), so a stop does not kill a job before that wait ends. The Vortex analysis value lets in-flight analyses finish.

{% hint style="danger" %}
Without this, `docker compose down`, `stop`, and `restart` send `SIGTERM` and then kill the container 10 seconds later, in the middle of the drain. A running job is lost with its results unwritten, and because the runtime is killed before it can report anything, the Orchestrator only fails the job once its own watcher timeout expires, up to 12 hours later for the Hunter Agent.
{% endhint %}

These are ceilings, not waits. Stopping an idle deployment returns immediately, because the drain completes as soon as there is nothing in flight. Run `docker compose ps` first if you want to know whether a job is running before you stop.

## Network isolation <a href="#network-isolation" id="network-isolation"></a>

The Compose deployment models the same security boundaries the Kubernetes network policies do, using separate Docker networks. The important properties to preserve:

| Boundary                                                                                                                                 | Why                                                                                                                                                         |
| ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The Orchestrator can reach the runtimes                                                                                                  | It pushes jobs to them.                                                                                                                                     |
| SonarQube Server cannot reach the runtimes                                                                                               | The runtimes are untrusted, and nothing in SonarQube Server needs to call them.                                                                             |
| The runtimes cannot reach SonarQube Server or the database, except the Remediation Agent runtime's signed calls through the egress proxy | Most jobs ask the Orchestrator for SonarQube Server data; the Remediation Agent runtime calls SonarQube Server to verify fixes and fetch rule descriptions. |
| The runtimes cannot reach each other                                                                                                     | One job must not be able to observe or disturb another.                                                                                                     |
| Only the egress proxy has a route out                                                                                                    | This is what constrains where a job can send data.                                                                                                          |

Two things do that work in the example. First, each runtime sits only on its own network, `hunter-jobnet` or `remediation-jobnet`, shared with the Orchestrator for job dispatch and with the egress proxy for its route out. Both are `internal: true`, so the runtimes have no route off the host and cannot reach each other, and SonarQube Server is on neither. Second, only the Orchestrator and Vortex analysis publish a port.

{% hint style="warning" %}
The runtime services do not authenticate requests to their own endpoints beyond verifying the Orchestrator's signature on a job push. Their protection is network isolation, so these boundaries are a security control rather than tidy organization. Do not publish runtime ports, and do not place the runtimes on a network that other workloads can reach.
{% endhint %}

## Trusting a private certificate authority <a href="#custom-ca" id="custom-ca"></a>

If SonarQube Server serves a certificate from your own authority, or outbound traffic passes through a TLS-inspecting proxy, the components have to trust that authority. Put the certificates in a directory on the host and mount it. Each component takes it differently:

| Component          | How it trusts the authority                                         |
| ------------------ | ------------------------------------------------------------------- |
| Agent Orchestrator | Mount the certificates at `/custom-ca`. Nothing else is needed.     |
| Agent runtimes     | Mount the certificates at `/custom-ca`. Nothing else is needed.     |
| Vortex analysis    | Build a truststore and point the JVM at it.                         |
| SonarQube Server   | Build a truststore and point the web and compute engine JVMs at it. |

Neither Vortex analysis nor SonarQube Server reads `/custom-ca`, and SonarQube Server's own truststore is read-only inside its image. Add a one-shot service that builds a truststore from the same directory:

<details>

<summary>docker-compose.yaml (truststore addition)</summary>

```yaml
volumes:
  truststore:

services:
  truststore-init:
    image: eclipse-temurin:21-jre
    command:
      - sh
      - -c
      - |
        cp "$$JAVA_HOME/lib/security/cacerts" /truststore/cacerts
        i=0
        for cert in /custom-ca/*.crt /custom-ca/*.pem; do
          [ -f "$$cert" ] || continue
          i=$$((i + 1))
          keytool -importcert -keystore /truststore/cacerts -storepass changeit \
            -noprompt -alias "custom-ca-$$i" -file "$$cert"
        done
    volumes:
      - ./custom-ca:/custom-ca:ro
      - truststore:/truststore

  vortex:
    environment:
      JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/truststore/cacerts -Djavax.net.ssl.trustStorePassword=changeit
    volumes:
      - truststore:/truststore:ro
    depends_on:
      truststore-init:
        condition: service_completed_successfully
```

</details>

Give SonarQube Server the same truststore, through `SONAR_WEB_JAVAADDITIONALOPTS` and `SONAR_CE_JAVAADDITIONALOPTS`.

The [#example](#example "mention") has no `/custom-ca` mount, because it uses plain HTTP throughout. When you need one, add `./custom-ca:/custom-ca:ro` to the `volumes` of the Orchestrator and of each runtime service. An empty `/custom-ca` changes nothing, and each component keeps the trust its image shipped with.

## Traffic between components <a href="#traffic" id="traffic"></a>

Traffic between the Orchestrator, the runtimes, and Vortex analysis is unencrypted, on the basis that these components sit together on internal networks on one host and authenticate each request by signature rather than by transport. Traffic that leaves the host does not have to be: put SonarQube Server behind TLS and the components reach it over HTTPS. TLS is disabled in the example; every hop is plain HTTP.

To encrypt the published component ports as well, terminate TLS in front of the Orchestrator and Vortex analysis with a reverse proxy of your own, and give SonarQube Server the issuing authority.

## Starting the deployment <a href="#starting" id="starting"></a>

Have these files beside your Compose file before the first start:

| File                               | Holds                                                                                          |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| `.env`                             | The values from [#env-file](#env-file "mention").                                              |
| `egress-proxy/squid.conf.template` | The egress allowlist template from [#egress](#egress "mention").                               |
| `egress-proxy/entrypoint.sh`       | The script that renders the allowlist and starts the proxy, from [#egress](#egress "mention"). |

The orchestrator stores its state in SonarQube's database and keeps failing its health check until SonarQube has created the schema, so start your SonarQube first, then `docker compose up -d`. On an upgrade, SonarQube Server must also be migrated before the Orchestrator starts. The Helm chart enforces this with a `wait-for-sonarqube` init container on the Orchestrator.

Then start the components and confirm each one reports healthy:

```bash
docker compose up -d
docker compose ps
```

### Handing the keys to SonarQube Server <a href="#key-handover" id="key-handover"></a>

Run the commands below on the SonarQube Server host. Compose reads `.env` on its own, but your shell does not, so set `AGENTIC_SIGNING_SECRET` in the shell first, to the same value as in `.env`. The orchestrator image is the one the example uses, `sonarsource/sonarqube-agent-orchestrator:2026.5.0`.

Every agentic request between SonarQube, the orchestrator, and Vortex is signed. Derive your copy of the shared key from this bundle's `AGENTIC_SIGNING_SECRET` (in `.env`) and point SonarQube at it:

```bash
mkdir -p ~/.sonarqube/agentic-signing
printf '%s' "$AGENTIC_SIGNING_SECRET" > ~/.sonarqube/agentic-signing/instance-secret
chmod 600 ~/.sonarqube/agentic-signing/instance-secret

docker run --rm --user 0:0 \
  -v ~/.sonarqube/agentic-signing:/run/agentic-signing \
  --entrypoint /derive-keys.sh <orchestrator image from this bundle> \
  --secret-file /run/agentic-signing/instance-secret \
  --label agentic-shared=/run/agentic-signing/agentic-shared.key
```

{% hint style="danger" %}
`instance-secret` is the shared secret itself, and every other key derives from it. The derivation runs as root, so make sure both `instance-secret` and `agentic-shared.key` are owned by the SonarQube Server user and readable only by it.
{% endhint %}

Then in `sonar.properties`:

```properties
sonar.agentic.signing.secretFile=/home/<you>/.sonarqube/agentic-signing/instance-secret
sonar.agentic.orchestrator.signingKeyPath=/home/<you>/.sonarqube/agentic-signing/agentic-shared.key
```

Apply the rest of the properties from [#server-properties](#server-properties "mention"), and restart SonarQube Server.

Regenerating this bundle keeps `AGENTIC_SIGNING_SECRET`, so these keys stay valid. Only after `--rotate-secrets` redo this step and run `docker compose up -d --force-recreate`, so SonarQube and the containers derive their keys from the new secret.

### Confirming it worked <a href="#confirming" id="confirming"></a>

Check **Administration** > **System**, where the Agent Orchestrator, Vortex analysis, and each agent runtime the Orchestrator can see should all report healthy. See [Verifying the deployment](/sonarqube-server/server-installation/ai-agents/verifying-the-deployment.md).

If Vortex stays unhealthy and its health check reports `sonarqube-connectivity DOWN: ... Unauthorized`, your SonarQube is missing part of sections 3, 5 or 7: check that all three are applied and that SonarQube was restarted since. With TLS, `agentic-proxy` waits for a healthy Vortex, so it doesn't start either until then.

Sections 3, 5, and 7 are the Orchestrator and Vortex analysis URLs in [#server-properties](#server-properties "mention"), the signing key in [#key-handover](#key-handover "mention"), and [#tls-trust](#tls-trust "mention").

## Using a shared filesystem instead of object storage <a href="#shared-filesystem" id="shared-filesystem"></a>

A shared filesystem works too, and is the other backend in [Setting up shared storage](/sonarqube-server/server-installation/ai-agents/shared-storage.md). It changes three things:

* Set the storage type to `FILESYSTEM` and a base directory instead of a bucket, in both property namespaces, and on SonarQube Server as well:

```yaml
  orchestrator:
    environment:
      SONAR_AGENTIC_ORCHESTRATOR_STORAGE_TYPE: FILESYSTEM
      SONAR_AGENTIC_ORCHESTRATOR_STORAGE_FILESYSTEM_BASE_DIR: /agentic-storage

  vortex:
    environment:
      SONAR_AGENTIC_STORAGE_TYPE: FILESYSTEM
      SONAR_AGENTIC_STORAGE_FILESYSTEM_BASE_DIR: /agentic-storage
```

* Mount the storage in every component, including both runtimes, at the same path. The components exchange artifacts by path in this mode, and they must all resolve a given path to the same file
* Add a one-shot service that makes the directory writable by every component, which each run as a different user with no owner or group in common

The trade-off is that the runtimes hold a mount rather than a short-lived presigned URL, and the path has to match everywhere. Object storage avoids both.

## SonarQube Server on its own host <a href="#separate-host" id="separate-host"></a>

The example runs SonarQube Server on the container host. The components already reach SonarQube Server and its database by host name (`sonarqube.example.com` and `db.example.com`), so that side works unchanged from another host. On SonarQube Server, replace `localhost` in the Orchestrator and Vortex analysis URLs from [#server-properties](#server-properties "mention") with the container host's address. Both sides need names that resolve from where they run and a network path on those ports.

The agents cannot run from a ZIP installation, because they need a container runtime for the per-job sandbox. SonarQube Server itself can, and this deployment works against it unchanged: set the agentic properties in its own `conf/sonar.properties` and place the secret file and the `agentic-shared` key on that host.

## Next step <a href="#next-step" id="next-step"></a>

Confirm the deployment works, see [Verifying the deployment](/sonarqube-server/server-installation/ai-agents/verifying-the-deployment.md).

## Related pages <a href="#related-pages" id="related-pages"></a>

* [Installation overview](/sonarqube-server/server-installation/ai-agents/installation-overview.md)
* [Configuration reference](/sonarqube-server/server-installation/ai-agents/configuration-reference.md)
* [Verifying the deployment](/sonarqube-server/server-installation/ai-agents/verifying-the-deployment.md)
* [Capacity planning](/sonarqube-server/server-installation/ai-agents/capacity-planning.md)
* [Troubleshooting](/sonarqube-server/server-installation/ai-agents/troubleshooting.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.sonarsource.com/sonarqube-server/server-installation/ai-agents/deploying-with-docker-compose.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
