Dockerized Agent Gateway + Agent Platform Stack

Agentic AI Systems intermediate Building with AI ~25 min read

Overview

Containerizing a multi-service AI agent stack — the agent gateway, the agent-platform MCP server, agent workspaces, and the Discord bridge — with Docker Compose transforms a fragile “works on my machine” setup into a reproducible, one-command deployment. The docker-compose.yml orchestrates these services with shared network namespaces so containers can communicate over localhost-equivalent addresses, read-only volume mounts for agent workspaces and the agent-platform node_modules tree, exposed gateway and bridge ports for external access, and health checks that verify the Node.js process can reach both the Discord API and the Anthropic API before the stack is considered ready.

The design reflects a core tension in AI agent infrastructure: agents need access to local filesystem resources (workspace directories, configuration files, model artifacts) while also requiring network access to external APIs (Anthropic, Discord, third-party MCP tool backends). Docker Compose resolves this by treating volumes as the bridge between host state and container runtime — workspace directories are bind-mounted so agents can read and write files that persist across container restarts, while the node_modules directory is mounted read-only to prevent agents from modifying their own dependency tree at runtime.

Health checks are particularly critical for an agent stack because a container that is “running” is not necessarily “functional.” The Node.js process may have started but failed to authenticate with Discord or Anthropic due to missing or expired credentials. The health check script makes lightweight API calls to both services and returns a non-zero exit code if either fails, causing Docker to mark the container as unhealthy and preventing dependent services from starting against a broken backend.

Core Concepts

Shared Network Namespaces

By placing services in the same Docker network (or using network_mode: "service:<name>"), containers can reach each other over localhost as if they were processes on the same host. This eliminates the need for service discovery or DNS-based routing between the agent gateway and the agent-platform MCP server — the gateway simply calls http://localhost:3100 regardless of whether it’s running in Docker or directly on the host. This also simplifies migration from a non-containerized setup, since all internal URLs remain unchanged.

Read-Only Volume Mounts for Immutable Dependencies

Mounting node_modules and other dependency directories as read-only (:ro) prevents the running application — or any agent tool call — from modifying its own dependencies at runtime. This protects against accidental npm install commands issued by an agent, supply chain attacks that attempt to write to node_modules, and subtle bugs caused by runtime dependency mutations. If a dependency update is needed, it must happen in the build phase (Dockerfile), not at runtime, ensuring that every container instance runs the exact same code.

Bind Mounts for Agent Workspaces

Agent workspace directories are bind-mounted from the host filesystem into the container, making files created by agents persist across container restarts and accessible to the host for inspection. Unlike Docker volumes (which are managed by Docker and opaque to the host), bind mounts provide a direct filesystem path that operators can browse, back up, or feed into other tools. The trade-off is that bind mounts are host-path-dependent — the path must exist on the host and have correct permissions before the container starts.

Health Checks with External API Verification

A container health check is a command that Docker runs periodically to determine whether the service inside the container is functioning correctly. For AI agent services, “functioning” means more than “process is alive” — it means the service can authenticate with its external dependencies (Anthropic API, Discord API). The health check makes a lightweight API call to each dependency and returns success only if both respond. Docker uses this status to control service startup order via depends_on: condition: service_healthy.

Port Exposure Strategy for Gateway & Bridge

Only the ports that external clients need to reach are exposed (ports: directive): the agent gateway port (for MCP clients and the proxy) and the Discord bridge port (for webhook callbacks). Internal-only ports like the agent-platform MCP server’s port remain unexposed — they are reachable within the Docker network but invisible to the host’s network interfaces. This minimizes the attack surface and prevents accidental external access to internal services.

How It Works

Docker Compose Service Topology

The docker-compose.yml defines three primary services: agent-platform (the MCP server), agent-gateway (the gateway and agent runtime), and discord-bridge (the Discord bot and delivery layer). All three share a custom Docker network with a bridge driver. The agent-gateway service depends on agent-platform with a health condition, ensuring the MCP server is verified-ready before the gateway starts accepting requests. The discord-bridge depends on agent-gateway similarly, creating a startup chain: Agent Platform → Agent Gateway → Discord Bridge.

Each service’s Dockerfile follows a multi-stage build pattern: a builder stage installs dependencies and compiles TypeScript, then a runner stage copies only the compiled output and production node_modules, reducing the final image size and excluding dev dependencies, TypeScript source, and build tools from the runtime container.

Volume Mount Architecture

Workspace directories are mounted from the host’s ./workspaces/ directory into each container at /app/workspaces/. The agent-gateway service has read-write access to all workspace subdirectories (since agents need to write files), while the agent-platform service only needs read access to workspace data. The node_modules directory is mounted as a named volume populated during the build stage, then re-mounted read-only at runtime to prevent mutation.

Configuration files (gateway.json, .env) are bind-mounted individually rather than mounting the entire project directory. This limits what the container can access on the host filesystem and makes the mount contract explicit — if a new config file is needed, it must be added to the compose file, not silently picked up from a broad mount.

Health Check Implementation

Each service includes a healthcheck block in the compose file. The agent-platform MCP server’s health check calls its /health endpoint with curl. The agent gateway’s health check is a custom Node.js script that attempts a lightweight Anthropic API call (e.g., counting tokens for a short string) and a Discord API call (e.g., fetching the bot’s own profile). If either call fails or times out within 5 seconds, the script exits with code 1, marking the container unhealthy. Docker retries the health check on a configurable interval (e.g., every 30 seconds) with a configurable number of retries before declaring the container permanently unhealthy.

