Docker Compose setups for parallel AI agents

Docker Compose setups for parallel AI agents

~ 13 min read


AI coding agents are much more useful when they can run the application, reproduce a bug, write a fix, and verify it without waiting for a human to babysit the environment.

That gets awkward as soon as the app needs more than one simultaneous process.

A Laravel app is a good example. It is not just PHP serving HTTP. A realistic local stack might include Laravel Sail, MySQL, a separate test database, Redis, a queue worker, Mailpit, Vite, Playwright, scheduled jobs, and local fakes for email, payments, AI, or CRM integrations.

One agent can usually run that stack. Several agents running it at the same time is where the default Docker Compose setup starts to creak.

The problem: agents collide with each other

Parallel AI development usually means parallel worktrees or checkouts on your laptop:

~/work/app-agent-a/
~/work/app-agent-b/
~/work/app-agent-c/

Each agent thinks it owns the repository. Each one tries to run the same commands:

./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate --seed
./vendor/bin/sail npm run dev
./vendor/bin/sail artisan test

If the Compose file was written for one human developer on one laptop, the agents will conflict in predictable places.

The most common failures are:

  • fixed container names such as myapp-mysql
  • fixed host ports such as 8080:80, 3306:3306, or 5173:5173
  • shared named volumes that make one agent see another agent’s database
  • shared Redis keys, mailboxes, queues, object storage, or cache state
  • non-deterministic seeders that leave the app in a different shape each run
  • test accounts created manually through the UI instead of by factories
  • agents running destructive commands against the wrong stack

The result is noisy. One agent drops the database while another is testing. One agent binds the web port first and the others fail. One queue worker processes jobs created by another checkout. The app appears broken, but the bug is really environment contention.

For AI agents, that matters because they will often treat environment failure as product failure. If the stack is ambiguous, the agent wastes time debugging Docker instead of the Laravel change it was asked to make.

The target shape

The goal is simple:

Any agent should be able to start a complete copy of the app, with isolated services and predictable data, from any checkout.

That means each running stack needs its own:

  • Compose project name
  • container namespace
  • Docker network
  • database volume
  • Redis volume or memory space
  • host ports, only where host ports are actually needed
  • Laravel .env values
  • seed data
  • cleanup command

Docker Compose already gives you most of this. You just have to stop opting out of it.

Abstract illustration of isolated agent environments running as separate local stacks

Use Compose project names as the boundary

Docker Compose prefixes containers, networks, and volumes with the project name. If you run the same Compose file with different project names, you get different stacks.

docker compose -p app-agent-a up -d
docker compose -p app-agent-b up -d

That is the basic trick.

A Compose file can make that explicit, make the first line in the file declare the project name:

name: ${COMPOSE_PROJECT_NAME:-myapp}
services:
# Now start to define your services as normal

That lets a wrapper choose a project name per agent. The same file also avoids fixed container_name values, which is the right default. Compose can then create separate containers, networks, and volumes for:

myapp-agent-login-fix-laravel.test-1
myapp-agent-login-fix-mysql-1
myapp-agent-login-fix-sailmysql

The rule is worth making explicit:

  • avoid container_name
  • avoid named volumes with a hard-coded global name:
  • avoid external networks unless they are genuinely shared infrastructure
  • keep host port bindings configurable

The moment you hard-code one of those names, every checkout starts fighting over the same Docker object.

Make ports configurable

Project names isolate Docker objects. They do not isolate host ports.

A Laravel Sail-style Compose file should expose host-facing ports as environment variables:

name: ${COMPOSE_PROJECT_NAME:-myapp}
services:
    laravel.test:
        ports:
            - "${APP_PORT:-80}:80"
            - "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
            - "${PLAYWRIGHT_PORT:-3000}:3000"
            - "${PLAYWRIGHT_ALT_PORT:-3001}:3001"

    mysql:
        ports:
            - "${FORWARD_DB_PORT:-3306}:3306"

    redis:
        ports:
            - "${FORWARD_REDIS_PORT:-6379}:6379"

    mailpit:
        ports:
            - "${FORWARD_MAILPIT_PORT:-1025}:1025"
            - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"

That is enough for one stack. For parallel agents, the missing piece is a repeatable way to assign those variables.

Give each agent a wrapper command

Do not make agents remember which flags and environment variables matter. Put the rules in a script.

One useful pattern is to create ignored env files from a known local template:

scripts/create-agent-env login-fix

That writes:

