17 min read

Docker Setup Guide: A Hollywood Blockbuster Edition

Stop googling Docker errors at 3am. Actual technical knowledge, the guide I wish I had. Includes the controversial SSH container topic.
Docker Setup Guide: A Hollywood Blockbuster Edition
Photo by Guillaume Bolduc / Unsplash

Look, I've been where you are. Staring at Docker documentation, feeling like you're trying to decipher alien technology. So let me break this down using something we all understand – movies – and then get into the real technical meat. Grab a coffee, this is going to be a proper deep dive.

What is Docker? The Matrix Has You

Remember when Neo took the red pill in The Matrix and discovered that his entire world was actually a simulation? Docker containers are similar – they're lightweight, portable environments that simulate a complete runtime environment for your application.

But here's where it gets interesting technically. Unlike what the marketing folks will tell you, Docker isn't magic. It's actually using Linux kernel features that have been around for years: namespaces and cgroups. Let me break this down properly.

How Docker Actually Works Under the Hood

Okay, movie reference done. Let's talk real tech. Docker uses:

  1. Linux Namespaces: These create isolated workspaces. Think of it like this – when you run a process in a Docker container, it thinks it's the only thing running on the system. It has its own:
    • PID namespace (process IDs start from 1)
    • Network namespace (own network stack)
    • Mount namespace (own filesystem view)
    • UTS namespace (own hostname)
    • IPC namespace (isolated inter-process communication)
    • User namespace (can have root inside container without being root on host)
  2. Control Groups (cgroups): These limit and monitor resource usage. You can say "this container gets max 2GB RAM and 50% CPU" and cgroups enforce it. No more runaway processes eating all your server resources.
  3. Union File Systems (like OverlayFS): This is the clever bit. Docker images are built in layers, and these layers are stacked on top of each other. When you change a file, Docker doesn't modify the original layer – it creates a new layer on top. This is why Docker images can share common layers and save disk space.

Docker vs Virtual Machines: The Real Difference

Everyone says "Docker is lightweight compared to VMs" but nobody explains WHY. Here's the actual difference:

Virtual Machines:

graph LR App[Your App] --> GuestOS[Guest OS] GuestOS --> Hypervisor Hypervisor --> HostOS[Host OS] HostOS --> Hardware style App fill:#7e22ce,stroke:#2e1065,stroke-width:2px,color:#ffffff style GuestOS fill:#9333ea,stroke:#2e1065,stroke-width:2px,color:#ffffff style Hypervisor fill:#a855f7,stroke:#2e1065,stroke-width:2px,color:#ffffff style HostOS fill:#6b7280,stroke:#2e1065,stroke-width:2px,color:#ffffff style Hardware fill:#374151,stroke:#2e1065,stroke-width:2px,color:#ffffff

Each VM runs a complete operating system. If you're running 5 VMs with Ubuntu, you have 5 complete copies of Ubuntu running. That's GB of RAM and disk space per VM, plus the CPU overhead of virtualizing hardware.

Docker Containers:

graph LR App[Your App] --> Docker[Docker Engine] Docker --> HostOS[Host OS] HostOS --> Hardware style App fill:#7e22ce,stroke:#2e1065,stroke-width:2px,color:#ffffff style Docker fill:#9333ea,stroke:#2e1065,stroke-width:2px,color:#ffffff style HostOS fill:#6b7280,stroke:#2e1065,stroke-width:2px,color:#ffffff style Hardware fill:#374151,stroke:#2e1065,stroke-width:2px,color:#ffffff

Containers share the host's kernel. There's no hypervisor, no hardware virtualization. When you run 5 containers, they all use the same kernel. The isolation happens at the process level, not the hardware level.

Real numbers from my production servers:

  • A basic Ubuntu VM: ~1GB RAM minimum, 4GB disk
  • A basic Ubuntu container: ~100MB RAM, 200MB disk

That's a 10x difference. When you're running dozens of services, this matters.

Is Docker Resource Draining? Let's Measure It

I see this question a lot. Here's how to actually check:

# Check Docker's own resource usage
docker system df

# See real-time stats
docker stats

