Quick Answer

Docker packages an application together with its dependencies and runtime into an image, which runs as a container identically on any machine with Docker installed. That removes environment differences between development, testing and production. An image is the blueprint and a container is a running instance. You do not need it for a personal project on one machine, but it becomes valuable as soon as several people or several services are involved.

The Problem It Actually Solves

The stock explanation is "it fixes works on my machine", which is true but vague. The specific failures it removes are these.

Your laptop has Node 20 and the server has Node 18, so a syntax feature fails in production. You installed a system library months ago and forgot, so a fresh machine cannot build. Two projects need different versions of the same runtime. A colleague spends a day setting up a project because the README omits three steps nobody remembers.

Docker packages the application together with everything it needs to run — runtime, libraries, system packages, environment — into an image. Running that image produces a container that behaves the same on any machine with Docker.

Container versus virtual machine is the usual follow-up. A VM includes an entire guest operating system, so it is heavy and slow to start. A container shares the host kernel and isolates only the application, so it starts in under a second and adds little overhead. That difference is why running a dozen containers on a laptop is practical and running a dozen VMs is not.

Images, Containers and the Commands You Need

An image is the blueprint; a container is a running instance. One image can produce many containers, in the same way one class produces many objects.

# Images
docker pull node:20-alpine        # download an image
docker images                     # list local images
docker build -t myapp .           # build from a Dockerfile in this folder

# Containers
docker run -p 3000:3000 myapp     # run, mapping host port to container port
docker run -d --name web myapp    # detached, with a name
docker ps                         # running containers
docker ps -a                      # including stopped ones
docker logs -f web                # follow the logs
docker exec -it web sh            # shell inside a running container
docker stop web && docker rm web  # stop and remove

# Cleanup — reclaims a surprising amount of disk
docker system prune -a

The port mapping catches everyone. -p 3000:3000 means host port 3000 maps to container port 3000. Without it the application runs but is unreachable, because containers are isolated by default.

Containers are ephemeral. Anything written inside a container is lost when it is removed. That is deliberate — persistent data belongs in a volume:

docker run -v mydata:/var/lib/postgresql/data postgres:16

Forgetting this and losing a development database is a standard rite of passage.

Writing a Dockerfile

A Dockerfile is the recipe for an image. Here is a reasonable Node example with the details that matter:

FROM node:20-alpine

WORKDIR /app

# Copy dependency files FIRST, install, then copy the source.
# Docker caches each layer — this way, changing your code does not
# reinstall every dependency on every build.
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

# Do not run as root
USER node

EXPOSE 3000
CMD ["node", "server.js"]

The copy order is the single most important optimisation. Each instruction creates a cached layer, and a layer is rebuilt only if it or something before it changed. Copying everything first means every code change reinstalls all dependencies — turning a five-second build into several minutes.

Add a .dockerignore, for the same reasons as .gitignore:

node_modules
.git
.env
*.log

Copying local node_modules into an image is both slow and wrong — the host's binaries may not match the container's platform.

Choose a small base image. Alpine variants are a fraction of the size of the default, which means faster pulls and a smaller attack surface. Never bake secrets into an image — they persist in the layer history even if a later step deletes them. Pass them at runtime.

Docker Compose for Multiple Services

Real applications need more than one thing running — an API, a database, maybe a cache. Compose describes them in one file and starts them together.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://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     # survives container removal

volumes:
  pgdata:
docker compose up -d       # start everything
docker compose logs -f     # follow all logs
docker compose down        # stop and remove
docker compose down -v     # also delete volumes — destroys the data

Services reach each other by name. The connection string above uses db as the hostname, not localhost, because Compose puts them on a shared network where the service name resolves. Using localhost inside a container refers to that container itself, which is the most common Compose mistake.

Note that depends_on controls start order but does not wait for the database to be ready to accept connections. Your application should retry its initial connection rather than assuming.

When You Actually Need Docker

An honest answer, because Docker is frequently adopted before it earns its cost.

You probably do not need it when: you are learning to program, working alone on one machine, building a static site or simple frontend, or deploying to a platform that builds from your repository anyway. In these cases it adds a layer of complexity between you and your code, and debugging becomes harder rather than easier.

You start to need it when: several people work on the project and setup differences waste time; the application needs services like PostgreSQL or Redis that are tedious to install natively; you run several projects with conflicting runtime versions; production runs containers so you want development to match; or your deployment target expects an image.

Learn it before Kubernetes. Kubernetes orchestrates many containers across many machines, and it solves problems of scale that a small project does not have. Learning it early is a common and expensive detour.

A sensible first step: use Docker to run your database before containerising your application. One command gives you PostgreSQL with the right version and no system-wide installation, and removing it leaves nothing behind. That alone is worth the learning, and it is a much gentler introduction than rewriting your deployment.

Frequently Asked Questions

What is the difference between an image and a container? An image is a read-only blueprint containing the application and its dependencies. A container is a running instance of that image. One image can run as many containers, similar to a class and its objects.
Do I need Docker for a personal project? Usually not, if you work alone on one machine. It becomes worthwhile when several people share the project, when you need services like PostgreSQL without installing them system-wide, or when production runs containers.
Why does my container run but I cannot reach it? Containers are network-isolated by default. You need to publish the port with -p hostPort:containerPort. Also check the application binds to 0.0.0.0 rather than 127.0.0.1, which would only accept connections from inside the container.
Why is my Docker build so slow every time? Most likely the copy order in your Dockerfile. Copy the dependency manifest and install first, then copy the source. Otherwise any code change invalidates the cached layer and reinstalls every dependency.
Should I learn Kubernetes after Docker? Not immediately. Kubernetes orchestrates many containers across many machines and solves scaling problems small projects do not have. Get comfortable with Docker and Compose first, and learn Kubernetes when a real requirement appears.