DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    DockerLesson 13 of 14
    Your progress · 0%
    On this page
    1. Hot reloading
    2. Debugging
    3. Tests in containers
    4. Docker in CI
    ↖ Course roadmap
    Lesson 13 · Developer experience

    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.

    35 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 5 complete

    Hot 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 dev
    • dockerbuild and run containers
    • runstart 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-inside
    • hello-devthe image to start
    • npm 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 watch
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • watchsync file changes into running services
    curl -s localhost:3000
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker compose logs app | tail -3
    • logsshow a service’s output
    • appthe service
    • |pipe: send output to the next command
    • tailshow the last lines

    Code is synced in and reloaded by the app; dependency changes rebuild the image.© Animation: DevOps Academy

    Code is synced in and reloaded by the app; dependency changes rebuild the image.© Animation: DevOps Academy
    ActionOn changeUse for
    synccopy the files into the containercode, when the app reloads itself (node --watch, uvicorn --reload, Vite)
    sync+restartcopy, then restart the containerconfig files, apps with no watcher
    rebuildbuild a new image and replace the containerpackage.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 app
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • logsshow a service’s output
    • -fkeep following
    • appthe service
    docker compose exec app sh
    • execrun a command in a running service
    • shthe command to run inside it
    docker inspect shop-api-1 --format '{{json .State}}'
    • inspectshow every detail as JSON
    • shop-api-1the container or image
    • --format '{{json .State}}'print only these fields (Go template)
    docker compose run --rm app env
    • runrun a one-off container for a service
    • --rmdelete the container when it exits
    • envthe 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.

    A debug port is a remote shell

    Anyone who can reach a debugger port can run code in your process. Publish it on 127.0.0.1 only, and keep debug flags in the development Compose file, never in the production image.

    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 test
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • runrun a one-off container for a service
    • --rmdelete the container when it exits
    • --no-depsdon’t start the services it depends on
    • appthe service
    • npm 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 containers
    • buildbuild 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.

    In production

    Keep development settings (watch, bind mounts, debug ports) in compose.yaml or a compose.override.yaml that production never uses. The image CI builds and tests is the exact image you deploy: never rebuild it for production.

    ⌘
    Mini mission

    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.

    Input: Docker with the Compose pluginOutput: curl showing your edit within seconds, and the tests passing inside the container
    Reveal 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 test
    • mkdirmake 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 watch
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • watchsync file changes into running services
    curl -s localhost:3000
    • curlmake 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 on
    • appthe service
    • npm testthe command to run inside it
    • |pipe: send output to the next command
    • grepfind lines that match a pattern
    docker compose down
    • downstop 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    What does a Compose watch rule with action sync do when you save a file?

    Go deeper

    Official reading for this lesson

    • DocsDocker Docs: Compose watchDocker Docs ↗
    • DocsDocker Docs: cache storage backendsDocker Docs ↗
    • DocsDocker Docs: GitHub Actions with DockerDocker Docs ↗
    • DocsNode.js: debugging guideNode.js ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousThe Docker CLI day to day: inspect, filter, format and clean upNext lesson →Deploying containers: PaaS, Docker Swarm and Kubernetes