Docker for Developers: Images, Containers and Compose
You cloned a project, ran the setup instructions, and hit a wall: a missing system library, a database that won't start, or a Node version mismatch. Docker exists to remove that class of problem. Instead of installing services directly on your machine, you describe them once and run them in isolated containers that behave the same everywhere.
This guide explains the three ideas you actually need: images, containers, and Compose. It stays practical, with commands you can run as you read.
Images vs containers: the mental model
An image is a read-only template. It contains a filesystem snapshot plus metadata such as the default command and exposed ports. A container is a running (or stopped) instance of that image with a thin writable layer on top.
If you come from object-oriented programming, the analogy is close: an image is a class, a container is an object. You can start many containers from one image, and changes inside a container do not modify the image.
| Concept | What it is | Typical command |
|---|---|---|
| Image | Immutable template | docker build, docker pull |
| Container | Running instance | docker run, docker ps |
| Volume | Persistent storage | docker volume create |
| Compose | Multi-container definition | docker compose up |
Your first image with a Dockerfile
A Dockerfile is a recipe. Each instruction creates a layer, and Docker caches layers so rebuilds are fast when nothing changed. Here is a small Node.js example:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Two habits matter here. First, copy dependency manifests before the rest of the source so the npm ci layer is cached when only application code changes. Second, prefer a slim base image such as alpine to reduce size and attack surface.
Build and run it:
docker build -t myapp:1.0 .
docker run --rm -p 3000:3000 myapp:1.0The -p 3000:3000 flag maps host port 3000 to container port 3000. --rm deletes the container when it exits, which keeps your machine tidy during development.
Everyday container commands
These cover most daily work:
docker ps— list running containers; add-ato include stopped ones.docker logs -f <container>— stream application output.docker exec -it <container> sh— open a shell inside a running container.docker stop <container>— graceful shutdown.docker images— list local images and their sizes.docker system prune— remove unused containers, networks, and dangling images.
When a container exits immediately, read the logs first. The most common causes are a wrong CMD, a missing environment variable, or the process binding to 127.0.0.1 instead of 0.0.0.0 inside the container.
Why Compose exists
A real application usually needs more than one process: an API, a database, maybe a cache. Running three docker run commands with long flag lists is error-prone. Docker Compose lets you declare all services in one YAML file and start them together.
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:Notice that the API connects to db, not localhost. Compose creates a private network where each service is reachable by its service name. This is the single most common source of confusion for newcomers.
Start everything with docker compose up, or docker compose up -d to run in the background. Rebuild after code changes with docker compose up --build. Tear down with docker compose down, and add -v if you also want to delete the named volumes.
Volumes, bind mounts, and live reload
Containers are ephemeral; data inside them disappears when removed. Two mechanisms solve this:
- Named volumes — managed by Docker, ideal for database files. They persist across container restarts and are declared in the
volumes:section. - Bind mounts — map a host directory into the container, ideal for source code during development.
services:
api:
build: .
volumes:
- ./src:/app/srcWith a bind mount, editing files on your host is reflected inside the container. Combine this with a file watcher (nodemon, uvicorn --reload, air) and you get live reload without rebuilding the image.
A practical development workflow
- Write a Dockerfile for your app and keep it minimal.
- Add a
docker-compose.ymlwith the app plus its dependencies. - Use bind mounts for source code and named volumes for data.
- Keep secrets out of the file; use a
.envfile and reference variables with${VAR}. - Add a
.dockerignorecontainingnode_modules,.git, and build output to speed up builds. - Run
docker compose up --buildand iterate.
If your service produces logs you need to inspect later, remember that container logs are just text. Tools that parse server logs, such as an nginx log analyzer, pair well with containers because you can pipe or copy logs out without installing anything on the host.
Common pitfalls
- Fat images: use multi-stage builds to compile in one stage and copy only artifacts into a slim runtime image.
- Rebuilding everything: order Dockerfile instructions from least to most frequently changing.
- Hardcoded hosts: inside Compose, use service names; outside, use environment variables.
- Ignoring cleanup: dangling images and stopped containers accumulate. Run
docker system pruneperiodically. - Running as root: add a non-root
USERin production images.
FAQ
What is the difference between an image and a container?
An image is an immutable template containing a filesystem and metadata. A container is a running instance of that image with an added writable layer. You can start many containers from a single image.
Do I need Docker Compose for a single service?
No. A single docker run command is enough for one container. Compose becomes valuable when you have multiple services, shared networks, or environment variables you want to declare in one place.
How do containers talk to each other?
Compose puts all services on a shared network where each service is reachable by its service name. Use that name as the hostname, for example db:5432, instead of localhost.
Docker rewards a small upfront investment. Once images, containers, and Compose click, onboarding a teammate or switching machines stops being a setup ritual and becomes a single command.