.env.agent.login-fix
.env.testing.agent.login-fix

The script derives a safe slug, calculates a port offset, and writes values such as:

COMPOSE_PROJECT_NAME=myapp-agent-login-fix
SAIL_ENV_FILE=.env.agent.login-fix

APP_PORT=18879
VITE_PORT=15972
PLAYWRIGHT_PORT=13799
PLAYWRIGHT_ALT_PORT=14799
FORWARD_DB_PORT=14105
FORWARD_REDIS_PORT=17178
FORWARD_MAILPIT_PORT=11824
FORWARD_MAILPIT_DASHBOARD_PORT=18824

DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=web
DB_USERNAME=sail
DB_PASSWORD=password

TEST_DB_HOST=mysql.testing
TEST_DB_DATABASE=testing
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=password

The exact numbers will differ by agent name. The useful part is that they are deterministic.

Here is a complete scripts/create-agent-env implementation:

#!/usr/bin/env bash

set -euo pipefail

agent_name="${1:-}"
port_offset="${2:-}"
app_name="${APP_COMPOSE_NAME:-myapp}"
base_env_file="${AGENT_BASE_ENV_FILE:-.env.testing}"

if [[ -z "$agent_name" ]]; then
    echo "Usage: scripts/create-agent-env <agent-name> [port-offset]" >&2
    exit 1
fi

if [[ ! -f "$base_env_file" ]]; then
    echo "Missing ${base_env_file}; create it before seeding an agent environment." >&2
    exit 1
fi

slug="$(
    printf "%s" "$agent_name" \
        | tr "[:upper:]" "[:lower:]" \
        | tr -cs "a-z0-9" "-" \
        | sed "s/^-//; s/-$//"
)"

if [[ -z "$slug" ]]; then
    echo "Agent name must contain at least one letter or number." >&2
    exit 1
fi

if [[ -z "$port_offset" ]]; then
    port_offset="$(printf "%s" "$slug" | cksum | awk "{ print \$1 % 1000 }")"
elif ! [[ "$port_offset" =~ ^[0-9]+$ ]]; then
    echo "Port offset must be numeric." >&2
    exit 1
fi

env_file=".env.agent.${slug}"
testing_env_file=".env.testing.agent.${slug}"

filter_env() {
    awk -F= '
        BEGIN {
            skip["APP_ENV"] = 1
            skip["APP_URL"] = 1
            skip["COMPOSE_PROJECT_NAME"] = 1
            skip["SAIL_ENV_FILE"] = 1

            skip["APP_PORT"] = 1
            skip["VITE_PORT"] = 1
            skip["PLAYWRIGHT_PORT"] = 1
            skip["PLAYWRIGHT_ALT_PORT"] = 1
            skip["FORWARD_DB_PORT"] = 1
            skip["FORWARD_REDIS_PORT"] = 1
            skip["FORWARD_MAILPIT_PORT"] = 1
            skip["FORWARD_MAILPIT_DASHBOARD_PORT"] = 1

            skip["DB_CONNECTION"] = 1
            skip["DB_HOST"] = 1
            skip["DB_PORT"] = 1
            skip["DB_DATABASE"] = 1
            skip["DB_USERNAME"] = 1
            skip["DB_PASSWORD"] = 1

            skip["TEST_DB_HOST"] = 1
            skip["TEST_DB_DATABASE"] = 1
            skip["TEST_DB_USERNAME"] = 1
            skip["TEST_DB_PASSWORD"] = 1

            skip["REDIS_HOST"] = 1
            skip["REDIS_PASSWORD"] = 1
            skip["REDIS_PORT"] = 1
            skip["MAIL_HOST"] = 1
            skip["MAIL_PORT"] = 1
            skip["WWWUSER"] = 1
            skip["WWWGROUP"] = 1
        }
        /^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next }
        {
            key = $1
            sub(/^[[:space:]]*/, "", key)
            sub(/[[:space:]]*$/, "", key)
            if (!(key in skip)) {
                print
            }
        }
    ' "$base_env_file"
}

app_port=$((18080 + port_offset))
vite_port=$((15173 + port_offset))
playwright_port=$((13000 + port_offset))
playwright_alt_port=$((14000 + port_offset))
db_port=$((13306 + port_offset))
redis_port=$((16379 + port_offset))
mailpit_port=$((11025 + port_offset))
mailpit_dashboard_port=$((18025 + port_offset))
wwwuser="$(id -u)"
wwwgroup="$(id -g)"

