{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"docker-for-developers-stop-it-works-on-my-machine-forever-b5kpu","url":"https://zyvop.com/docker-for-developers-stop-it-works-on-my-machine-forever-b5kpu","title":"Docker for Developers: Stop \"It Works on My Machine\" Forever","subtitle":"A Practical Guide to Containerizing Your App, Writing Clean Dockerfiles, and Running Multi-Service Setups with Docker Compose","tldr":"Docker eliminates the “works on my machine” problem by packaging your app, dependencies, and runtime into portable containers. This guide covers production-grade Dockerfiles, layer caching, multi-stage builds, Docker Compose, volumes, networking, and practical workflows for real applications.","keywords":["Docker","multi-stage builds","dockerfile","Local Development","containerization","containers","Docker Compose","DevOps","nodejs","developer-workflow"],"entities":["ZyVOP","Docker","multi-stage builds","dockerfile","Local Development","containerization","containers","Docker Compose","DevOps","nodejs","developer-workflow"],"keyTakeaways":["Docker gives you reproducible environments, consistent behavior across machines, and a clean way to run complex multi-service applications locally without installing anything directly on your host. The key concepts to internalize: layer caching makes builds fast (copy dependency files before source code), multi-stage builds keep production images lean, .dockerignore prevents bloated build contexts, and Docker Compose orchestrates multi-service apps with a single command. Once Docker becomes part of your workflow, you'll wonder how you ever managed without it. Onboarding new developers, reproducing bugs, running integration tests — all of it gets simpler when the environment is defined in code."],"headings":["Why Docker Matters for Developers","Containers vs Virtual Machines","Writing Your First Dockerfile","Understanding Layer Caching — The Most Important Docker Concept","Multi-Stage Builds: Keeping Production Images Small","The .dockerignore File","Docker Compose: Running Multi-Service Apps Locally","Useful Docker Commands You'll Actually Use","Development vs Production Compose Files","Summary"],"outboundLinks":[],"contentText":"Why Docker Matters for Developers Before Docker, setting up a project on a new machine was a ritual of pain. Clone the repo. Install the right Node version. Oh wait, you need a specific version of PostgreSQL. And Redis. And now your global npm packages conflict with another project. And somehow it works fine on your colleague's MacBook but not on yours. Docker eliminates this entirely. A container packages everything the app needs — the runtime, system libraries, dependencies, configuration — into a single portable unit. Run it on your laptop, your teammate's Linux machine, or a cloud server, and the behavior is identical. The environment is part of the code. For teams, the productivity gain compounds. Onboarding goes from \"follow this 20-step setup guide and pray\" to docker compose up. New developers are productive within minutes, not days. Containers vs Virtual Machines Before going further, it helps to know what a container actually is — because it's not a virtual machine. A virtual machine emulates an entire computer, including its own operating system kernel. It's heavy: VMs take minutes to boot and consume gigabytes of RAM. A container shares the host machine's OS kernel but isolates the application's filesystem, processes, and network. It's lightweight: containers start in seconds and use only the memory your app actually needs. Docker is the tool that creates and manages these containers. A Docker image is a read-only snapshot of a filesystem — your app's code, dependencies, and runtime baked in. A container is a running instance of that image. One image can run as many containers as you want simultaneously. Writing Your First Dockerfile A Dockerfile is the recipe for building your image. It's a text file with a sequence of instructions that Docker executes layer by layer. Here's a basic Node.js Dockerfile: FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . EXPOSE 3000 CMD [\"node\", \"src/index.js\"] Walking through each line: FROM node:20-alpine — Every image starts from a base image. node:20-alpine is the official Node.js 20 image built on Alpine Linux, a minimal distro that keeps the image small (~170MB vs ~1GB for the full Debian-based image). WORKDIR /app — Sets the working directory inside the container. All subsequent commands run from here. COPY package*.json ./ — Copies package.json and package-lock.json into the container before copying source code. This is intentional — keep reading. RUN npm ci — Installs dependencies. npm ci (clean install) is preferred over npm install in Docker because it's faster, deterministic, and respects the lockfile exactly. COPY . . — Copies the rest of your source code into the container. EXPOSE 3000 — Documents that the container listens on port 3000. This doesn't publish the port — it's metadata for whoever runs the container. CMD [\"node\", \"src/index.js\"] — The default command to run when the container starts. Use the array form (exec form) rather than a string — it avoids running your process as a child of a shell, which causes issues with signal handling. Understanding Layer Caching — The Most Important Docker Concept Each instruction in a Dockerfile creates a layer. Docker caches each layer and only rebuilds from the point where something changed. This makes rebuilds dramatically faster — but only if you structure your Dockerfile to take advantage of it. Consider what happens if you copy everything first and then install: # 🚫 Bad — cache busts on every source code change COPY . . RUN npm ci Every time you change a single line of source code, Docker invalidates the cache at the COPY step — and then re-runs npm ci, downloading all your dependencies again. On a project with 500 packages, that's painful. The fix is to copy dependency files first and install, then copy source code: # ✅ Good — dependencies only reinstall when package.json changes COPY package*.json ./ RUN npm ci COPY . . Now when you change source code, Docker uses the cached layer for npm ci and only rebuilds from the COPY . . step onward. Your build goes from 60 seconds to 3 seconds. This pattern applies to any ecosystem. In Python, copy requirements.txt and run pip install before copying source. In Go, copy go.mod and go.sum and run go mod download first. Same principle everywhere. Multi-Stage Builds: Keeping Production Images Small Your development image needs build tools, compilers, dev dependencies, and test frameworks. Your production image needs none of that — just the compiled output and runtime dependencies. Multi-stage builds let you use a heavy image for building and copy only the result into a minimal final image. Here's a TypeScript app with a multi-stage build: # ── Stage 1: Build ──────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build # Outputs compiled JS to /app/dist # ── Stage 2: Production ─────────────────────────────── FROM node:20-alpine AS production WORKDIR /app # Only copy production dependencies COPY package*.json ./ RUN npm ci --omit=dev # Copy compiled output from the builder stage COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD [\"node\", \"dist/index.js\"] The final image contains only the Alpine base, production node_modules, and your compiled code. No TypeScript compiler, no source files, no dev dependencies. A typical image shrinks from 800MB to under 150MB. Smaller images mean faster pulls, smaller attack surfaces, and lower storage costs. The .dockerignore File Just like .gitignore, a .dockerignore file tells Docker what to exclude when copying your project into the image. Without it, you're copying node_modules (hundreds of MB), .git history, test files, and local env files into every build context — which slows down builds and risks leaking sensitive files. # .dockerignore node_modules .git .gitignore *.md .env .env.* dist coverage .nyc_output .DS_Store Dockerfile* docker-compose* Always create this file before your first docker build. It's one of those things that's much easier to add upfront than to chase down why your builds are slow later. Docker Compose: Running Multi-Service Apps Locally Real applications don't run in isolation. They have a database, a cache, maybe a message queue, maybe a background worker. Docker Compose lets you define and run all of these together in a single configuration file. Here's a docker-compose.yml for a Node.js app with PostgreSQL and Redis: services: app: build: . ports: - \"3000:3000\" environment: NODE_ENV: development DATABASE_URL: postgresql://postgres:password@db:5432/myapp REDIS_URL: redis://cache:6379 volumes: - .:/app - /app/node_modules depends_on: db: condition: service_healthy cache: condition: service_started command: npm run dev db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: myapp ports: - \"5432:5432\" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [\"CMD-SHELL\", \"pg_isready -U postgres\"] interval: 5s timeout: 5s retries: 5 cache: image: redis:7-alpine ports: - \"6379:6379\" volumes: postgres_data: A few things worth understanding here: volumes: - .:/app — This mounts your local source code directory into the container at /app. When you edit a file on your host machine, the change is immediately reflected inside the container. Combined with a dev server that watches for file changes (like nodemon), you get hot reloading inside Docker. - /app/node_modules — This is an anonymous volume that prevents the host's node_modules from overwriting the container's. Without this, your macOS node_modules would overwrite the Linux ones inside the container, causing native module failures. depends_on with condition: service_healthy — This tells Docker to wait until the database passes its healthcheck before starting the app. Without this, your app might start before PostgreSQL is ready to accept connections, resulting in a connection error on boot. postgres_data named volume — Database data is stored in a named volume managed by Docker, not in your project directory. This persists across container restarts and docker compose down commands. Running docker compose down -v removes the volumes too — useful for a clean reset. To start everything: docker compose up # Start all services, watch logs docker compose up -d # Start in background (detached) docker compose down # Stop and remove containers docker compose down -v # Stop, remove containers AND volumes (full reset) Useful Docker Commands You'll Actually Use Running commands inside a container: # Open a shell in a running container docker compose exec app sh # Run a one-off command (e.g. database migration) docker compose exec app npm run migrate # Run a command in a new container (not the running one) docker compose run --rm app npm run seed Inspecting what's happening: # Follow logs for all services docker compose logs -f # Follow logs for a specific service docker compose logs -f app # See running containers and resource usage docker stats Cleaning up: # Remove stopped containers, unused networks, dangling images docker system prune # Remove everything including unused images (be careful) docker system prune -a Rebuilding after Dockerfile changes: # Force a rebuild without cache docker compose build --no-cache docker compose up --build Development vs Production Compose Files You'll often want slightly different configurations for development and production — maybe development has volume mounts and a dev server, while production uses the built image. Docker Compose supports this with file layering: # Base config used everywhere docker-compose.yml # Development overrides docker-compose.dev.yml # Production overrides docker-compose.prod.yml Run with a specific override: docker compose -f docker-compose.yml -f docker-compose.dev.yml up Or set COMPOSE_FILE in your .env: COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml This keeps the shared configuration DRY while allowing environment-specific differences. Summary Docker gives you reproducible environments, consistent behavior across machines, and a clean way to run complex multi-service applications locally without installing anything directly on your host. The key concepts to internalize: layer caching makes builds fast (copy dependency files before source code), multi-stage builds keep production images lean, .dockerignore prevents bloated build contexts, and Docker Compose orchestrates multi-service apps with a single command. Once Docker becomes part of your workflow, you'll wonder how you ever managed without it. Onboarding new developers, reproducing bugs, running integration tests — all of it gets simpler when the environment is defined in code.","contentHash":"sha256:56f68844529b8257ffde1291eb9354e1e62b21ba18bf8336f7fc4cb4ed67cc32","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Docker","multi-stage builds","dockerfile","Local Development","containerization","containers","Docker Compose","DevOps","nodejs","developer-workflow"],"audience":"Software engineers and developers building applications with Docker","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":7,"wordCount":1594,"faqs":null,"primaryTopic":"Docker","publishedAt":"2026-05-28T07:21:35.357Z","updatedAt":"2026-08-18T08:40:00.444Z","canonicalUrl":"https://zyvop.com/docker-for-developers-stop-it-works-on-my-machine-forever-b5kpu"}