# Check how much disk Docker is using
du -sh /var/lib/docker/

On my development machine right now:

  • Docker daemon: ~50MB RAM
  • Each container: Adds only 10-20MB overhead (plus whatever your app uses)
  • Disk usage: This is where it gets tricky...

The Disk Space Truth Nobody Talks About

Docker can eat disk space if you're not careful. Every time you build an image, Docker keeps the old layers. Pull a new version? Old one stays. Build failed? Those layers stay too.

After 6 months of development, I had 50GB of dead images. Here's how to clean up:

# Remove all stopped containers
docker container prune

# Remove all unused images
docker image prune -a

# Nuclear option - clean everything
docker system prune -a --volumes

# See what's eating space
docker system df -v

Pro tip: Add this to your crontab:

0 2 * * 0 docker system prune -f

Installing Docker: Your Hero's Journey Begins (With Actual Technical Details)

For Linux (Because That's Where Docker Really Shines)

Forget the package manager version – it's always outdated. Here's the proper way:

# Remove old versions (important!)
sudo apt-get remove docker docker-engine docker.io containerd runc

# Install dependencies
sudo apt-get update
sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release

# Add Docker's official GPG key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Set up the repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin

# The important bit everyone forgets - add yourself to docker group
sudo usermod -aG docker $USER
newgrp docker

# Verify without sudo
docker run hello-world

For Windows and Mac (The Painful Truth)

Docker Desktop for Windows and Mac is actually running a Linux VM. Yeah, you heard that right. All that "native" talk? Marketing.

Here's what actually happens:

  • Windows: Uses WSL2 (a lightweight Linux VM) or Hyper-V
  • Mac: Uses HyperKit (a lightweight hypervisor)

This is why Docker on Windows/Mac:

  • Uses more resources (you're running a VM!)
  • Has file sharing performance issues
  • Sometimes has networking quirks

If you're serious about Docker, develop on Linux. I switched my dev environment to Ubuntu and never looked back.

Understanding Docker Images: More Than Just Templates

An image isn't just a template – it's a stack of read-only layers. Let me show you:

# See the layers in an image
docker history nginx

# See the actual JSON config
docker inspect nginx

# See what makes up an image
docker save nginx -o nginx.tar
tar -tf nginx.tar

Each line in a Dockerfile creates a new layer. This is why Dockerfile optimization matters:

Bad Dockerfile (creates many layers):

FROM ubuntu:20.04
RUN apt-get update
RUN apt-get install -y python3
RUN apt-get install -y python3-pip
RUN apt-get install -y git
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

Good Dockerfile (fewer layers, smaller image):

FROM ubuntu:20.04
RUN apt-get update && apt-get install -y \
    python3 \
    python3-pip \
    git \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

The difference? The bad one creates 8 layers and is 800MB. The good one creates 4 layers and is 400MB.

Docker Networking: The Part Everyone Gets Wrong

Docker networking is not complicated, but the defaults are confusing. Here's what's actually happening:

# List networks
docker network ls

# You'll see:
# - bridge (default)
# - host
# - none

Bridge Network (Default)

When you run a container without specifying a network, it goes on the bridge network. Docker creates a virtual network interface and does NAT. That's why you need -p 8080:80 – you're mapping the host port to the container port through NAT.

# See the bridge details
docker network inspect bridge

# See the actual Linux bridge
ip addr show docker0

Host Network

This removes network isolation. The container uses the host's network directly. Faster, but less secure:

docker run --network host nginx
# Now nginx is directly on port 80, no -p needed

Custom Networks (What You Should Actually Use)

Default bridge network doesn't have DNS between containers. Custom networks do:

# Create a network
docker network create myapp

# Run containers on it
docker run -d --name web --network myapp nginx
docker run -d --name api --network myapp myapi

# Now 'web' can reach 'api' by name!

Real Docker Commands Explained (Not Just Listed)

docker run - What Actually Happens

When you type docker run nginx, here's the actual sequence:

  1. Docker client contacts Docker daemon
  2. Daemon checks if 'nginx' image exists locally
  3. If not, pulls from registry (Docker Hub by default)
  4. Creates a new container from the image
  5. Allocates a read-write filesystem layer
  6. Sets up network interface and IP
  7. Starts the process defined in CMD/ENTRYPOINT
# See it all happen
docker run -it --rm alpine sh -c "ps aux && ip addr && df -h"

docker exec - The Debugging Lifesaver

This runs a command in a RUNNING container. Not a new container, the same one:

# Get a shell in a running container
docker exec -it container_name bash

# Run one-off commands
docker exec container_name ps aux
docker exec container_name cat /etc/nginx/nginx.conf

# Copy files out (without volumes!)
docker exec container_name cat /path/to/file > local_file

docker logs - Understanding Output

Docker captures stdout and stderr. That's it. If your app logs to files, Docker doesn't see it:

# Follow logs in real-time
docker logs -f container_name

# Get last 100 lines
docker logs --tail 100 container_name

# Get logs since timestamp
docker logs --since 2023-01-01T00:00:00 container_name

# Pro tip: logs are stored here
sudo cat /var/lib/docker/containers/<container_id>/<container_id>-json.log

Docker Compose: Multi-Container Apps Done Right

Compose isn't just for running multiple containers. It's for defining your entire stack as code. Real example from production:

services:
  web:
    build: 
      context: .
      dockerfile: Dockerfile.prod
    image: myapp:${VERSION:-latest}
    ports:
      - "${PORT:-8080}:8080"
    environment:
      - DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    networks:
      - backend
      - frontend

  db:
    image: postgres:13-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=myapp
    restart: unless-stopped
    networks:
      - backend

  cache:
    image: redis:6-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    restart: unless-stopped
    networks:
      - backend

  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./static:/usr/share/nginx/html:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - web
    restart: unless-stopped
    networks:
      - frontend

networks:
  frontend:
  backend:

volumes:
  postgres_data:

This is production-ready. It has:

  • Health checks
  • Restart policies
  • Network isolation
  • Environment variables
  • Volume persistence
  • Proper dependencies

The Pitfalls Nobody Warns You About

1. The PID 1 Problem

In a container, your app usually runs as PID 1. Problem: PID 1 has special responsibilities in Linux (like reaping zombie processes). Most apps aren't designed for this.

Solution: Use an init system:

# Add tini
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["your-app"]

2. Container Sprawl

It's easy to create containers and forget about them:

# Find the forgotten ones
docker ps -a --filter "status=exited"

# Auto-remove containers when they stop
docker run --rm myimage

3. Build Context Sending Everything

When you run docker build ., Docker sends the entire directory to the daemon. I once sent 10GB of test data by accident. Build took forever.

Solution: .dockerignore file:

node_modules
.git
*.log
test-data/
.env

4. Using Latest Tag in Production

latest doesn't mean newest – it means "whatever was pushed last". I learned this the hard way when a dev pushed a test image as latest.

Always use specific tags:

docker build -t myapp:v1.2.3 -t myapp:latest .
docker push myapp:v1.2.3
docker push myapp:latest

5. Logs Filling Up Disk

Docker keeps all logs by default. Forever. I've seen servers die because /var/lib/docker filled up.

Fix it globally:

# /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

6. Security Defaults Are Terrible

By default, containers run as root (inside the container). If someone breaks out, they're root on your host.

Always:

# Create a user
RUN useradd -m -u 1000 appuser
USER appuser

Performance Tuning: Making Docker Fly

Build Performance

Multi-stage builds are a game-changer:

# Build stage
FROM node:16 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM node:16-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

Result: 1.2GB image → 150MB image.

Runtime Performance

# Limit resources
docker run -m 512m --cpus="1.5" myapp

# Use tmpfs for temporary files
docker run --tmpfs /tmp:rw,noexec,nosuid,size=100m myapp

# Optimize storage driver
# Check current driver
docker info | grep "Storage Driver"
# overlay2 is usually fastest

Debugging Like a Pro

When things go wrong (and they will), here's your toolkit:

# See what's happening inside
docker exec container_name ps aux
docker exec container_name netstat -tulpn
docker exec container_name df -h

# Check container's view of resources
docker exec container_name cat /proc/meminfo
docker exec container_name cat /proc/cpuinfo

# See the actual container config
docker inspect container_name | jq '.[0].Config'

# See what files changed
docker diff container_name

# Export container filesystem for analysis
docker export container_name > container.tar

# Debug networking
docker run --rm --net container:container_name nicolaka/netshoot ss -tulpn

Production Best Practices (Learned the Hard Way)

1. Never Use Docker Run in Production

Use Docker Compose, Kubernetes, or at least systemd units:

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=docker.service
Requires=docker.service

[Service]
Type=simple
Restart=always
ExecStartPre=-/usr/bin/docker stop myapp
ExecStartPre=-/usr/bin/docker rm myapp
ExecStart=/usr/bin/docker run --name myapp \
  --restart=no \
  -p 8080:8080 \
  -v /data/myapp:/data \
  --env-file /etc/myapp/env \
  myapp:v1.2.3

[Install]
WantedBy=multi-user.target

2. Always Health Check

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost/health || exit 1

3. Log to Stdout, Always

Configure your app to log to stdout/stderr. Let Docker handle the rest. Use a log aggregator (ELK, Loki, etc.) to collect from Docker.

4. One Process Per Container (Usually)

Yes, you can run multiple processes with supervisord. No, you shouldn't. Exception: closely coupled processes like nginx + php-fpm.

5. Secrets Management

Never put secrets in images. Ever. Use:

  • Environment variables (okay for dev)
  • Docker secrets (Swarm)
  • Kubernetes secrets
  • External secret managers (Vault, AWS Secrets Manager)

Advanced Patterns That Actually Work

The Sidecar Pattern

Need to add functionality to a container without modifying it? Add a sidecar:

services:
  app:
    image: myapp
    networks:
      - internal
  
  logging-sidecar:
    image: fluent/fluent-bit
    volumes:
      - logs:/var/log/myapp
    networks:
      - internal

The Ambassador Pattern

Need to connect to different databases in different environments? Use an ambassador:

services:
  app:
    image: myapp
    environment:
      - DB_HOST=db-ambassador
  
  db-ambassador:
    image: mycompany/db-router
    environment:
      - ENVIRONMENT=${ENVIRONMENT}

The Init Container Pattern

Need to do setup before your main app starts?

# In your entrypoint script
#!/bin/bash
# Wait for database
until pg_isready -h $DB_HOST; do
  echo "Waiting for database..."
  sleep 2
done

# Run migrations
python manage.py migrate

# Start app
exec python manage.py runserver 0.0.0.0:8000

Monitoring and Observability

You can't fix what you can't see:

# Basic monitoring
docker stats

# Better monitoring with cAdvisor
docker run -d \
  --name=cadvisor \
  --volume=/:/rootfs:ro \
  --volume=/var/run:/var/run:ro \
  --volume=/sys:/sys:ro \
  --volume=/var/lib/docker/:/var/lib/docker:ro \
  --publish=8080:8080 \
  google/cadvisor:latest

# Even better: Prometheus + Grafana
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'docker'
    static_configs:
      - targets: ['cadvisor:8080']

The Real-World Docker Workflow

Here's how I actually use Docker in my daily work:

  1. Development: Docker Compose with hot reload
services:
  app:
    build: .
    volumes:
      - .:/app  # Mount source code
    command: npm run dev  # Use dev server with hot reload
  1. Testing: Fresh environment every time
# Run tests in container
docker build -t myapp:test .
docker run --rm myapp:test npm test
  1. CI/CD: Build once, run everywhere
# In CI pipeline
docker build -t myapp:${GIT_COMMIT} .
docker tag myapp:${GIT_COMMIT} myapp:latest
docker push myapp:${GIT_COMMIT}
  1. Production: Immutable infrastructure
# Deploy specific version
docker pull myapp:v1.2.3
docker stop myapp-old
docker run -d --name myapp-new myapp:v1.2.3
# Test
docker rm myapp-old
docker rename myapp-new myapp

SSH in Docker Containers: The Controversial Topic

Alright, let's talk about the elephant in the room. "Can I SSH into my Docker container?" Yes. "Should you?" Usually no. But let me explain the whole thing properly.

Why People Want SSH in Containers

Coming from traditional server management, it's natural to want to SSH into your "server". I get it. When I first started with Docker, I tried to make every container an SSH-accessible mini-server. Here's why that thinking is flawed:

  1. Containers aren't VMs - They're meant to run a single process
  2. Docker provides better tools - docker exec does everything SSH does
  3. Security nightmare - Running SSH daemon increases attack surface
  4. Against the philosophy - Containers should be immutable and disposable

But Sometimes You Really Need It

Look, I'm pragmatic. Sometimes you need SSH:

  • Legacy applications that expect SSH access
  • Development environments where team members need traditional access
  • Migration scenarios where you're moving from VMs to containers
  • Specific tools that only work over SSH

Here's how to do it properly:

SSH in a Standalone Container

FROM ubuntu:20.04

# Install SSH server
RUN apt-get update && apt-get install -y \
    openssh-server \
    sudo \
    && rm -rf /var/lib/apt/lists/*

# Create SSH directory
RUN mkdir /var/run/sshd

# Create a user (never allow root SSH)
RUN useradd -m -s /bin/bash -G sudo developer
RUN echo 'developer:changeme' | chpasswd

# Configure SSH
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config

# SSH port
EXPOSE 22

# Use supervisor to run multiple processes (SSH + your app)
RUN apt-get update && apt-get install -y supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

CMD ["/usr/bin/supervisord"]

The supervisord.conf:

[supervisord]
nodaemon=true

[program:sshd]
command=/usr/sbin/sshd -D
autostart=true
autorestart=true

[program:app]
command=/usr/local/bin/myapp
autostart=true
autorestart=true

Build and run:

docker build -t ssh-container .
docker run -d -p 2222:22 --name myserver ssh-container

# Now you can SSH in
ssh -p 2222 developer@localhost

SSH in Docker Compose (The Real-World Scenario)

Here's a more realistic example - a development environment that mimics production servers:

version: '3.8'

services:
  # Development server with SSH access
  dev-server:
    build:
      context: .
      dockerfile: Dockerfile.ssh
    image: myapp-dev:latest
    container_name: dev-server
    hostname: dev-server
    ports:
      - "2222:22"      # SSH
      - "8080:80"      # App
    environment:
      - ENV=development
      - SSH_USERS=john:1001:1001,jane:1002:1002
    volumes:
      - ./app:/var/www/html
      - ssh-keys:/home
    networks:
      - development
    restart: unless-stopped

  # Database (no SSH needed)
  database:
    image: postgres:13
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=developer
      - POSTGRES_PASSWORD=secret
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - development

networks:
  development:
    driver: bridge

volumes:
  ssh-keys:
  db-data:

Better Dockerfile for SSH with proper user management:

FROM ubuntu:20.04

# Install essentials
RUN apt-get update && apt-get install -y \
    openssh-server \
    sudo \
    curl \
    git \
    vim \
    && rm -rf /var/lib/apt/lists/*

# Setup SSH
RUN mkdir /var/run/sshd
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config

# Create users from environment variable
COPY create-users.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/create-users.sh

# Copy entrypoint
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

EXPOSE 22

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["/usr/sbin/sshd", "-D"]

The create-users.sh script:

#!/bin/bash
# Creates users from SSH_USERS env var
# Format: SSH_USERS=user1:uid1:gid1,user2:uid2:gid2

if [ -n "$SSH_USERS" ]; then
    for user in $(echo $SSH_USERS | tr "," "\n"); do
        IFS=':' read -r username uid gid <<< "$user"
        
        groupadd -g $gid $username
        useradd -m -u $uid -g $gid -s /bin/bash $username
        usermod -aG sudo $username
        
        # Set password or use SSH keys
        echo "$username:$username" | chpasswd
        
        # Create .ssh directory
        mkdir -p /home/$username/.ssh
        chown $username:$username /home/$username/.ssh
        chmod 700 /home/$username/.ssh
    done
fi

Treating Containers as Virtual Servers (When It Makes Sense)

Sometimes you need containers to behave like traditional servers. Here's when it's acceptable:

1. Development Environments

services:
  dev-box:
    build: ./docker/dev
    image: company/dev-environment:latest
    hostname: devbox
    domainname: local.company.com
    ports:
      - "2201:22"  # SSH
      - "8080:80"  # HTTP
      - "3000:3000"  # Node.js
      - "5432:5432"  # PostgreSQL
    volumes:
      - ./:/workspace
      - ~/.ssh:/home/developer/.ssh:ro
      - ~/.gitconfig:/home/developer/.gitconfig:ro
    environment:
      - DISPLAY=${DISPLAY}  # For GUI apps
    extra_hosts:
      - "api.local:host-gateway"
    cap_add:
      - SYS_PTRACE  # For debugging
    stdin_open: true
    tty: true

This creates a full development environment that developers can SSH into, run their IDEs, debug, etc.

2. CI/CD Runners

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./config:/etc/gitlab-runner
    environment:
      - DOCKER_HOST=tcp://docker:2375
    networks:
      - ci-network

  # SSH-accessible build agent
  build-agent:
    build: ./docker/build-agent
    ports:
      - "2222:22"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - build-cache:/cache
    environment:
      - JENKINS_AGENT_SSH_PUBKEY=${JENKINS_SSH_KEY}
    networks:
      - ci-network

3. Legacy Application Migration

When migrating a legacy app that expects to SSH between servers:

services:
  app-server:
    build: 
      context: ./legacy-app
      args:
        - SSH_HOST_KEY=${SSH_HOST_KEY}
    hostname: app01
    ports:
      - "2201:22"
      - "8080:8080"
    networks:
      backend:
        ipv4_address: 172.20.0.10

  worker-server:
    build: 
      context: ./legacy-worker
      args:
        - SSH_HOST_KEY=${SSH_HOST_KEY}
    hostname: worker01
    ports:
      - "2202:22"
    networks:
      backend:
        ipv4_address: 172.20.0.11

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24

The Better Alternatives to SSH

Before you implement SSH, consider these alternatives:

1. Docker Exec (99% of use cases)

# Get a shell
docker exec -it container_name bash

# Run commands
docker exec container_name ps aux

# As specific user
docker exec -u developer container_name bash

2. Docker Compose Run

# Run one-off commands
docker-compose run --rm app bash
docker-compose run --rm app python manage.py migrate

3. Visual Studio Code Remote Containers

// .devcontainer/devcontainer.json
{
    "name": "My Dev Container",
    "dockerComposeFile": "docker-compose.yml",
    "service": "app",
    "workspaceFolder": "/workspace",
    "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
    },
    "extensions": [
        "ms-python.python",
        "ms-azuretools.vscode-docker"
    ]
}

Now VS Code connects directly to the container - no SSH needed!

4. Web-Based Terminals

services:
  wetty:
    image: wettyoss/wetty
    ports:
      - "3000:3000"
    command: --ssh-host=app-server --ssh-port=22
    depends_on:
      - app-server

When SSH in Containers Actually Makes Sense

Let me be honest about when I actually use SSH in containers:

  1. Development Environments: When the team is more comfortable with traditional SSH workflows
  2. Testing Ansible/Chef/Puppet: When you need to test configuration management tools
  3. Educational Environments: Teaching Linux/DevOps where students expect SSH access
  4. Hybrid Architectures: During migration from VMs to containers

Security Considerations for SSH Containers

If you're going to do it, do it right:

# Use SSH keys only
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
RUN sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config

# Limit SSH access
RUN echo "AllowUsers developer" >> /etc/ssh/sshd_config

# Use fail2ban
RUN apt-get update && apt-get install -y fail2ban

# Log everything
RUN echo "LogLevel VERBOSE" >> /etc/ssh/sshd_config

The Reality Check

Here's what I've learned after years of Docker in production:

  1. Containers with SSH are just lightweight VMs - You lose many Docker benefits
  2. It's a stepping stone - Use it for migration, then refactor away from it
  3. Document why - If you need SSH, document WHY for future developers
  4. Monitor everything - SSH containers need extra monitoring

Example monitoring setup:

services:
  ssh-monitor:
    image: prom/node-exporter
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
    networks:
      - monitoring

Practical SSH Container Patterns

Pattern 1: Bastion Host Container

services:
  bastion:
    build: ./docker/bastion
    ports:
      - "2222:22"
    networks:
      - frontend
      - backend
    volumes:
      - ssh-keys:/etc/ssh/keys:ro
    environment:
      - ALLOWED_IPS=${ALLOWED_IPS}

Pattern 2: Development Jumpbox

services:
  jumpbox:
    image: company/dev-jumpbox:latest
    hostname: jump.dev.local
    ports:
      - "2222:22"
    volumes:
      - ~/.ssh/authorized_keys:/home/developer/.ssh/authorized_keys:ro
      - ./scripts:/scripts:ro
    networks:
      - development

Pattern 3: Ansible Control Node

services:
  ansible:
    build: ./docker/ansible
    ports:
      - "2223:22"
    volumes:
      - ./playbooks:/ansible/playbooks
      - ./inventory:/ansible/inventory
      - ansible-ssh-keys:/home/ansible/.ssh
    environment:
      - ANSIBLE_HOST_KEY_CHECKING=False

Common Misconceptions Debunked

"Docker is Just for Microservices"

Nope. I containerize monoliths all the time. Benefits: consistent deployment, easy rollback, resource isolation.

"Containers are Less Secure than VMs"

Different, not less. VMs protect against hypervisor escape. Containers protect against application compromise. Use both for defense in depth.

"Docker Adds Too Much Overhead"

Overhead is typically <3% for CPU and memory. Network overhead can be higher due to NAT, but host networking eliminates that.

"Kubernetes is Required for Production Docker"

False. For simple apps, Docker + systemd works fine. I ran a 10M user app with just Docker Compose and nginx load balancing.

"SSH in Containers is Always Wrong"

Not always. It's wrong when you're trying to make containers behave like VMs without thinking. It's right when you have specific needs and understand the tradeoffs.

Troubleshooting Checklist

When your container isn't working:

  1. Is it running? docker ps
  2. Did it crash? docker ps -a and check STATUS
  3. What's the error? docker logs container_name
  4. Is the process running inside? docker exec container_name ps aux
  5. Network accessible? docker exec container_name netstat -tulpn
  6. Can it reach external services? docker exec container_name ping google.com
  7. File permissions okay? docker exec container_name ls -la /app
  8. Environment variables set? docker exec container_name env
  9. Enough resources? docker stats container_name
  10. What does the app think? docker exec container_name cat /proc/1/status

The End Game: Where to Go from Here

You've got Docker basics down. Here's your learning path:

  1. Master Docker Compose - It's not optional for real work
  2. Learn Docker Swarm - Simple orchestration before jumping to K8s
  3. Understand Container Security - Read the CIS Docker Benchmark
  4. Try Kubernetes - When you need it, you'll know
  5. Explore BuildKit - Next-gen image building
  6. Check out Podman - Daemonless containers

Final Thoughts

Docker isn't perfect. It has quirks, gotchas, and occasionally makes you want to throw your laptop out the window. But it's also revolutionized how we build and deploy software.

The key is understanding what's actually happening under the hood. Don't just memorize commands – understand the concepts. When something breaks (and it will), you'll know how to fix it.

Remember: every expert was once a beginner who didn't quit. I've been using Docker for 7 years and I still learn new things. That's the beauty of it.

Now stop reading and start containerizing. Your future self will thank you when you're deploying to production with a single command while others are still fighting with dependency hell.

# Your journey begins...
docker run -it --rm alpine sh -c "echo 'Welcome to the real world, Neo'"

Happy containerizing! And remember – when in doubt, docker system prune -a and start fresh. Sometimes that's easier than debugging. 🐳


P.S. - If you found this helpful, you probably have friends who are struggling with Docker too. Share the knowledge. The community is what makes open source amazing.

Subscribe to my newsletter.

Become a subscriber receive the latest updates in your inbox.