{
    filter_env
    cat <<ENV

APP_ENV=local
APP_URL=http://localhost:${app_port}

COMPOSE_PROJECT_NAME=${app_name}-agent-${slug}
SAIL_ENV_FILE=${env_file}
WWWUSER=${wwwuser}
WWWGROUP=${wwwgroup}

APP_PORT=${app_port}
VITE_PORT=${vite_port}
PLAYWRIGHT_PORT=${playwright_port}
PLAYWRIGHT_ALT_PORT=${playwright_alt_port}
FORWARD_DB_PORT=${db_port}
FORWARD_REDIS_PORT=${redis_port}
FORWARD_MAILPIT_PORT=${mailpit_port}
FORWARD_MAILPIT_DASHBOARD_PORT=${mailpit_dashboard_port}

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=web
DB_USERNAME=sail
DB_PASSWORD=password

REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_HOST=mailpit
MAIL_PORT=1025

TEST_DB_HOST=mysql.testing
TEST_DB_DATABASE=testing
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=password
ENV
} > "$env_file"

{
    filter_env
    cat <<ENV

APP_ENV=testing
APP_URL=http://localhost:${app_port}

DB_CONNECTION=mysql
DB_HOST=mysql.testing
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=root
DB_PASSWORD=password

REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_HOST=mailpit
MAIL_PORT=1025
ENV
} > "$testing_env_file"

cat <<MSG
Created ${env_file}
Created ${testing_env_file}

Start this agent stack with:
  scripts/sail-agent ${slug} up -d

Run tests inside this agent stack with:
  scripts/sail-agent ${slug} test
MSG

Make it executable:

chmod +x scripts/create-agent-env

Then add scripts/sail-agent, which runs commands against the chosen stack:

#!/usr/bin/env bash

set -euo pipefail

agent_name="${1:-}"

if [[ -z "$agent_name" ]]; then
    echo "Usage: scripts/sail-agent <agent-name|env-file> <command> [args...]" >&2
    echo "Commands: up, down, stop, restart, ps, logs, config, exec, artisan, composer, npm, php, node, npx, test, url, env" >&2
    exit 1
fi

shift

slugify() {
    printf "%s" "$1" \
        | tr "[:upper:]" "[:lower:]" \
        | tr -cs "a-z0-9" "-" \
        | sed "s/^-//; s/-$//"
}

if [[ "$agent_name" == .env.agent.* || "$agent_name" == */.env.agent.* ]]; then
    env_file="$agent_name"
    slug="${agent_name##*.env.agent.}"
else
    slug="$(slugify "$agent_name")"
    env_file=".env.agent.${slug}"
fi

if [[ -z "$slug" ]]; then
    echo "Agent name must contain at least one letter or number." >&2
    exit 1
fi

if [[ ! -f "$env_file" ]]; then
    echo "Missing ${env_file}; create it with: scripts/create-agent-env ${slug}" >&2
    exit 1
fi

command="${1:-ps}"

if [[ $# -gt 0 ]]; then
    shift
fi

read_env_value() {
    local key="$1"

    awk -F= -v key="$key" '
        $1 == key {
            value = substr($0, length(key) + 2)
            gsub(/^"|"$/, "", value)
            print value
            exit
        }
    ' "$env_file"
}

compose() {
    local compose_env=()
    local key
    local value
    local keys=(
        APP_PORT
        COMPOSE_PROJECT_NAME
        DB_DATABASE
        DB_PASSWORD
        DB_USERNAME
        FORWARD_DB_PORT
        FORWARD_MAILPIT_DASHBOARD_PORT
        FORWARD_MAILPIT_PORT
        FORWARD_REDIS_PORT
        PLAYWRIGHT_ALT_PORT
        PLAYWRIGHT_PORT
        SAIL_ENV_FILE
        TEST_DB_DATABASE
        TEST_DB_PASSWORD
        VITE_PORT
        WWWGROUP
        WWWUSER
    )

    for key in "${keys[@]}"; do
        value="$(read_env_value "$key")"

        if [[ -n "$value" ]]; then
            compose_env+=("${key}=${value}")
        fi
    done

    env "${compose_env[@]}" docker compose --env-file "$env_file" "$@"
}

run_tests() {
    local lock_dir="${TEST_LOCK_DIR:-.agent-test.lock}"

    while ! mkdir "$lock_dir" 2>/dev/null; do
        sleep 1
    done

    cleanup() {
        rmdir "$lock_dir" 2>/dev/null || true
    }

    trap cleanup EXIT INT TERM

    compose exec -T laravel.test php artisan test --compact "$@"
}

case "$command" in
    up | down | stop | restart | ps | logs | config | exec)
        compose "$command" "$@"
        ;;
    artisan | composer | npm | php | node | npx)
        compose exec -T laravel.test "$command" "$@"
        ;;
    test)
        run_tests "$@"
        ;;
    url)
        read_env_value APP_URL
        ;;
    env)
        printf "%s\n" "$env_file"
        ;;
    *)
        echo "Unknown command: ${command}" >&2
        echo "Commands: up, down, stop, restart, ps, logs, config, exec, artisan, composer, npm, php, node, npx, test, url, env" >&2
        exit 1
        ;;
