Developing with Docker: hot reload, debugging, tests and CI
See code changes instantly with bind mounts and Compose watch, attach a debugger, run tests in the image CI uses, and build images in CI with a shared cache.
By the end of this lesson you'll be able to
0 of 5 completeHot reloading
Rebuilding an image for every one-line change is too slow for development. Two ways to get changes into a running container:
A bind mount (lesson 6) shares the folder itself:
$ docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/src":/app/src hello-dev npm run dev
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/src":/app/src hello-dev npm run devdockerbuild and run containersrunstart a new container--rmdelete the container when it exits-p 127.0.0.1:3000:3000publish a port, host:container-v "$PWD/src":/app/srcmount a volume or folder, source:path-insidehello-devthe image to startnpm run devthe command run inside, instead of the image’s default
Mount only the source folder, not the whole project: mounting . over /app hides the node_modules the
image installed for Linux behind your laptop's own.
Compose watch syncs only the paths you list, and knows when a rebuild is needed instead. A small Node app:
# compose.yaml services: app: build: . ports: - "127.0.0.1:3000:3000" develop: watch: - action: sync path: ./src target: /app/src - action: rebuild path: package.json
The image runs node --watch src/server.js, Node's built-in reloader. Then:
$ docker compose watch Watch enabled $ curl -s localhost:3000 hello v1 $ sed -i 's/hello v1/hello v2, synced/' src/server.js # or save in your editor $ curl -s localhost:3000 hello v2, synced $ docker compose logs app | tail -3 app-1 | listening on 3000 app-1 | Restarting 'src/server.js' app-1 | listening on 3000
docker compose watchdockerbuild and run containerscomposerun a multi-container app from compose.yamlwatchsync file changes into running services
curl -s localhost:3000curlmake an HTTP request-ssilent: no progress bar
docker compose logs app | tail -3logsshow a service’s outputappthe service|pipe: send output to the next commandtailshow the last lines
| Action | On change | Use for |
|---|---|---|
sync | copy the files into the container | code, when the app reloads itself (node --watch, uvicorn --reload, Vite) |
sync+restart | copy, then restart the container | config files, apps with no watcher |
rebuild | build a new image and replace the container | package.json, requirements.txt, the Dockerfile |
Compiled languages like Go can use rebuild on the source folder, or a dev image running a watcher such as
air.
Debugging
Most problems are found with tools you already know:
$ docker compose logs -f app # what did it print? $ docker compose exec app sh # look around inside $ docker inspect shop-api-1 --format '{{json .State}}' # exit code, OOMKilled, health log $ docker compose run --rm app env # what configuration does it really get?
docker compose logs -f appdockerbuild and run containerscomposerun a multi-container app from compose.yamllogsshow a service’s output-fkeep followingappthe service
docker compose exec app shexecrun a command in a running serviceshthe command to run inside it
docker inspect shop-api-1 --format '{{json .State}}'inspectshow every detail as JSONshop-api-1the container or image--format '{{json .State}}'print only these fields (Go template)
docker compose run --rm app envrunrun a one-off container for a service--rmdelete the container when it exitsenvthe command to run inside it
For a step-through debugger, start the runtime in debug mode, publish the port on loopback only and attach your editor:
command: ["node", "--inspect=0.0.0.0:9229", "src/server.js"] ports: - "127.0.0.1:9229:9229"
0.0.0.0 inside the container is needed so the published port reaches it; 127.0.0.1 on the host keeps
everyone else out. Python's debugpy and Java's JDWP work the same way. For an image with no shell (lesson
7), attach a tools container to its namespaces:
docker run --rm -it --pid container:api --network container:api alpine:3.22 sh.
Tests in containers
Run the tests in the same image you develop and ship with, so a test that passes on your laptop passes in CI:
$ docker compose run --rm --no-deps app npm test # tests 1 # pass 1 # fail 0
docker compose run --rm --no-deps app npm testdockerbuild and run containerscomposerun a multi-container app from compose.yamlrunrun a one-off container for a service--rmdelete the container when it exits--no-depsdon’t start the services it depends onappthe servicenpm testthe command to run inside it
run --rm starts a one-off container from the service's configuration and removes it afterwards; --no-deps
skips starting the database when the tests don't need it. For integration tests, let Compose start the
dependencies (depends_on with health checks, lesson 9), and run docker compose down -v at the end so
every run starts from a clean database.
A multi-stage Dockerfile can include a test stage, so a failing test fails the build:
FROM build AS test RUN go test ./...
$ docker build --target test .
docker build --target test .dockerbuild and run containersbuildbuild an image from a Dockerfile--target teststop at this build stage.the build context: the files COPY can see
Docker in CI
CI runners start empty every time, so without help every build starts from zero. The CI/CD course builds a full pipeline; the Docker part looks like this in GitHub Actions:
- uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max
cache-from and cache-to store BuildKit's layer cache in GitHub's cache service, so the
dependency-install layers from lesson 7 are reused between runs. mode=max also caches the build stage's
layers, not just the final image's. Other CI systems use a registry cache (type=registry,ref=…:buildcache).
Tag with the commit SHA (lesson 8), scan before pushing (lesson 11), and pin actions to a full commit SHA as
the CI/CD course's security lesson explains.
A two-second edit loop
Containerise a tiny Node server, set up Compose watch so saving a file reloads it without a rebuild, prove it with curl, then run the test suite in the same image with docker compose run.
curl showing your edit within seconds, and the tests passing inside the containerReveal one safe solution
$ mkdir -p src test $ printf "require('node:http').createServer((q, s) => s.end('hello v1\\\\n')).listen(3000, () => console.log('listening on 3000'));\n" > src/server.js $ printf '{ "name": "hello", "scripts": { "dev": "node --watch src/server.js", "test": "node --test" } }\n' > package.json $ printf "require('node:test')('adds', () => require('node:assert').strictEqual(1 + 1, 2));\n" > test/server.test.js $ printf 'FROM node:22-alpine\nWORKDIR /app\nCOPY package.json ./\nRUN npm install --omit=dev\nCOPY . .\nCMD ["npm", "run", "dev"]\n' > Dockerfile $ # write compose.yaml from this lesson, then in a second terminal: $ docker compose watch $ curl -s localhost:3000 hello v1 $ sed -i 's/hello v1/hello v2/' src/server.js && sleep 2 && curl -s localhost:3000 hello v2 $ docker compose run --rm --no-deps app npm test | grep -E '^# (pass|fail)' # pass 1 # fail 0 $ docker compose down
mkdir -p src testmkdirmake a directory-pcreate missing parents, no error if it exists
printf "require('node:http').createServer((q, s) => s.end('hello v1\\\\n')).listen(3000, () => console.log('listening on 3000'));\n" > src/server.js>write output to a file, replacing it
docker compose watchdockerbuild and run containerscomposerun a multi-container app from compose.yamlwatchsync file changes into running services
curl -s localhost:3000curlmake an HTTP request-ssilent: no progress bar
sed -i 's/hello v1/hello v2/' src/server.js && sleep 2 && curl -s localhost:3000&&run the next command only if this one worked
docker compose run --rm --no-deps app npm test | grep -E '^# (pass|fail)'runrun a one-off container for a service--rmdelete the container when it exits--no-depsdon’t start the services it depends onappthe servicenpm testthe command to run inside it|pipe: send output to the next commandgrepfind lines that match a pattern
docker compose downdownstop and remove the containers and network
No rebuild between the two curls: the file was synced and Node restarted itself. Change package.json and
watch Compose rebuild instead.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
What does a Compose watch rule with action sync do when you save a file?
Official reading for this lesson
Or get every quiz answer right and it completes itself.