Skip to content
Blog Panji

Docker for Developers: A Practical Guide from Dockerfile to Compose

A practical Docker for developers guide: write correct Dockerfiles, slim images with multi-stage builds, and orchestrate dev stacks with Docker Compose.

Panji Ihsanudin Fajri

13 min read1 reads

Blue shipping containers stacked as a Docker for developers concept illustration on a light background
Blue shipping containers stacked as a Docker for developers concept illustration on a light background
Contents
  1. Docker for developers: the problem it actually solves
  2. Containers vs virtual machines
  3. Dockerfile anatomy: the instructions you must know
  4. Writing your first Dockerfile: a Node.js app
  5. Multi-stage builds: slim images without build junk
  6. Docker Compose for everyday dev workflows
  7. The daily workflow: commands you will use every day
  8. Real-world example: API + database + cache stack
  9. Common beginner mistakes
  10. Docker best practices for development environments
  11. FAQ
  12. Is Docker only for deployment, or is it useful while coding?
  13. How much RAM do I need to run Docker?
  14. Does Docker work on Windows and macOS?
  15. What is the difference between docker compose and docker-compose?
  16. When should I not use Docker?
  17. Conclusion

If you have ever heard "but it works on my machine," you already know the problem Docker was built to solve. This docker for developers guide is a hands-on docker tutorial: not a wall of theory, but the Dockerfiles, images, containers, and Compose files you can drop into your daily projects. By the end, you will be able to write your own Dockerfile, run a multi-service stack with a single command, and sidestep the traps that catch most beginners.

Summary: Docker packages your app and its dependencies into one image that runs identically everywhere. You will learn how containers work, the anatomy of a Dockerfile, multi-stage builds for slim images, Docker Compose for everyday dev workflows, the essential commands, a real-world stack example (API + database + cache), common beginner mistakes, and proven best practices.

Blue shipping containers stacked as a Docker for developers concept illustration on a light background
Cover: containers as the standard unit for shipping applications consistently

Docker for developers: the problem it actually solves

Docker is a platform that packages an application together with everything it needs — libraries, runtimes, configuration — into a standard unit called a container, which runs identically on a developer laptop, a staging server, and production. Containers share the host operating system's kernel, so they are lightweight and start in seconds, unlike virtual machines that must boot a full OS every time.

The problem Docker solves is painfully concrete. Without containers, every developer installs different versions of Node, Python, or databases on their laptop, and bugs appear on exactly one machine where nobody can reproduce them. With Docker, the development environment becomes a version-controlled file: anyone who runs the same command gets the same environment. That is why Docker shows up as a required skill in nearly every backend and DevOps job posting today.

Containers vs virtual machines

Technical diagram comparing container and virtual machine architecture side by side
Architecture comparison: VMs carry a full OS, containers share the host kernel

Developers often conflate containers with virtual machines (VMs). Both isolate applications, but the mechanism — and what it means for your daily workflow — is very different.

A VM virtualizes hardware: each VM carries its own kernel and OS on top of a hypervisor. A container virtualizes the operating system: all containers on one host share the same kernel, and isolation is handled by Linux kernel features like namespaces and cgroups. The result is containers measured in tens of megabytes that start in seconds, versus VMs measured in gigabytes that take tens of seconds to minutes to boot.

Aspect Containers Virtual machines
Typical size Tens–hundreds of MB Several GB
Start time Seconds Tens of seconds–minutes
What it ships App + dependencies Full OS + app + dependencies
Isolation Process isolation (namespaces, cgroups) Full hardware isolation
Density per host Dozens–hundreds A few–a dozen
Main use Running apps and services Running a different OS per VM

One caveat: containers do not replace VMs everywhere. If you need a different kernel (say, Linux on Windows without WSL) or hardware-level security isolation, a VM is still the answer. For everyday development — running an API, a database, and a cache locally — containers are almost always faster and cheaper on resources.

Dockerfile anatomy: the instructions you must know

A Dockerfile is a text recipe for building an image. Every instruction creates one layer, and layers are cached so subsequent builds only redo what changed. These are the instructions you will use constantly:

  • FROM — the base image, e.g. node:22-alpine. Always the first line.
  • WORKDIR — the working directory inside the image; everything after runs there.
  • COPY — copies files from your machine into the image.
  • RUN — executes a command at build time, e.g. installing dependencies.
  • ENV — sets an environment variable.
  • EXPOSE — documents which port the app uses (does not publish it automatically).
  • CMD — the default command when the container starts; can be overridden.
  • ENTRYPOINT — the command that always runs; arguments can be appended via docker run.
  • ARG — a variable that exists only at build time, handy for versions or flags.