esac

Make that executable too:

chmod +x scripts/sail-agent

Now the normal usage is:

scripts/sail-agent login-fix up -d
scripts/sail-agent login-fix artisan migrate --seed
scripts/sail-agent login-fix npm run build
scripts/sail-agent login-fix url

This is better than asking the agent to use raw Sail commands because the wrapper always supplies the right env file and project variables.

Keep Laravel environment values agent-safe

The important detail in the generated env files is that container-to-container traffic stays inside the Compose network:

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=web
DB_USERNAME=sail
DB_PASSWORD=password

REDIS_HOST=redis
REDIS_PORT=6379

MAIL_HOST=mailpit
MAIL_PORT=1025

Do not put host ports into Laravel service-to-service configuration. Inside Compose, the database host is mysql, not 127.0.0.1. Redis is redis, not the host machine. Mailpit SMTP is mailpit:1025, not localhost:1025.

That keeps each stack internal to its own Docker network.

The same principle applies to integrations. A local agent stack should not be able to send real customer emails, charge real payment methods, sync a real marketing audience, or spend production AI credentials while debugging a feature.

Abstract illustration of deterministic seed data growing from repeatable application factories

Data factories are part of the environment

The Compose stack gets the processes running. It does not make the app usable.

For AI agents, seed data is not a nice extra. It is infrastructure. The agent needs to reproduce user states without clicking around the app for ten minutes or borrowing stale data from another developer’s database.

For a typical product application, useful scenarios might include:

  • an organisation or account with no content
  • a draft record
  • a published record with related activity
  • a record with comments and hidden comments
  • a paid feature or subscription state
  • a failed payment
  • users who accepted or rejected marketing consent
  • a queued email waiting to be sent
  • an admin user

Those should be created by Laravel factories and seeders, not by a shared SQL dump that slowly rots.

Most Laravel applications already have factories for the core objects: users, organisations, accounts, orders, content, comments, payments, subscriptions, and admin users. The missing layer is usually scenario seeders: small, named sets of data for the flows agents are likely to work on.

<?php

namespace Database\Seeders;

use App\Models\Account;
use App\Models\Comment;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Seeder;

class AgentScenarioSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            LocalReferenceDataSeeder::class,
        ]);

        $owner = User::factory()->create([
            "email" => "owner@example.test",
        ]);

        $account = Account::factory()->create([
            "name" => "Agent Test Organisation",
            "owner_user_id" => $owner->id,
        ]);

        $post = Post::factory()
            ->create([
                "title" => "Agent Verification Record",
                "slug" => "agent-verification-record",
                "account_id" => $account->id,
                "created_by_user_id" => $owner->id,
                "status" => "published",
                "published_at" => now()->subDay(),
            ]);

        Comment::factory()
            ->count(25)
            ->create([
                "post_id" => $post->id,
                "is_hidden" => false,
            ]);
    }
}

The model names are placeholders. Use the real models from your application, but keep the scenario stable enough that an agent can rely on it.

Then make the bootstrap command boring:

scripts/sail-agent login-fix artisan migrate:fresh --seed --seeder=AgentScenarioSeeder

If the app has several important states, create named scenarios:

scripts/sail-agent login-fix artisan agent:seed default
scripts/sail-agent login-fix artisan agent:seed account-with-activity
scripts/sail-agent login-fix artisan agent:seed payment-edge-cases

That gives the agent a stable target. It can say “I verified this against payment-edge-cases” instead of “I clicked around until it looked right”.

Make seeders deterministic

Factories are useful because they are fast and expressive. They can also create noise if every run generates unrelated names, dates, slugs, and emails.

For agent scenarios, prefer stable values where the UI or tests will reference them:

