How to Let Docker Containers Control Other Containers (Without Docker-in-Docker)

Learn how to enable Docker containers to manage other containers by mounting the Docker socket, avoiding the complexity of Docker-in-Docker setups. This tutorial provides a complete working example and discusses security considerations.

How to Let Docker Containers Control Other Containers (Without Docker-in-Docker)

Ever needed one container to manage, inspect, or execute commands in other containers? Maybe you’re building an admin dashboard, a CI/CD tool, or a backup system that needs to interact with your database container.

The naive approach is Docker-in-Docker (DinD) - running a full Docker daemon inside a container. But there’s a simpler, more efficient solution: Docker socket mounting.

In this post, I’ll show you exactly how it works with a complete working example you can run on your machine.

The Problem

Imagine you have a web-based admin tool (let’s call it “Toolbox”) that needs to:

  • Run database backups: docker exec postgres pg_dump ...
  • Check container health: docker inspect postgres
  • View logs: docker logs api-server

Your Toolbox runs in its own container. How does it execute these Docker commands?

Two Approaches

Approach 1: Docker-in-Docker (DinD)

Run a complete Docker daemon inside your container:

┌─────────────────────────────────────────────┐
│              Host Machine                   │
│                                             │
│  Docker Daemon                              │
│       │                                     │
│       ▼                                     │
│  ┌──────────────────────────────────────┐   │
│  │  Toolbox Container                   │   │
│  │                                      │   │
│  │  Docker Daemon (nested!)             │   │
│  │       │                              │   │
│  │       ▼                              │   │
│  │   ┌──────────┐  ┌──────────┐         │   │
│  │   │ Postgres │  │   API    │         │   │
│  │   │(nested)  │  │(nested)  │         │   │
│  │   └──────────┘  └──────────┘         │   │
│  └── ───────────────────────────────────┘   │
└─────────────────────────────────────────────┘

Problems:

  • Resource overhead (two Docker daemons)
  • Complex storage management
  • Security implications of --privileged mode
  • Your other containers (Postgres, API) must run inside the nested Docker

Share the host’s Docker socket with your container:

  ┌────────────────────────────────────────────────────────────────────────┐
  │                              Host Machine                              │
  │                                                                        │
  │    Docker Daemon ◄──────────────────────────────────────────────┐      │
  │         │                                                       │      │
  │         │ manages all containers                                │      │
  │         ▼                                                       │      │
  │    ┌──────────┐  ┌──────────┐  ┌───────────────────────────┐    │      │
  │    │ Postgres │  │   API    │  │    Toolbox Container      │    │      │
  │    │          │  │          │  │                           │    │      │
  │    │          │  │          │  │   Docker CLI installed    │    │      │
  │    │          │  │          │  │           │               │    │      │
  │    │          │  │          │  │           │ talks to      │    │      │
  │    │          │  │          │  │           ▼               │    │      │
  │    │          │  │          │  │   /var/run/docker.sock ───┼────┘      │
  │    │          │  │          │  │   (mounted from host)     │           │
  │    └──────────┘  └──────────┘  └───────────────────────────┘           │
  │         ▲                                  │                           │
  │         │                                  │                           │
  │         └─────────── can control ──────────┘                           │
  │                                                                        │
  └────────────────────────────────────────────────────────────────────────┘

Benefits:

  • Single Docker daemon (no overhead)
  • Toolbox controls sibling containers, not nested ones
  • Simple volume mount - no privileged mode
  • All containers are peers on the same network

How Docker Socket Mounting Works

The Docker CLI doesn’t contain the Docker engine - it’s just a client that sends commands to the Docker daemon via a Unix socket at /var/run/docker.sock.

When you mount this socket into a container:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

The container’s Docker CLI talks to the host’s Docker daemon. Commands like docker ps show the host’s containers, and docker exec postgres ... executes in sibling containers.

Complete Working Example

Let’s build a minimal example: a “controller” container that can execute commands in a “worker” container.

Project Structure

docker-socket-demo/
├── Makefile
├── docker-compose.yml
├── controller/
│   ├── Dockerfile
│   └── app.sh
└── worker/
    └── Dockerfile