Instruction order directly affects build speed. Put rarely-changing instructions (dependency installation) as early as possible so their cached layers get reused, and copy frequently-changing source code last.

For the full reference of every instruction, see the official Dockerfile documentation.

Writing your first Dockerfile: a Node.js app

Enough theory. Here is a real Dockerfile for a Node.js app that compiles to a dist directory:

FROM node:22-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

Notice the ordering: package.json is copied and dependencies installed before the source code. That means as long as dependencies do not change, Docker reuses the cached npm ci layer and the build only repeats the final steps. Using npm ci --omit=dev with a lockfile pins exact dependency versions on every build — a small detail that kills an entire class of "works on my machine" bugs.

Build and run it:

# Build the image with a tag
docker build -t myapp:1.0 .

# Run it as a container, mapping host port 3000 to container port 3000
docker run -d --name myapp -p 3000:3000 myapp:1.0

# Stream the logs live
docker logs -f myapp

# Open a shell inside the running container
docker exec -it myapp sh

# Stop and remove the container
docker stop myapp && docker rm myapp

The -d flag runs the container in the background, -p maps ports, and --name gives it an easy reference. logs -f and exec -it are the two debugging commands you will reach for every single day.

Multi-stage builds: slim images without build junk

The Dockerfile above still ships build tooling (the TypeScript compiler, devDependencies at build time) into the final image. A multi-stage build splits building and running into two stages, then copies only the final output across:

# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: slim runtime
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

The final image contains only the Node runtime, production dependencies, and the build output — no TypeScript sources, no compiler, no devDependencies. On real projects this technique routinely cuts image size by 50–80 percent, which means faster pulls in CI and shorter deploys. The full details are in the multi-stage build documentation.

Docker Compose for everyday dev workflows

Running one container with docker run is easy. The pain starts when your app needs a database and a cache: three docker run commands with a dozen flags each, the right startup order, and a network to create by hand. Docker Compose solves this with one declarative YAML file:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      - db
      - cache
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
  cache:
    image: redis:7-alpine

volumes:
  pgdata:

With this file, docker compose up -d starts the API, Postgres, and Redis together on one private network. The api service builds from your local Dockerfile, while db and cache use official images from Docker Hub. The pgdata volume keeps Postgres data alive across container recreation. The complete syntax is covered in the Compose file specification.

Workflow diagram showing source code built into an image and run as containers
Docker workflow: source code is built into an image, then run as containers

The flow is always the same: source code gets built into an image, the image runs as a container, and Compose orchestrates many containers at once. Once this pattern clicks, adding a new service — a message broker, a search engine — is a dozen lines of YAML.

The daily workflow: commands you will use every day

After the initial setup, 90 percent of your Docker interaction is this handful of commands:

# Start everything (rebuild if the Dockerfile changed)
docker compose up -d --build

# Check the status of all services
docker compose ps

# Stream one service's logs live
docker compose logs -f api

# Restart one service after changing code
docker compose restart api

# Open a shell in the db service
docker compose exec db psql -U postgres

# Shut everything down; add -v to also remove data volumes
docker compose down

Three habits separate developers who are comfortable with Docker from those who fight it: always read the logs before guessing (logs -f), give services clear names in the compose file, and run docker compose down at the end of the workday so no orphaned container eats your RAM. Writing precise commands and reading output carefully is the same skill as writing precise prompts for AI — both are about stating intent explicitly to a system that takes you literally.

When a container misbehaves, this diagnosis order saves time: docker compose ps for status, docker compose logs <service> for the error message, then docker compose exec <service> sh for direct inspection. Nine out of ten development issues — port conflicts, unset environment variables, volumes mounting the wrong directory — show up clearly in those three commands.

Real-world example: API + database + cache stack

Let us put it all together in a realistic scenario: you are building a Node.js REST API backed by Postgres and Redis. The project layout is simple — a Dockerfile and compose.yaml at the root, code in src/, and a .env file holding DB_PASSWORD.

First, write the multi-stage Dockerfile from the earlier example. Second, write the compose.yaml with three services. Third, one command:

docker compose up -d --build
docker compose ps

The ps output shows three healthy containers. The API answers on localhost:3000, Postgres on localhost:5432, Redis on localhost:6379 — each isolated, yet connected through Compose's internal network. You can restart the API without touching the database, tear down and rebuild the API without losing data (thanks to the pgdata volume), and a teammate gets an identical environment by running the same command.