Post::factory()->create([
    "title" => "Agent Verification Record",
    "slug" => "agent-verification-record",
    "status" => "published",
    "published_at" => now()->subDay(),
]);

Use randomness for filler records, not for records the agent needs to find.

It is also worth adding test-friendly selectors or stable labels in the frontend. An agent can work with visible text, but deterministic text makes browser verification much less brittle.

Reset the whole stack cheaply

Agents should be allowed to destroy their own environment.

That is another reason project names matter. This command should delete only the current agent’s containers, network, and volumes:

scripts/sail-agent login-fix down -v --remove-orphans

Then a clean reset is:

scripts/sail-agent login-fix down -v --remove-orphans
scripts/sail-agent login-fix up -d --build
scripts/sail-agent login-fix artisan migrate:fresh --seed --seeder=AgentScenarioSeeder

That is much safer than asking an agent to surgically repair a half-corrupt local database.

Put the workflow in AGENTS.md

The repository instructions should tell agents how to use the stack. Keep it specific enough that the agent does not fall back to generic Docker habits.

A good AGENTS.md section should tell agents to create an isolated environment instead of editing .env:

# Parallel agent environments

When running as an agent alongside another agent or the local developer, create an
isolated Compose env from the local test template instead of editing `.env`:
`scripts/create-agent-env <agent-name>`.

Start that stack with:

```bash
scripts/sail-agent <agent-name> up -d
```

Run commands inside that stack:

```bash
scripts/sail-agent <agent-name> artisan migrate --seed
scripts/sail-agent <agent-name> test
scripts/sail-agent <agent-name> npm run build
```

That is the right kind of instruction. It names the exact scripts, explains why they exist, and tells agents not to run raw commands that would collide with another stack.

If your tests mutate a shared test database inside the checkout, make the test wrapper serialize test runs with a small lock directory. That avoids overlapping migrations against the same test database.

Abstract illustration of an agent verification loop passing through app, database, queue, and browser checks

What to verify

For a Laravel multi-container app, a useful agent verification pass usually looks like this:

scripts/create-agent-env login-fix
scripts/sail-agent login-fix up -d
scripts/sail-agent login-fix ps
scripts/sail-agent login-fix artisan migrate:fresh --seed --seeder=AgentScenarioSeeder
scripts/sail-agent login-fix test
scripts/sail-agent login-fix artisan queue:work --once --tries=1
scripts/sail-agent login-fix logs --tail=100 laravel.test queue mysql redis mailpit

If the task affects the browser UI, add a browser check against that agent’s APP_PORT.

If the task affects email, check Mailpit for that agent’s MAILPIT_PORT.

If the task affects jobs, run at least one queue pass and inspect failed jobs:

scripts/sail-agent login-fix artisan queue:work --once --tries=1
scripts/sail-agent login-fix artisan queue:failed

The point is to verify the full path the feature depends on, not just the PHP unit that was easy to run.

What to tighten

Once the Compose isolation is working, the remaining improvements are usually small:

  • add first-class scenario seeders for agent verification, not just broad development seeders
  • keep deterministic records for UI flows, especially slugs, user emails, and fixture identifiers
  • make external services fail closed locally unless a sandbox key is explicitly provided
  • print the app, Vite, Mailpit, and Playwright URLs after scripts/sail-agent <name> up -d
  • document which scenarios to use for common task classes, such as signup, payment, email, admin, and reporting

Those are small changes, but they reduce the amount of inference an agent has to do.

The same pattern works outside Laravel

This article uses a Laravel app as the example because Laravel applications often have queues, mail, caches, browser tests, and database-backed feature tests. The pattern is not Laravel-specific.

Any multi-container app has the same isolation problem:

  • Rails with Sidekiq, PostgreSQL, Redis, and Mailcatcher
  • Django with Celery, PostgreSQL, Redis, and local S3
  • Node with a worker, PostgreSQL, Redis, and Playwright
  • Go services with Kafka, PostgreSQL, and MinIO

The details change, but the rules stay the same:

  • isolate each agent with a Compose project name
  • avoid global Docker names
  • bind host ports only when humans or browsers need them
  • generate deterministic seed data through the app’s own factory layer
  • make reset cheap
  • document the exact commands agents should run

Once that is in place, parallel AI agents become much less dramatic. They can each have a disposable copy of the system, with their own database, queue, cache, mail inbox, and browser port.

That is the difference between “the agent can edit code” and “the agent can safely work on a real application”.

all posts →