Section 05

Kali in Docker — Method 2 (Docker Compose)

The same lab as Method 1, automated with two files: a docker-compose.yml and a setup.sh entrypoint script that installs the toolset and configures the MCP service on first boot. Once that initial run finishes, the container can be fully managed from Docker Desktop with one click.

For sponsors: what this means in one paragraph

Section 04 built the audit environment by hand; this section captures that build in two small text files so the whole thing can be rebuilt from scratch in minutes. The governance benefit is real: the lab is now disposable, which means if anything goes wrong — a misconfiguration, a suspected compromise of the toolset — the cost of starting clean is a coffee break, not an afternoon. The files are also portable, so another engineer (or an auditor reviewing past work) can re-create exactly the same environment a previous engagement used. This is what "reproducible" means in practice, and it is the property that makes the workflow auditable in the regulatory sense of the word.

When to choose Method 2

Use Method 2 if you have already worked through Method 1 once and understood what every flag does. The compose file does not do anything the manual method does not — it just collects the same commands in one place. Reading Method 1 first makes Method 2 debuggable.

Step 1 — Create a project folder

mkdir kali-mcp && cd kali-mcp
mkdir kali-mcp; cd kali-mcp
mkdir kali-mcp && cd kali-mcp

Step 2 — Create the setup.sh entrypoint

Save the following as setup.sh in the project folder. It runs once on first boot, drops a sentinel file at /etc/kali-mcp-setup-complete, then hands off to systemd. On subsequent boots the sentinel skips the setup block.

Windows: line endings will bite you

Save setup.sh with LF line endings, not CRLF. In VS Code, the line-ending indicator is bottom-right of the status bar; click it and switch to LF. CRLF line endings will cause the script to fail inside the Linux container with a confusing $'\r': command not found error.

#!/bin/bash

# Only run setup if not already done
if [ ! -f /etc/kali-mcp-setup-complete ]; then
    echo ">>> Running first-time setup..."

    # Update and install Kali tools + MCP server + sudo
    apt update
    apt install -y kali-linux-default mcp-kali-server sudo

    # Create the systemd service
    cat > /etc/systemd/system/kali-mcp-server.service << EOF
[Unit]
Description=Kali MCP Server
After=network.target

[Service]
ExecStart=/usr/bin/kali-server-mcp
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

    # Enable the service
    systemctl daemon-reload
    systemctl enable kali-mcp-server

    # Mark setup as complete so it doesn't run again
    touch /etc/kali-mcp-setup-complete
    echo ">>> First-time setup complete."
fi

# Hand off to systemd
exec /sbin/init

Step 3 — Create the docker-compose.yml

Use the variant for your host platform. The Linux version uses host networking; the Windows and macOS versions use bridge mode with a port map, because Docker Desktop does not support --network host on either platform.

services:
  kali-mcp:
    image: kalilinux/kali-rolling
    container_name: kali-mcp
    privileged: true
    network_mode: host
    restart: unless-stopped
    tmpfs:
      - /run
      - /run/lock
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
      - ./setup.sh:/setup.sh:ro
    cgroupns_mode: host
    entrypoint: ["/bin/bash", "/setup.sh"]
services:
  kali-mcp:
    image: kalilinux/kali-rolling
    container_name: kali-mcp
    privileged: true
    restart: unless-stopped
    ports:
      - "5000:5000"
    tmpfs:
      - /run
      - /run/lock
    volumes:
      - ./setup.sh:/setup.sh:ro
    entrypoint: ["/bin/bash", "/setup.sh"]

The cgroup volume mount and network_mode: host are removed; Docker Desktop handles cgroups via WSL2 and exposes the MCP port through the port map instead.

services:
  kali-mcp:
    image: kalilinux/kali-rolling
    container_name: kali-mcp
    privileged: true
    restart: unless-stopped
    ports:
      - "5000:5000"
    tmpfs:
      - /run
      - /run/lock
    volumes:
      - ./setup.sh:/setup.sh:ro
    entrypoint: ["/bin/bash", "/setup.sh"]

The cgroup volume mount and network_mode: host are removed; Docker Desktop on macOS handles cgroups inside its internal Linux VM and exposes the MCP port through the port map instead.

Step 4 — Make the script executable

chmod +x setup.sh
# No action needed. Docker handles execute permissions
# inside the Linux container automatically — provided
# the file has LF line endings (see warning above).
chmod +x setup.sh

Step 5 — Start the container

Same on all three platforms.

docker compose up -d

Docker will pull the Kali image and start the container. The setup.sh script runs inside the container and installs everything.

