Learn Docker - Docker for Development: Hot Reload, Debugging & Dev Containers
Series/Learn Docker/Episode 21
Episode 21 of 28

Learn Docker - Docker for Development: Hot Reload, Debugging & Dev Containers

Turning Docker from a slowly-built box into a smooth development machine: binding source code mounts with hot reload without rebuilds, dev tooling as profile-based Compose services, container debugging commands, VS Code Dev Containers, docker compose watch, and permission traps.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After bringing applications to production in episode 20 — reverse proxy, TLS, zero-downtime deployment — this time we step back one pace to the workbench: how Docker is used as a day-to-day development environment. Many people only try Docker at the deploy stage, then get disappointed because the development workflow feels slow: every code change means rebuilding the image, restarting the container, waiting. But that paradigm is wrong.

The mental key to understand from the start: images for production, bind mounts for development. When debugging in production, you wait for a build because images are immutable. But on a laptop, you don't need to change the image to change code — just map the source code folder directly into the container (a bind mount), and the application inside will see file changes instantly, exactly like running on a normal host. This turns Docker from a "slowly-built box" into a "reproducible development machine": your team's environment is identical, while the workflow stays as fast as container-free development.

Main Discussion

Bind Mount + Hot Reload: Change Code, No Rebuild

The core of the modern development workflow is hot reload: the application inside the container automatically restarts or reloads when files change. The right combination is a bind mount (so file changes are visible) plus a process manager/watch inside the container (so the application follows along). The tool choice depends on the stack:

  • Node.js + Nodemon: nodemon --watch src server.js — restarts the process when files in src change.
  • Vite / Next.js dev: npm run dev already includes built-in HMR; no extra tool needed.
  • Go: go run -watch . (Go 1.24+) recompiles automatically, or community tools like air for older versions.

An example dev service for a Vite frontend application:

compose.yaml (service dev)
services:
  app:
    image: node:22-alpine
    working_dir: /app
    command: sh -c "npm ci && npm run dev"
    ports:
      - "5173:5173"
    volumes:
      - ./:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - VITE_API_BASE=http://localhost:8000
    depends_on:
      - db
      - redis

The two volumes above are the decisive detail. ./:/app is the bind mount — your project folder is linked directly into the container, so code changes in the editor are instantly visible without a rebuild. The second line, /app/node_modules, is an anonymous volume that "covers" node_modules in the host folder. This isn't coincidence, and we'll dissect it fully in the pitfalls section.

Note also that there's no build: or built image — the node:22-alpine image is enough as a development runtime because all code comes from the bind mount. This contrasts with the "rebuild the image on every code change" mentality that's often the reason people don't want to use Docker for development.

Dev Tooling as Services with Profiles

A development environment is rarely complete with one service. Databases, caches, and supporting tools like a fake mail server or a database admin are part of the workbench. Rather than installing them on the host (versions can differ between team machines), make them all Compose services. In episode 11 we learned profiles; now here's where to use them — tools only needed during development shouldn't come up in production:

compose.yaml (lengkap dengan profiles)
services:
  app:
    image: node:22-alpine
    working_dir: /app
    command: sh -c "npm ci && npm run dev"
    ports:
      - "5173:5173"
    volumes:
      - ./:/app
      - /app/node_modules
    depends_on:
      - db
      - redis
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: app_dev
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
 
  mailhog:
    image: mailhog/mailhog:latest
    profiles: ["tools"]
    ports:
      - "1025:1025"
      - "8025:8025"
 
  pgadmin:
    image: dpage/pgadmin4:latest
    profiles: ["tools"]
    environment:
      PGADMIN_DEFAULT_EMAIL: dev@example.com
      PGADMIN_DEFAULT_PASSWORD: dev
    ports:
      - "5050:80"
    depends_on:
      - db
 
volumes:
  pgdata:

The db, redis, and app services come up with a regular docker compose up -d. Meanwhile mailhog (a fake SMTP server for testing email) and pgAdmin only come up when truly needed: docker compose --profile tools up -d. The result: a one-file development configuration for the whole team, and the production workflow (episode 22) can use a separate compose file that doesn't include this tooling at all.

