Containers vs Virtual Machines
"It works on my machine" is a punchline, but it's also a real productivity killer. Docker solves it by packaging your application with everything it needs to run — the runtime, the dependencies, the config — so the environment is identical whether you're on a MacBook, a Linux CI server, or a production VM. That consistency alone is worth the learning curve.
Containers take a different approach. Instead of virtualizing hardware, they virtualize the operating system. Containers share the host kernel but have isolated filesystems, processes, networks, and resource limits. A container starts in milliseconds, uses megabytes of memory, and shares common base layers between images.
The trade-off: containers are less isolated than VMs (they share the kernel), but for most development and deployment use cases, the isolation is more than sufficient.
Core Docker Concepts
- Image: A read-only blueprint for a container. Built from a Dockerfile. Stored in registries like Docker Hub or AWS ECR.
- Container: A running instance of an image. Ephemeral — when it stops, any changes to its filesystem are lost (unless using volumes).
- Dockerfile: A text file with instructions for building an image. Each instruction creates a new layer in the image.
- Registry: A server for storing and distributing images. Docker Hub is the default public registry.
- Volume: A mechanism for persisting data beyond a container's lifetime, or sharing data between containers.
Writing a Dockerfile
A Dockerfile defines the environment your application runs in. Here's a well-structured example for a Node.js application:
# Use an official base image (specific version for reproducibility) FROM node:20-alpine # Set the working directory inside the container WORKDIR /app # Copy dependency files first (layer caching optimization) COPY package.json package-lock.json ./ # Install dependencies RUN npm ci --only=production # Copy the rest of the application code COPY . . # Expose the port the app listens on EXPOSE 3000 # Define the command to run the app CMD ["node", "server.js"]
The order of instructions matters for build performance. Docker caches each layer — if a layer's inputs haven't changed, Docker reuses the cached version. By copying package.json before the source code, dependencies are only reinstalled when package.json changes, not on every code change.
Essential Docker Commands
# Build an image from current directory docker build -t myapp:1.0 . # Run a container docker run -p 3000:3000 myapp:1.0 # Run in detached (background) mode docker run -d -p 3000:3000 --name myapp myapp:1.0 # List running containers docker ps # View container logs docker logs myapp # Execute a command inside a running container docker exec -it myapp sh # Stop and remove a container docker stop myapp && docker rm myapp # List images docker images # Remove an image docker rmi myapp:1.0
Docker Compose for Multi-Container Apps
Real applications usually need multiple services: a web server, a database, a cache. Docker Compose lets you define and run multi-container applications with a single YAML file:
# docker-compose.yml version: '3.9' services: app: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgres://user:pass@db:5432/mydb - REDIS_URL=redis://cache:6379 depends_on: - db - cache volumes: - ./src:/app/src # mount source for hot reload db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - postgres_data:/var/lib/postgresql/data cache: image: redis:7-alpine volumes: postgres_data:
# Start all services docker compose up # Start in background docker compose up -d # Stop all services docker compose down # View logs for all services docker compose logs -f # Rebuild and restart docker compose up --build
Multi-Stage Builds
Multi-stage builds reduce final image size by separating the build environment from the runtime environment. This is especially useful for compiled languages:
# Stage 1: Build FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: Production image (much smaller) FROM node:20-alpine AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/server.js"]
The final image only contains the production runtime — not the build tools, dev dependencies, or source files. A Node.js app might shrink from 1.5GB to 150MB using this pattern.
The .dockerignore File
Like .gitignore, .dockerignore tells Docker which files to exclude from the build context. This speeds up builds and prevents accidentally copying secrets:
node_modules .git .env .env.local *.log dist coverage .DS_Store README.md docker-compose*.yml
Dockerfile Best Practices
- Use specific base image tags: Pin to node:20.11-alpine not node:latest to ensure reproducible builds.
- Run as non-root: Add a user to run the application process — root inside a container can still be a security risk.
- Minimize layers: Chain RUN commands with && to reduce layer count and clean up in the same layer.
- Never store secrets in images: Pass secrets as environment variables at runtime, not in the Dockerfile or image layers.
- Use alpine variants: Alpine-based images are significantly smaller, reducing pull times and attack surface.
# Run as non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser # Minimize layers and clean up in one RUN RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/*
On Mac and Windows, Docker Desktop provides a graphical interface and runs Docker in a lightweight VM. On Linux servers, Docker Engine runs natively. For production deployments, Docker alone is usually not enough — container orchestration platforms like Kubernetes or managed services like AWS ECS, Google Cloud Run, or Railway handle scheduling, scaling, health checks, and rolling deployments.
Conclusion
Docker solves real problems: consistent environments across development, staging, and production; fast, reproducible builds; and simplified local development of complex multi-service applications. Starting with Docker might feel like added complexity, but the payoff — eliminated environment issues, easy onboarding for new developers, and deployment confidence — is significant. Start by containerizing one service, learn Docker Compose for local development, and build from there. The patterns covered here apply to any language or framework.