“It works on my machine” used to be a running joke in every engineering team, right up until it caused a real production incident. Docker’s actual innovation was solving this specific problem: packaging an application with everything it needs — code, dependencies, configuration — into a single, portable unit called a container, so it behaves identically on a laptop, a test server, and production.
Docker’s Core Pieces, With Real Definitions
A Docker image is a read-only blueprint for a container — built once, run many times. A container is a running instance of that image. A Dockerfile is the text file with instructions for building an image. The Docker Engine is the runtime that actually builds and runs containers on your machine. A registry (Docker Hub being the most common) is where built images get stored and pulled from.
Your First Real Dockerfile
Here’s what an actual Dockerfile looks like for a simple Node.js app — not a list of concepts, but the real file:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Each line does something specific: FROM sets the base image (Alpine here, specifically because it’s a minimal Linux distribution that keeps the final image small). WORKDIR sets the working directory inside the container. COPY package*.json ./ copies just the dependency manifest first — deliberately separate from the rest of the code — so Docker can cache the npm install layer and skip re-running it on every build when only your application code changed, not your dependencies. EXPOSE documents which port the app listens on. CMD is the command that actually starts the application when the container runs.
Building and Running It for Real
docker build -t my-app:1.0 .
docker run -d -p 3000:3000 --name my-app-container my-app:1.0
docker ps
docker logs my-app-container
docker build reads the Dockerfile and produces an image, tagged here as my-app:1.0. docker run starts a container from that image — -d runs it in the background, -p 3000:3000 maps the container’s port to your machine’s port so you can actually reach it. docker ps shows running containers, and docker logs is the command you’ll reach for constantly to see what the application inside actually printed.
Why Layers and Image Size Actually Matter
Each instruction in a Dockerfile creates a cached layer, which is exactly why the COPY package*.json ordering trick above matters — it lets Docker reuse cached layers on rebuilds instead of reinstalling every dependency every time. Smaller images (using Alpine or “slim” base image variants) build faster, deploy faster, and carry a smaller attack surface, since there’s simply less software present that could contain a vulnerability.
Containers Are Meant to Be Disposable
A running container gets a writable layer on top of its read-only image, but that layer disappears when the container is removed — containers are designed to be ephemeral and stateless. This is intentional, not a limitation: it’s what makes containers easy to replace, scale, and restart cleanly. For anything that actually needs to persist — a database’s data, uploaded files, logs — Docker volumes exist specifically to separate that data from the container’s own disposable lifecycle:
docker run -d -v my-app-data:/app/data my-app:1.0
That -v my-app-data:/app/data mounts a named volume into the container at /app/data, so the data survives even if the container itself gets removed and recreated.
Running Multiple Containers Together: Docker Compose
Real applications rarely run as a single container — a backend service, a database, and a cache are common together. Docker Compose manages this with one file instead of several manual docker run commands. A real docker-compose.yml:
version: "3.8"
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: examplepassword
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Running docker-compose up starts both services together, with the web service waiting on db per the depends_on line, and the database’s data persisted in the named volume across restarts. This single file replaces what would otherwise be several manually-ordered commands.
Configuration Belongs Outside the Image
Hard-coding a database password or API key directly into an image is a real security risk, not just messy practice — anyone with access to the image can extract it. Environment variables (as shown in the docker-compose.yml above) or a dedicated secrets manager keep the same image portable across development, staging, and production, with only the environment-specific values changing between them.
Security Practices Worth Actually Following
Running as a non-root user inside the container, scanning images for known vulnerabilities (docker scan, or a dedicated tool like Trivy), and never baking secrets into an image are the baseline, not advanced practices. Adding a dedicated user in the Dockerfile is a couple of extra lines:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Skipping this means your application runs as root inside the container by default, which is a meaningfully larger risk if that container is ever compromised.
Getting to Production-Ready
A genuinely production-ready container has more than a working Dockerfile: a health check the orchestrator can poll, explicit CPU and memory limits so one container can’t starve the others on the same host, logs sent to standard output rather than written to a file inside the container (which disappears with it), and images referenced by a specific version tag rather than “latest,” so a deployment is reproducible and rollbacks are actually possible.
Where This Fits Into the Bigger Picture
Docker packages and runs a single application reliably. Once you’re managing many containers across multiple machines, scaling them, and handling failures automatically, that’s the problem Kubernetes solves — the two are complementary layers, not competing tools. And if you hit an actual error while working through any of this, this rundown of the most common Docker errors and their real fixes covers the issues that come up constantly in practice.
The Actual Path to Mastery
Understanding Docker well doesn’t come from memorizing every flag — it comes from building something real with it: write an actual Dockerfile for a project you’re already working on, run it, break it, and fix what breaks. That loop, repeated a handful of times, teaches more than reading about containers ever will on its own.
Pingback: Ultimate Kubernetes Guide 2026: From Local Development to Global Scaling - Daily Reading Habit