Reading Container State: Debugging Commands

Development isn't always smooth; what distinguishes an experienced engineer is the ability to systematically read the container's state. Docker provides a set of inspection commands that reveal what's happening inside a container without touching its code.

Inspeksi runtime & interaksi
# Masuk ke shell kontainer yang berjalan (inspeksi langsung)
docker exec -it app sh
 
# Jalankan perintah sekali tanpa masuk shell
docker exec app node -e "console.log(process.env.DB_HOST)"
 
# Salin file keluar dari kontainer (misal log, dump)
docker cp app:/app/logs/app.log ./logs/
 
# Salin file ke dalam kontainer (hati-hati: tidak persisten)
docker cp ./.env.local app:/app/.env.local

docker exec is the main entrance: you can inspect environment variables, confirm dependencies are installed, or trace running processes. docker cp is useful for pulling artifacts out (logs, migration database dumps) or pushing temporary files in — remember that changes via docker exec/docker cp are lost when the container is recreated, because they go into the writable layer, not the image or a volume.

Layer image & perubahan kontainer
# Lihat layer-layer penyusun image dan ukurannya
docker image history my-app:1.0
 
# Lihat hash layer lengkap tanpa dipotong
docker image history --no-trunc my-app:1.0
 
# Bandingkan filesystem kontainer dengan image asalnya
docker diff app

docker image history shows the Dockerfile commands that formed each layer — episode 23 will dissect this deeper. Meanwhile docker diff answers "what changed inside this container since it was born?" with prefixes A (added), C (changed), and D (deleted) per file. This is very useful for proving that a service writes files you didn't expect, such as cache or logs inside the writable layer.

Monitor real-time
# Attach stdin/stdout ke proses utama kontainer (hati-hati: kirim SIGINT)
docker attach app
 
# Penggunaan resource real-time (CPU, memori, I/O)
docker stats
 
# Proses yang berjalan di dalam kontainer
docker top app
 
# Log dengan rentang waktu dan batas baris
docker logs --since 10m --tail 100 -f app

docker stats is the per-container resource consumption dashboard — the first answer when suspecting a memory leak. docker top shows processes inside the container's namespace, not just the main PID. docker logs is the most-used debugging tool: the combination of --since (time range), --tail (line limit), and -f (follow) makes finding errors in long logs fast.

Warning

Be careful with docker attach: it connects your stdin/stdout to the container's main process, so pressing Ctrl+C sends SIGINT to that process — not just exits the session. If the process doesn't handle signals well, the container can stop. For merely "getting into a container", docker exec -it app sh is far safer.

VS Code Dev Containers: A Reproducible Environment

Environment incompatibility between machines is the classic source of "works on my machine". Dev Containers solve it radically: the entire development environment — runtime, tools, VS Code extensions, even ports — is defined in code and run inside a container. VS Code recognizes the .devcontainer/devcontainer.json file and automatically reopens the workspace inside that container.

.devcontainer/devcontainer.json
{
  "name": "app-dev",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "22"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "forwardPorts": [5173, 8000, 5432],
  "mounts": [
    "source=${localWorkspaceFolder}/secrets,target=/workspaces/app/secrets,type=bind"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.formatOnSave": true,
        "typescript.tsdk": "node_modules/typescript/lib"
      },
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "ms-azuretools.vscode-docker"
      ]
    }
  },
  "postCreateCommand": "npm ci && npm run typecheck",
  "remoteUser": "node"
}

Dissecting the key elements:

  • image + features: the same base image for everyone; features are official modules for adding runtimes (Node 22) and docker-in-docker capability without writing a manual Dockerfile.
  • mountWorkspaceFolder (default true): the project folder is mounted into the container so your files sync automatically; mounts adds extra bind mounts (for example a secrets folder that must not go into git).
  • forwardPorts: ports inside the container are automatically forwarded to the host, just like -p.
  • customizations.vscode: VS Code settings and extensions are installed inside the container — every team member automatically gets the same extensions and configuration, eliminating "why is the formatting different" on every machine.
  • postCreateCommand: a command run once when the container is first created (dependency installation, typecheck). Guarantees "fresh clone works immediately".

