"It works on my machine" is the joke that everyone in software has heard, because it's happened to everyone in software. Docker exists to make that sentence irrelevant. Instead of hoping a teammate's laptop, your CI server, and your production host all happen to agree on Node version, system libraries, and a dozen other details, you package the application together with everything it needs to run — once — and run that exact package everywhere.

The problem before containers

Before containers, "deploying" usually meant writing a setup document: install this runtime, this exact version of that library, set these three environment variables, hope the OS matches. Every environment — a new hire's laptop, staging, production — was a slightly different snowflake, and differences between them were where a lot of bugs hid.

Virtual machines solved this by packaging an entire operating system alongside the app, which worked but was heavy: gigabytes per VM, and slow to start. Docker's insight was to share the host machine's kernel and only package the application and its dependencies, which makes containers dramatically lighter and faster to start — typically seconds, not minutes.

The core vocabulary

Walkthrough: from zero to a running container

Here's a minimal example for a small Node.js app, to make the vocabulary above concrete.

1. Write the Dockerfile

# Start from a small, official Node image
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy dependency manifests first (better caching)
COPY package*.json ./
RUN npm install --production

# Copy the rest of the app
COPY . .

# Document which port the app listens on
EXPOSE 3000

# The command that runs when the container starts
CMD ["node", "server.js"]

The order here isn't arbitrary. Copying package.json and installing dependencies before copying the rest of the code means Docker can reuse that cached layer on future builds as long as your dependencies haven't changed — even if you've edited application code. That one habit alone can cut build times significantly on a real project.

2. Build the image

docker build -t my-app:1.0 .

This reads the Dockerfile in the current directory (.) and produces an image tagged my-app:1.0. The tag is just a label — useful for tracking versions the same way you'd tag a release.

3. Run a container from it

docker run -p 3000:3000 my-app:1.0

-p 3000:3000 maps port 3000 on your machine to port 3000 inside the container, which is what makes the app reachable from your browser at localhost:3000. Everything the app needs — the exact Node version, the exact dependency versions — is already inside the image, so this behaves the same on your laptop, a teammate's laptop, or a production server.

4. Everyday commands worth knowing

docker ps                 # list running containers
docker logs <container>   # view a container's output
docker exec -it <container> sh   # open a shell inside a running container
docker stop <container>   # stop it
docker compose up         # start a whole multi-container app (e.g. app + database)
Where teams usually go next A single container is a good starting point, but real applications often need a database, a cache, and background workers running together. That's what docker-compose.yml is for — it describes a group of containers and how they connect, and docker compose up starts the whole set with one command.

What Docker doesn't solve by itself

It's worth being clear-eyed here: Docker packages and isolates your app, but it doesn't automatically handle things like scaling across multiple servers, rolling deployments, or service discovery — that's the job of an orchestrator like Kubernetes or a managed platform. For a lot of small and mid-sized projects, though, Docker alone (often with Docker Compose) is more than enough, and reaching for a full orchestration platform before you need one usually adds complexity without adding value.

The one habit worth keeping

If you take away one practical habit from this guide, make it this: keep your Dockerfile as close as possible to what actually runs in production, and use the same image locally that you deploy. The entire value of containers evaporates the moment "the container" and "what's actually running in prod" quietly drift apart.