Step 1: Create the Directory Structure

mkdir -p docker-socket-demo/controller docker-socket-demo/worker
cd docker-socket-demo

Step 2: Create the Worker Container

This is a simple container that just runs and waits. The controller will execute commands inside it.

worker/Dockerfile:

FROM alpine:3.19

# Install some tools the controller might want to use
RUN apk add --no-cache bash curl

# Keep container running
CMD ["tail", "-f", "/dev/null"]

Step 3: Create the Controller Container

This container has Docker CLI installed and will control the worker.

controller/Dockerfile:

FROM alpine:3.19

# Install Docker CLI (not the daemon, just the client)
RUN apk add --no-cache docker-cli bash

# Copy our demo script
COPY app.sh /app.sh
RUN chmod +x /app.sh

CMD ["/app.sh"]

controller/app.sh:

#!/bin/bash

echo "========================================"
echo "  Docker Socket Mounting Demo"
echo "========================================"
echo ""

# Wait for worker to be ready
sleep 2

echo "1. Listing all containers (from inside the controller container):"
echo "----------------------------------------------------------------"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
echo ""

echo "2. Executing a command in the worker container:"
echo "------------------------------------------------"
docker exec worker hostname
echo ""

echo "3. Creating a file in the worker container:"
echo "--------------------------------------------"
docker exec worker sh -c 'echo "Hello from controller!" > /tmp/message.txt'
docker exec worker cat /tmp/message.txt
echo ""

echo "4. Getting worker container's environment:"
echo "-------------------------------------------"
docker exec worker env | head -5
echo ""

echo "5. Running a multi-step operation in worker:"
echo "---------------------------------------------"
docker exec worker sh -c '
    echo "Step 1: Current directory is $(pwd)"
    echo "Step 2: Creating test data..."
    echo "test data $(date)" > /tmp/test.txt
    echo "Step 3: Verifying..."
    cat /tmp/test.txt
'
echo ""

echo "========================================"
echo "  Demo Complete!"
echo "========================================"
echo ""
echo "Key insight: The controller container executed commands"
echo "in a SIBLING container (worker), not a nested one."
echo ""
echo "This works because /var/run/docker.sock is mounted,"
echo "allowing the controller to talk to the host's Docker daemon."

# Keep container running for inspection
tail -f /dev/null

Step 4: Create docker-compose.yml

docker-compose.yml:

services:
  # The worker container - a simple container we want to control
  worker:
    build: ./worker
    container_name: worker

  # The controller container - has Docker CLI and can control worker
  controller:
    build: ./controller
    container_name: controller
    depends_on:
      - worker
    volumes:
      # THIS IS THE KEY: Mount the Docker socket
      - /var/run/docker.sock:/var/run/docker.sock

Step 5: Create the Makefile

Makefile:

.PHONY: build run logs clean demo shell-controller shell-worker help

# Default target
help:
	@echo "Docker Socket Mounting Demo"
	@echo ""
	@echo "Usage:"
	@echo "  make demo    - Build and run the complete demo"
	@echo "  make build   - Build the containers"
	@echo "  make run     - Start the containers"
	@echo "  make logs    - View controller logs (shows demo output)"
	@echo "  make clean   - Stop and remove containers"
	@echo ""
	@echo "Interactive:"
	@echo "  make shell-controller - Open shell in controller container"
	@echo "  make shell-worker     - Open shell in worker container"

# Build containers
build:
	docker-compose build

# Run containers in background
run:
	docker-compose up -d

# Show demo output
logs:
	@echo "Waiting for demo to complete..."
	@sleep 3
	docker-compose logs controller

# Complete demo: build, run, show output
demo: clean build run logs
	@echo ""
	@echo "-------------------------------------------"
	@echo "Demo is running. Try these commands:"
	@echo ""
	@echo "  make shell-controller  # Open shell in controller"
	@echo "  make shell-worker      # Open shell in worker"
	@echo "  make clean             # Stop everything"
	@echo "-------------------------------------------"

