Docker Explained for Developers Who've Never Used Containers
Image vs container, a basic Dockerfile, and why Docker fixes the classic "works on my machine" problem — a no-fluff guide for developers just starting out.
The new dev just cloned the repo. Followed the README step by step. Ran it. Didn't work. Wrong Node version. Missing system library. An undocumented environment variable someone set months ago and never wrote down. This loop has a name: "works on my machine" — and it's genuinely frustrating to debug without Docker.
Docker wasn't born to be everyone's favorite buzzword on a resume. It was built to solve that specific problem: making the execution environment travel with the code.
The real problem Docker solves
When you install an application on your machine, it depends on dozens of things beyond the source code: the runtime version, system libraries, environment variables, configuration files. All of that lives on your machine but not necessarily in production — or on your teammate's laptop.
Before Docker, the solution was to document everything and hope. Or use full virtual machines — which works, but costs setup time and wastes memory. A VM carries an entire operating system just to run one application.
Docker approaches it differently: instead of virtualizing the whole hardware stack, it isolates processes using features of the Linux kernel itself (namespaces and cgroups). The result is a container: an isolated process that can only see what you defined, but shares the host machine's kernel. Lighter than a VM, more predictable than installing bare on the host.
If you're still wrapping your head around the bigger picture — how deployment, automation, and CI/CD fit together — What DevOps Actually Is Beyond the Tools covers the culture and process behind the tooling.
Image vs container: the distinction everyone muddles at first
This question comes up constantly. The most direct analogy:
- Image is the recipe. Static, immutable, described in a file.
- Container is what's created when you execute the recipe. Dynamic, has state, has a running process.
One image can produce ten simultaneous containers. Each container is an instance of that recipe, isolated from the others. You can run the same image on your Mac, your colleague's Linux machine, and a cloud server — the container behaves the same in all three places, because the environment is baked into the image.
The image itself runs nothing. It's just a template. The container is where the work happens.
Dockerfile: where you describe the environment
A Dockerfile is a text file with sequential instructions. Each instruction becomes a layer of the image. Layers are cached — if you only change the application code, Docker doesn't need to reinstall the dependencies; it just applies the diff from the last layer.
A basic Dockerfile for a Node application:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Line by line:
FROM node:20-alpine— starts from a base image. Alpine Linux is minimal, a solid production choice.WORKDIR /app— sets the working directory inside the container.COPY package*.json ./followed byRUN npm ci— copies only the dependency manifest and installs. This takes advantage of layer caching: ifpackage.jsonhasn't changed, Docker skips this step on the next build.COPY . .— copies the rest of the code after the dependencies. Intentionally last, to avoid invalidating thenpm cicache on every code change.EXPOSE 3000— documents which port the process will use. Doesn't actually bind anything by itself.CMD— the command that runs when the container starts.
To build the image from this Dockerfile:
docker build -t my-api:1.0 .
To run a container from it:
docker run -p 3000:3000 my-api:1.0
The -p 3000:3000 maps port 3000 on your host to port 3000 inside the container. Without that mapping, the process is running but unreachable from outside.
What actually happens under the hood during docker run
When you execute docker run, Docker:
- Grabs the image (downloads from the registry if not available locally)
- Creates a writable layer on top of the read-only image layers
- Configures the container's virtual network
- Executes the process defined in
CMD
The container lives as long as the main process is active. When the process exits, the container stops. That's different from a VM, which stays "on" regardless of active processes.
Containers are disposable by design. If you need persistent state — databases, uploads, logs — you use volumes, which are directory mappings between the host and the container.
Docker Compose: when you have more than one container
Real applications usually have at least two services: the application and the database. docker-compose.yml describes all of them and how they communicate:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/myapp
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
With this, docker compose up brings everything up together. The API can reach the database using the hostname db — which is the service name, resolved automatically by Compose's internal network. No DNS configuration needed.
pgdata is a named volume: Postgres data persists even if you destroy and recreate the database container.
Why this actually fixes "works on my machine"
The answer is direct: because the environment stops being implicit.
Before Docker, the environment was documented (when it was) in READMEs, wikis, and bootstrap scripts that might be months out of date. With the Dockerfile, the environment is in the code. It's versioned alongside the application. If it works in CI, it will work in production — because CI and production run the same image.
The container doesn't know what version of Node is installed on your Mac. It uses the version defined in FROM. The system library missing on the server isn't missing anymore — it's in the image. The undocumented environment variable is now in docker-compose.yml or the project's .env.example, which is required to run locally.
This doesn't eliminate every environment bug — architecture differences (x86 vs ARM), secrets managed outside Compose, external dependencies like third-party APIs can still cause divergence. But it eliminates the most common class of problems: wrong runtime version and missing system dependencies.
For validating cron expressions in scheduled containers, the Cron Expression Generator shows upcoming executions with the correct timezone — useful when the container runs in UTC but the job was written with a local timezone in mind.
Frequently asked questions
Does Docker replace virtual machines?
For most development and application deployment use cases, yes — and with real advantages. Containers start in seconds, consume less memory, and are disposable. VMs still make sense when you need full kernel isolation (higher security requirements, different operating systems), or when the application requires a complete OS for specific reasons. For web services, APIs, and databases, containers are the industry default today.
Does Docker work the same on Windows, Mac, and Linux?
On Linux, Docker runs natively — containers share the host machine's kernel. On Mac and Windows, Docker Desktop runs a lightweight Linux VM in the background to provide the necessary kernel. For day-to-day development this is transparent, though there can be minor I/O performance differences in some cases. In production, the environment is almost always Linux, so your image will run without the extra layer.
What is Docker Hub?
It's the official public image registry. When you write FROM node:20-alpine, Docker pulls that image from Docker Hub. That's where base images for languages, databases, and tools come from. You can also host private images there, or use alternative registries like the GitHub Container Registry or Amazon ECR.
Should I use Docker for local development?
It depends. For teams with more than one person and projects that have a backend plus a database plus other services, Docker Compose saves hours of onboarding. For personal projects or simple tools you develop alone, the overhead of learning and maintaining a Dockerfile might not be worth it early on. The calculus flips when the project grows or when other developers need to contribute.
Dockerfile as environment documentation
The biggest benefit of Docker isn't deployment speed — it's making the environment explicit and version-controlled. A well-written Dockerfile answers questions that used to live in the head of whoever configured the original server: which runtime version? Which system dependencies? Which port? Which user runs the process?
That has direct value in onboarding, in CI, and during incidents at 11pm when someone needs to spin up an emergency container without knowing the machine's history. The environment is in the repository, alongside the code that depends on it.
- 01 VPS, VPC, and Dedicated Server: What's the Difference and When to Use Each VPS, VPC, and dedicated server appear side by side on every hosting comparison page — but they mean different things. Here's where the money goes and how to decide.
- 02 Relational vs NoSQL: How to Actually Choose An honest comparison of relational and NoSQL databases — real tradeoffs on consistency, schema, and scale, with a clear default recommendation.