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.
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.
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.
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 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:
| Platform | Address |
|---|---|
| Linux | 127.0.0.1:5000 |
| Windows | localhost:5000 |
| macOS | localhost: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
| Task | Command (or UI) |
|---|---|
| Start the container | docker compose up -d · or Docker Desktop |
| Stop the container | docker compose down · or Docker Desktop |
| Watch startup logs | docker logs -f kali-mcp |
| Open a shell in the container | docker exec -it kali-mcp /bin/bash |
| Check MCP server status | docker exec kali-mcp systemctl status kali-mcp-server |
| Restart the MCP server | docker exec kali-mcp systemctl restart kali-mcp-server |
| Rebuild from scratch | docker compose down && docker compose up -d |
Common things that go wrong
| Symptom | Cause | Fix |
|---|---|---|
$'\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. |
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.
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.
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.
$'\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.
setup.sh; bash inside the container does. Compose just mounts the file.
What is the sentinel file at /etc/kali-mcp-setup-complete doing?
Storing the operator's credentials between sessions.
Making the entrypoint script idempotent — its presence tells subsequent boots to skip the install block and hand straight off to systemd.
Acting as a lock file for the MCP server's port.
/etc. The sentinel only controls whether setup re-runs.
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.
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.
It removes the need for --privileged.
privileged: true appears in the compose file for the same reason --privileged appears in the docker run command — systemd needs it.