Implementation Patterns

Docker Compose Configuration

# docker-compose.yml
version: "3.9"

networks:
  agent-mesh:
    driver: bridge

volumes:
  agent-platform-node-modules:

services:
  agent-platform:
    build:
      context: ./agent-platform-mcp
      dockerfile: Dockerfile
    networks:
      - agent-mesh
    volumes:
      - agent-platform-node-modules:/app/node_modules:ro
      - ./workspaces:/app/workspaces:ro
    environment:
      - NODE_ENV=production
      - MCP_PORT=3100
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:3100/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    restart: unless-stopped

  agent-gateway:
    build:
      context: ./agent-gateway
      dockerfile: Dockerfile
    networks:
      - agent-mesh
    ports:
      - "8080:8080"   # Gateway port exposed to host
    volumes:
      - ./workspaces:/app/workspaces:rw
      - ./gateway.json:/app/gateway.json:ro
    environment:
      - NODE_ENV=production
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - MCP_SERVER_URL=http://agent-platform:3100
      - GATEWAY_PORT=8080
    depends_on:
      agent-platform:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "/app/scripts/healthcheck.js"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
    restart: unless-stopped

  discord-bridge:
    build:
      context: ./discord-bridge
      dockerfile: Dockerfile
    networks:
      - agent-mesh
    ports:
      - "9090:9090"   # Webhook callback port
    volumes:
      - ./workspaces:/app/workspaces:ro
    environment:
      - NODE_ENV=production
      - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
      - GATEWAY_URL=http://agent-gateway:8080
    depends_on:
      agent-gateway:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "/app/scripts/healthcheck.js"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 25s
    restart: unless-stopped

Health Check Script with External API Verification

#!/usr/bin/env node
// scripts/healthcheck.js — verify both Anthropic and Discord connectivity

const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
const TIMEOUT_MS = 5000;

async function checkWithTimeout(url, options, label) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);

  try {
    const res = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    clearTimeout(timer);

    if (!res.ok) {
      console.error(`[health] ${label}: HTTP ${res.status}`);
      return false;
    }
    console.log(`[health] ${label}: OK`);
    return true;
  } catch (err) {
    clearTimeout(timer);
    console.error(`[health] ${label}: ${err.message}`);
    return false;
  }
}

async function main() {
  const checks = [];

  // Check Anthropic API — lightweight token count request
  if (ANTHROPIC_API_KEY) {
    checks.push(
      checkWithTimeout(
        'https://api.anthropic.com/v1/messages/count_tokens',
        {
          method: 'POST',
          headers: {
            'x-api-key': ANTHROPIC_API_KEY,
            'anthropic-version': '2023-06-01',
            'content-type': 'application/json',
          },
          body: JSON.stringify({
            model: 'claude-haiku-4-5-20251001',
            messages: [{ role: 'user', content: 'health check' }],
          }),
        },
        'Anthropic API'
      )
    );
  }

  // Check Discord API — fetch bot user profile
  if (DISCORD_BOT_TOKEN) {
    checks.push(
      checkWithTimeout(
        'https://discord.com/api/v10/users/@me',
        {
          headers: {
            Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
          },
        },
        'Discord API'
      )
    );
  }

  // Check local MCP server
  checks.push(
    checkWithTimeout(
      `http://localhost:${process.env.MCP_PORT || 3100}/health`,
      {},
      'Agent Platform MCP'
    )
  );

  const results = await Promise.all(checks);
  const allHealthy = results.every(Boolean);

  if (allHealthy) {
    console.log('[health] All checks passed');
    process.exit(0);
  } else {
    console.error('[health] One or more checks failed');
    process.exit(1);
  }
}

main().catch((err) => {
  console.error(`[health] Unexpected error: ${err.message}`);
  process.exit(1);
});

Practical Applications

This Docker Compose setup enables a single docker compose up -d command to bring the entire agent mesh online on any machine with Docker installed. A new team member cloning the repository can have the full stack — MCP server, agent gateway, Discord bridge — running in under two minutes, compared to the previous setup process that required installing specific Node.js versions, running multiple npm install commands, configuring local ports, and starting services in the correct order. The compose file encodes all of this knowledge declaratively.

In production, the same compose file runs on a dedicated server with environment-specific .env files for API keys and configuration. The health check chain ensures that if the Anthropic API key expires or is revoked, the gateway container is marked unhealthy within 30 seconds, and monitoring tools (e.g., Datadog, Prometheus with cAdvisor) pick up the health state change and alert the operations team. Before health checks were added, expired keys would cause silent failures — agent turns would fail with 401 errors, but the container would remain “running” and scheduled cron jobs would silently produce no output.

The read-only node_modules mount proved its value during a debugging session where an agent’s tool call accidentally triggered an npm install command via a shell tool. In the non-containerized setup, this would have mutated the dependency tree and potentially introduced version conflicts. With the read-only mount, the command failed harmlessly with an EROFS error, the agent received the error as a tool result, and adjusted its approach — demonstrating exactly the kind of defense-in-depth that containerization provides for agent systems.

Common Pitfalls

Further Reading