Docker Setup Guide: A Hollywood Blockbuster Edition
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:
- 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)
- 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.
- 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:
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:
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:
- Docker client contacts Docker daemon
- Daemon checks if 'nginx' image exists locally
- If not, pulls from registry (Docker Hub by default)
- Creates a new container from the image
- Allocates a read-write filesystem layer
- Sets up network interface and IP
- 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:
- Development: Docker Compose with hot reload
services:
app:
build: .
volumes:
- .:/app # Mount source code
command: npm run dev # Use dev server with hot reload
- Testing: Fresh environment every time
# Run tests in container
docker build -t myapp:test .
docker run --rm myapp:test npm test
- 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}
- 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:
- Containers aren't VMs - They're meant to run a single process
- Docker provides better tools -
docker exec
does everything SSH does - Security nightmare - Running SSH daemon increases attack surface
- 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:
- Development Environments: When the team is more comfortable with traditional SSH workflows
- Testing Ansible/Chef/Puppet: When you need to test configuration management tools
- Educational Environments: Teaching Linux/DevOps where students expect SSH access
- 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:
- Containers with SSH are just lightweight VMs - You lose many Docker benefits
- It's a stepping stone - Use it for migration, then refactor away from it
- Document why - If you need SSH, document WHY for future developers
- 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:
- Is it running?
docker ps
- Did it crash?
docker ps -a
and check STATUS - What's the error?
docker logs container_name
- Is the process running inside?
docker exec container_name ps aux
- Network accessible?
docker exec container_name netstat -tulpn
- Can it reach external services?
docker exec container_name ping google.com
- File permissions okay?
docker exec container_name ls -la /app
- Environment variables set?
docker exec container_name env
- Enough resources?
docker stats container_name
- 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:
- Master Docker Compose - It's not optional for real work
- Learn Docker Swarm - Simple orchestration before jumping to K8s
- Understand Container Security - Read the CIS Docker Benchmark
- Try Kubernetes - When you need it, you'll know
- Explore BuildKit - Next-gen image building
- 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.
Become a subscriber receive the latest updates in your inbox.
Member discussion