Its greatest value isn't convenience, but consistency with CI: the code you write in a dev container is tested in the same environment as the CI/CD pipeline from episode 19. Bugs arising from Node version differences between a laptop and CI disappear by themselves.

Docker Compose Watch: Managed File Synchronization

A bind mount already syncs files, but sometimes you want to control what syncs and when. docker compose watch adds explicit rules: sync certain files into the container, and rebuild the image when files that define the application's structure change.

compose.yaml (dengan watch)
services:
  app:
    image: node:22-alpine
    working_dir: /app
    command: sh -c "npm ci && npm run dev"
    volumes:
      - ./:/app
      - /app/node_modules
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - "**/node_modules"
            - "**/dist"
        - action: rebuild
          path: package.json

The action: sync rule copies file changes from ./src to the target inside the container directly — more economical than a full bind mount because only relevant files are watched, and ignore can exclude large directories. The action: rebuild rule is for files that "define the image's shape" like package.json — when dependencies change, the image must be rebuilt, not just synced. Run it with docker compose watch and leave the terminal open; every code change is automatically synced and the application hot-reloads.

Tip

docker compose watch is a refinement, not a replacement for bind mounts. For most projects, a bind mount + hot reload is enough. Watch is useful when a bind mount isn't adequate — for example developing container images that run a daemon (not a web application), where "sync files" and "rebuild image" need to be explicitly distinguished.

Docker Development Pitfalls

  1. UID/GID mismatch (permissions). Files created inside a container are often owned by root, so they can't be edited from an editor on the host (running as a normal user), and conversely host-created files can't be written by a non-root container. The most practical Compose solution: set user: "${UID:-1000}:${GID:-1000}" on the dev service, or use an image that exposes a non-root user (like the node user on official Node images) and align work folder permissions. In a dev container, remoteUser handles this transparently.

  2. node_modules in a bind mount vs inside the container. This is the hardest trap. When a bind mount covers the /app folder with the host folder, node_modules inside the image disappears — the container uses the host's node_modules. The problem: native binaries like esbuild or sharp are compiled per platform: a npm ci node_modules from macOS won't run in a Linux container. That's why the - /app/node_modules (anonymous volume) pattern is used: Docker covers that folder inside the container, hiding the host node_modules, so npm ci inside the container produces binaries that match the Linux container. Every time dependencies change, re-run npm ci inside the container (or use a watch action: rebuild).

  3. Polling vs native filesystem events. Docker Desktop on macOS/Windows and remote filesystems (NFS, WSL2, network drives) often don't forward file change events (inotify) reliably into containers — the result: hot reload goes "silent" even though the code has changed. The solution is to switch to polling: set CHOKIDAR_USEPOLLING=true (Nodemon/Chokidar), VITE_USE_POLLING=true (Vite), or --watch-poll (Go). The price paid: polling consumes CPU, so enable it only in environments that genuinely have problems. On native Linux, not needed.

Conclusion

In this episode 21 we flipped the paradigm: Docker isn't just a production tool, but a development machine that makes the whole team's environment identical. We learned bind mounts + hot reload (without rebuilds), dev tooling as profile-based Compose services, container debugging commands (docker exec, docker cp, docker diff, docker stats, docker top, docker logs), reproducible VS Code Dev Containers, and docker compose watch for managed synchronization. We also dissected three key pitfalls: UID/GID mismatch, node_modules that must be isolated via an anonymous volume, and polling vs native filesystem events.

Core takeaways:

  • Images for production, bind mounts for development — don't rebuild just to change code.
  • Dev tooling (mailhog, pgAdmin) in profile-based services, not on the host, and not in production.
  • Master docker exec, docker logs, docker stats to read container state.
  • Dev Containers make environments reproducible and consistent with CI.
  • The - /app/node_modules anonymous volume and CHOKIDAR_USEPOLLING are two frontend workflow savers.

In the next episode, episode 22, we'll assemble everything into one: a production-grade architecture case study — frontend, API, PostgreSQL, Redis, Traefik, and monitoring (cAdvisor + Prometheus + Grafana) in one complete production Compose with a production-readiness checklist and real-world troubleshooting. See you in episode 22!