# Interactive shell in controller (try running docker commands!)
shell-controller:
	docker exec -it controller /bin/bash

# Interactive shell in worker
shell-worker:
	docker exec -it worker /bin/bash

# Clean up
clean:
	docker-compose down --remove-orphans 2>/dev/null || true

Step 6: Run the Demo

make demo

Expected Output:

========================================
  Docker Socket Mounting Demo
========================================

1. Listing all containers (from inside the controller container):
----------------------------------------------------------------
NAMES        STATUS          IMAGE
controller   Up 2 seconds    docker-socket-demo-controller
worker       Up 3 seconds    docker-socket-demo-worker

2. Executing a command in the worker container:
------------------------------------------------
worker

3. Creating a file in the worker container:
--------------------------------------------
Hello from controller!

4. Getting worker container's environment:
-------------------------------------------
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=worker
HOME=/root

5. Running a multi-step operation in worker:
---------------------------------------------
Step 1: Current directory is /
Step 2: Creating test data...
Step 3: Verifying...
test data Sat Nov 29 15:45:23 UTC 2025

========================================
  Demo Complete!
========================================

Key insight: The controller container executed commands
in a SIBLING container (worker), not a nested one.

This works because /var/run/docker.sock is mounted,
allowing the controller to talk to the host's Docker daemon.

Step 7: Explore Interactively

Open a shell in the controller and try Docker commands:

make shell-controller

Inside the controller container:

# List containers - you'll see both controller and worker
docker ps

# Execute commands in worker
docker exec worker ls -la /tmp

# Inspect the worker
docker inspect worker | head -20

# View worker logs
docker logs worker

Real-World Use Cases

1. Admin Dashboard (Like Our Toolbox)

# In your Flask/FastAPI app running in a container
import subprocess

@app.post("/backup")
def create_backup():
    result = subprocess.run([
        "docker", "exec", "postgres",
        "pg_dump", "-U", "postgres", "mydb"
    ], capture_output=True)
    return {"backup": result.stdout}

2. CI/CD Runner

# GitLab Runner configuration
volumes:
  - /var/run/docker.sock:/var/run/docker.sock

3. Container Monitoring

// Node.js monitoring service
const { exec } = require('child_process');

function getContainerStats() {
  exec('docker stats --no-stream --format "{{json .}}"', (err, stdout) => {
    const stats = stdout.split('\n').map(JSON.parse);
    broadcastToClients(stats);
  });
}

Security Considerations

Mounting the Docker socket gives a container significant power over the host. Be aware:

  1. Container Escape: A container with Docker socket access can potentially escape to the host by creating a privileged container.

  2. Mitigation Strategies:

    • Only mount the socket in trusted containers
    • Use read-only mount where possible: /var/run/docker.sock:/var/run/docker.sock:ro
    • Consider Docker socket proxies like Tecnativa/docker-socket-proxy for fine-grained access control
    • Run the container as non-root user
  3. Production Checklist:

    # More secure configuration
    services:
      controller:
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock:ro
        user: '1000:1000' # Non-root user
        read_only: true # Read-only filesystem
        security_opt:
          - no-new-privileges:true

Summary

ApproachUse CaseComplexityOverhead
Docker Socket MountControl sibling containersLowNone
Docker-in-DockerIsolated Docker environmentsHighSignificant
SSH to HostLegacy systemsMediumNetwork overhead

Docker socket mounting is the right choice when you need one container to manage others without the complexity of nested Docker environments. It’s used by CI/CD tools, admin dashboards, monitoring systems, and anywhere containers need to orchestrate other containers.

Quick Reference

Minimal docker-compose.yml:

services:
  controller:
    image: your-controller-image
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Required in Dockerfile:

RUN apk add --no-cache docker-cli  # Alpine
# or
RUN apt-get install -y docker.io   # Debian/Ubuntu

Test it works:

docker exec controller docker ps

About the Author

Ashish Anand

Ashish Anand

Founder & Lead Developer

Full-stack developer with 10+ years experience in Python, JavaScript, and DevOps. Creator of DevGuide.dev. Previously worked at Microsoft. Specializes in developer tools and automation.