The first run takes a while

The initial setup installs kali-linux-default and mcp-kali-server — that is roughly a gigabyte of packages and can take anywhere from a few minutes to half an hour depending on your connection. Watch progress with docker logs -f kali-mcp and wait for >>> First-time setup complete. to appear. All subsequent startups are fast — the sentinel file at /etc/kali-mcp-setup-complete skips the install block.

Step 6 — Verify the MCP server

docker exec kali-mcp systemctl status kali-mcp-server

You should see active (running). The MCP server listens on:

PlatformAddress
Linux127.0.0.1:5000
Windowslocalhost:5000
macOSlocalhost:5000

After first-time setup — use Docker Desktop

Once the initial run has completed, the terminal is no longer required to manage the lab. From Docker Desktop you can:

  • Start the container with one click.
  • Stop it the same way.
  • Restart it after configuration changes.
  • View logs directly in the UI.

The kali-mcp-server service starts automatically every time the container boots, whether started from Docker Desktop or the CLI. The restart: unless-stopped policy means Docker will bring the container back up after a host reboot too.

Quick reference

TaskCommand (or UI)
Start the containerdocker compose up -d · or Docker Desktop
Stop the containerdocker compose down · or Docker Desktop
Watch startup logsdocker logs -f kali-mcp
Open a shell in the containerdocker exec -it kali-mcp /bin/bash
Check MCP server statusdocker exec kali-mcp systemctl status kali-mcp-server
Restart the MCP serverdocker exec kali-mcp systemctl restart kali-mcp-server
Rebuild from scratchdocker compose down && docker compose up -d

Common things that go wrong

SymptomCauseFix
$'\r': command not found in docker logs setup.sh has CRLF line endings. Re-save the file with LF endings; docker compose down && docker compose up -d.
Container restarts in a loop The setup script failed mid-install (usually a network hiccup mid-apt). docker compose down, then docker compose up -d to retry. The sentinel only writes on success.
kali-mcp-server is inactive First-time install finished but systemd has not yet started the service. docker exec kali-mcp systemctl start kali-mcp-server; then check status.
Cannot connect to localhost:5000 from VS Code (Windows/macOS) The port map is missing or the container is not running. Check ports: in the compose file; docker ps to confirm the container is up.
Same operational note as Method 1

privileged: true grants the container elevated access to the host. Treat the lab machine as a single-purpose audit workstation, not a shared development laptop.

Check yourself

Three questions on the automated setup

Compose makes the lab easy to bring up. It also hides several decisions that matter when something breaks — these three test whether you've kept track of them.

Q1 / 3

Why does setup.sh need LF line endings, even though you're editing it on a Windows host?

Because Git refuses to commit files with CRLF line endings.
No Git happily handles CRLF, and Git is not in the picture for this lab. The cause is elsewhere.
Because the script runs inside a Linux container where bash treats CRLF endings as part of the command — so fi\r isn't recognised as fi and the script breaks.
Yes The symptom is a confusing $'\r': command not found in docker logs. The fix is to save the file with LF endings before docker compose up.
Because Docker Compose rejects files with mixed line endings.
No Compose itself does not parse setup.sh; bash inside the container does. Compose just mounts the file.
Q2 / 3

What is the sentinel file at /etc/kali-mcp-setup-complete doing?

Storing the operator's credentials between sessions.
No The file is empty — it stores nothing. Its purpose is to exist, not to hold data.
Making the entrypoint script idempotent — its presence tells subsequent boots to skip the install block and hand straight off to systemd.
Yes The first run installs and writes the sentinel; every later run sees it and skips ahead. This is why subsequent starts are fast.
Acting as a lock file for the MCP server's port.
No Port locking is the kernel's job, not a sentinel file in /etc. The sentinel only controls whether setup re-runs.
Q3 / 3

What does Method 2 do that Method 1 does not?

It runs the MCP server with stronger isolation than Method 1 — Compose adds security defaults that docker run does not.
No The two methods produce equivalent containers. Compose is a packaging format; it does not add isolation that docker run with the same flags would not.
It collects the same commands in declarative form so the lab can be rebuilt from a file, and lets Docker Desktop manage the running container after the first boot — but does not change what the container is or how it's reachable.
Yes Same flags, same image, same loopback binding. Compose buys you reproducibility and a GUI, not a different security posture. Reading Method 1 first is what makes Method 2 debuggable.
It removes the need for --privileged.
No privileged: true appears in the compose file for the same reason --privileged appears in the docker run command — systemd needs it.