Developer setup illustration with application, database, and cache containers running side by side
Typical dev setup: a laptop running app, database, and cache containers together

This is Docker's biggest win for developers: the development environment becomes code. Onboarding a new teammate changes from "follow these 20 wiki steps" to "clone the repo, run docker compose up." It sounds trivial until you have lived it on a team where everyone runs a different OS.

Common beginner mistakes

  1. Copying the entire build context, including node_modules. Without a .dockerignore file, COPY . . drags a giant folder into the build and wrecks layer caching. Always create a .dockerignore with at least node_modules, .git, and your .env files.
  2. Running containers as root for no reason. Official images ship a non-root user; add USER node (for Node images) so the app does not run with full privileges.
  3. Baking secrets into the image. A password or API key added via COPY or ENV in the Dockerfile is embedded in the image layers forever. Use env_file or a secrets manager — never hardcode.
  4. Running many processes in one container. The "API + cron + worker in one container" pattern breaks the one-service-per-container model and makes logs unreadable. Split them into separate Compose services.
  5. Not tagging image versions. docker build -t myapp . defaults to the ambiguous latest tag. Use explicit tags (myapp:1.2.0) so rollbacks and debugging stay sane.
  6. Skipping healthchecks. Without one, depends_on only waits for the container to start, not for the service to be ready. Add a healthcheck to the database so the API does not crash while Postgres is still warming up.

Docker best practices for development environments

  • Use small, specific base images. node:22-alpine is lighter than node:22, and an explicit version tag avoids surprises when latest moves.
  • Exploit layer caching deliberately. Order your Dockerfile from least-frequently-changed to most-frequently-changed. Every cacheable line is seconds saved on every build.
  • Separate dev and production configuration. Use compose.yaml for the base, compose.override.yaml for dev (source mounts, debug ports), and a separate file for production. Compose merges them automatically.
  • Mount source code as a volume during development. Code changes show up instantly without a rebuild — saving dozens of builds per day.
  • Clean up resources regularly. docker system prune removes unused images, containers, and networks. Run it every few weeks so your disk does not fill up silently.
  • Document commands in the README or a Makefile. One make dev wrapping docker compose up -d --build beats expecting everyone to memorize flags.
  • Read the official Dockerfile best practices as a checklist before any image goes to production.

FAQ

Is Docker only for deployment, or is it useful while coding?

It is extremely useful while coding — arguably where Docker shines brightest: running databases, caches, and supporting services without installing anything on your laptop. You keep writing code in your favorite editor while infrastructure dependencies live in containers you can start and stop in seconds.

How much RAM do I need to run Docker?

For a typical dev stack (one API + one database + one cache), 4–8 GB of total system RAM is usually enough. Containers themselves are light; what consumes memory is your applications — Postgres in a container uses the same RAM as native Postgres. Cap per-service memory in Compose if your laptop is tight.

Does Docker work on Windows and macOS?

Yes, via Docker Desktop. On Windows, Docker uses WSL 2 as its backend; on macOS it runs inside a lightweight, automatically managed Linux VM. The docker and docker compose commands you learned in this guide work identically on all three operating systems — one of Docker's core promises.

What is the difference between docker compose and docker-compose?

docker compose (no hyphen) is the modern plugin integrated into the Docker CLI, while docker-compose (with hyphen) is the legacy Python binary that is no longer developed. Always use docker compose — the YAML file syntax is compatible, so there is no reason to use the old one.

When should I not use Docker?

Skip Docker when your app needs tricky direct hardware access (GPU computing with special drivers, though this keeps improving), when one layer of abstraction is not worth it for a throwaway script, or when your team is not ready to maintain Dockerfiles. Docker is a tool, not an obligation — use it where it removes pain, not where it adds it.

Conclusion

Docker for developers comes down to one discipline: treat your environment as code. Write Dockerfiles that exploit layer caching, slim images down with multi-stage builds, orchestrate services with Compose, and drill the handful of daily commands until they are reflex. After that, "works on my machine" stops being a problem — because every machine runs the same thing.

The most sensible next step: take one of your own projects, write a Dockerfile and compose.yaml for it, and bring the whole stack up with a single command. If you want a smarter development workflow with AI assistance, also read our complete guide to prompt engineering — a consistent environment plus precise instructions makes iteration dramatically faster.

Keep reading

Related articles