Skip to main content
DevOps from zero · Lesson 5 of 8 · 13 min

Docker Compose in Production: Limits, Health, Restarts

Run Docker Compose in production safely: a restart policy, a memory limit paired with Node's heap cap, a health check, and a port bound to localhost.

By Matthews WongReviewed on

Key takeaway

Docker Compose in production is one YAML file that declares how a container runs on a server: its image, a restart policy, memory and CPU limits, a health check, log rotation and which ports are exposed. Each of those lines answers one question: what happens when the app crashes, leaks memory or stops answering.

You will be able to

  • Read a production Compose file and say what each line does when the app crashes, leaks memory or hangs.
  • Pair a container memory limit with Node's heap cap, so the kernel's OOM killer is the last resort.
  • Publish a port on 127.0.0.1 only, and know what a health check does and does not do.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One container under memory pressure

A container with a 512 MB limit leaks memory outside the V8 heap, the kernel OOM-kills it with exit code 137, the restart policy starts it again, the health check takes it from starting to healthy, and a hung app turns unhealthy without being restarted.

Step 1 of 7. One container under a memory limit. The kernel enforces the limit, the Docker engine applies the restart policy, and a health check probes the app.

Keys: ← → step · Home / End · K play or pause

  1. One container under a memory limit. The kernel enforces the limit, the Docker engine applies the restart policy, and a health check probes the app.

  2. NODE_OPTIONS caps the V8 heap at 384 MB, below the 512 MB limit. Near that cap, V8 spends more time collecting garbage, leaving headroom for memory outside the heap.

    ENV NODE_OPTIONS="--max-old-space-size=384" mem_limit: 512m
  3. Buffers and image processing live outside the heap, so the heap cap cannot stop them. The container's total memory climbs until it reaches the 512 MB limit.

    Predict the next step Total memory reaches the 512 MB limit. What happens next? (V8 garbage-collects the off-heap memory · The kernel kills it: exit 137 · Docker raises the limit for it)
  4. The limit is hit and nothing more can be reclaimed, so the kernel's OOM killer ends the process with SIGKILL. Docker records exit code 137: 128 plus 9.

  5. restart: unless-stopped starts the container again after any exit, unless you stopped it yourself. The fresh start resets the health status to starting.

    restart: unless-stopped
  6. Every 30 seconds Docker runs wget against /robots.txt inside the container. The first check that passes flips the status from starting to healthy.

    test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/robots.txt"] interval: 30s timeout: 5s retries: 3 start_period: 20sPredict the next step The app hangs and fails three checks. Does Docker restart it? (Yes, unless-stopped restarts it · No, it stays up, unhealthy)
  7. If the app hangs, three failed checks in a row mark it unhealthy. Restart policies react to exits, so Docker leaves an unhealthy container running.

    retries: 3

What does a production Docker Compose file declare?

A Compose file is YAML that declares how containers run: the image, environment, ports, limits, health check and logging. This site has one service, web. CI builds and pushes the image, copies the file to the server and writes a .env beside it, so the server only runs docker compose pull and up, and never builds anything itself.

Read more (1)

restart: unless-stopped restarts the container whatever its exit code, but leaves it down if you stopped it yourself, even after the Docker daemon restarts. container_name keeps the existing container's name, so each deploy replaces it in place. Docker's reference states the cost: Compose does not scale a service beyond one container if the file sets a container_name.

How do mem_limit and Node's heap cap work together?

By default a container has no resource constraints, so one leak can eat the memory every other service on the server needs. mem_limit: 512m sets a hard ceiling that the kernel enforces through the container's cgroup. When memory reaches the limit and cannot be reclaimed, the OOM killer is invoked inside that cgroup. The process dies by SIGKILL, and Docker reports exit code 137, which is 128 plus signal number 9.

Read more (2)

Node's heap is the other half. --max-old-space-size caps V8's old space, and Node's documentation states the effect: as memory approaches the limit, V8 spends more time on garbage collection. The cap covers the heap only. Buffers and native memory, such as the buffers used when next/image resizes a cover, sit outside it, so the heap cap has to stay well below the container limit.

This site learned that in July 2026. The heap cap was 512 MB, the entire container budget, so under real pressure the kernel would have killed the container before the cap did anything. It never fired, but it would have been useless the first time it mattered. The fix set 384 MB, about 75 percent of the limit. Measured steady state in production is 130 MB, so the cap is a safety valve, not a squeeze.

What does a Docker health check do, and what does it not do?

A health check is a command Docker runs inside the container on a schedule. This site's check runs BusyBox wget against /robots.txt every 30 seconds with a 5 second timeout. The status starts as starting, and the first passing check makes it healthy. Three failures in a row make it unhealthy, and failures during the 20 second start period do not count.

Read more (1)

What it does not do is fix anything. Restart policies react to the process exiting, and a hung process has not exited. In a test on Docker Engine 29.8.1, a container whose check always failed stayed running and unhealthy, with zero restarts. Something else has to act on the status: an uptime monitor that alerts you, or a deploy that waits for healthy before it calls itself done.

Why bind the container port to 127.0.0.1?

A port published without a host address binds to every interface, and Docker's documentation calls publishing insecure by default: the port becomes reachable from the outside world, not only from the host. It also goes around the firewall you might expect to protect it. Docker's docs say Docker and ufw are incompatible, because traffic to published ports is routed before ufw's rules apply.

Read more (1)

So this site publishes 127.0.0.1:${APP_PORT:-3002}:3000. Only processes on the server itself can reach the app, and nginx, which terminates TLS, is the single way in. ${APP_PORT:-3002} is Compose interpolation: the value of APP_PORT from .env when it is set and not empty, otherwise 3002.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

docker-compose.yml:8-11
    # Matches the existing container so deploys UPDATE it in place (no duplicate).
    container_name: matthewswong-portfolio
    restart: unless-stopped
    env_file: .env

The restart policy and the trade behind container_name. Updating in place means no duplicate container after a deploy, and also that Compose cannot run a second copy of the service. The runtime secrets come from .env, which CI writes on the server and git never sees.

docker-compose.yml:24-28
    ports:
      # Bind to loopback only — nginx terminates TLS and proxies to this port.
      - "127.0.0.1:${APP_PORT:-3002}:3000"
    mem_limit: 512m
    cpus: "1.0"

Loopback-only publishing, then the limits. mem_limit is the hard ceiling the kernel enforces. cpus: "1.0" caps the container at one CPU's worth of processing time, however many the host has.

Dockerfile:47-56
# This must stay meaningfully BELOW the `mem_limit: 512m` in docker-compose.yml.
# Resident set is heap plus non-heap — the Node runtime, and libvips buffers
# whenever next/image resizes a cover — and V8 only runs its final aggressive
# GC as it approaches this ceiling. Setting it to the full 512 MB (as this
# briefly did) means the kernel OOM-kills the container before the cap ever
# takes effect, which is the opposite of the intent. 384 MB is ~75% of the
# limit, leaving ~128 MB of headroom for everything off-heap.
#
# Measured steady state in production: 130 MB RSS.
ENV NODE_OPTIONS="--max-old-space-size=384"

The other half of the memory limit lives in the image, not in Compose. The comment records the July 2026 mistake and the arithmetic of the fix: about 384 MB for the heap and about 128 MB for everything else. Change the limit in docker-compose.yml and this line has to change with it.

docker-compose.yml:29-47
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/robots.txt",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

The probe and the log rotation. wget is available because the Alpine base ships BusyBox; a distroless base would need a different probe. The logging block caps the json-file log at three files of 10 MB, about 30 MB in all, where Docker's defaults are unlimited size and one file.

What breaks

Mistakes that cost real time

  • A heap cap equal to the memory limit

    This site's own July 2026 mistake. With --max-old-space-size at 512 and mem_limit at 512m, the heap could claim the entire budget, and anything off the heap would push the container over first. Keep the heap cap well below the limit, and change both together.

  • What I'd change: a deploy that does not wait for healthy

    This site deploys with docker compose up -d, which returns as soon as the container starts. A release that never passes its health check still ends in a green workflow. docker compose up --wait waits for running and healthy and fails otherwise. It reports the problem; it does not roll back, which is lesson 8.

  • Publishing "3000:3000" on a VPS

    Without a host address the port binds to every interface, and published-port traffic is routed before ufw's rules run. The app is then reachable from the internet, around nginx and around TLS. Put 127.0.0.1: in front of every port that only a local proxy should reach.

  • Logs with no rotation

    The json-file driver defaults to max-size -1, unlimited, and max-file only works when max-size is set. A chatty container then grows its log file until the disk fills. Set both, per service or in the daemon's defaults.

  • A volume over a missing mount point

    This site mounts a named volume at /app/.next/cache/images to keep next/image's cache across deploys, and its comment expects Docker to give the volume uid 1001. The research for this lesson found it most likely never works: an empty volume takes its owner from the image's directory only when that directory exists, and the runner never creates it. A test on Docker Engine 29.8.1 showed the volume root-owned and unwritable by uid 1001. The rule: create the mount point in the image, owned by the runtime user, before USER.

Which restart policy does what
no (default)Stays downStays downStays down
on-failureRestartsStays downStays down
alwaysRestartsRestartsStarts again
unless-stoppedRestartsRestartsStays down
Try it yourself

Do it on your own machine

You need Docker and curl, nothing else. Run a tiny server under a 128 MB limit, make it leak on purpose, and watch the kernel and Docker react.

  1. Create a server with a health route and a deliberate leak, and a Dockerfile that runs it as the image's node user.

    // server.js
    const { createServer } = require("node:http");
    const chunks = [];
    
    createServer((req, res) => {
      if (req.url === "/health") return res.end("ok\n");
      if (req.url === "/leak") {
        // Deliberately broken: 16 MB more every 100 ms, outside the V8 heap.
        setInterval(() => chunks.push(Buffer.alloc(16 * 1024 * 1024, 1)), 100);
        return res.end("leaking...\n");
      }
      res.end("hello\n");
    }).listen(3000, "0.0.0.0");
    
    # Dockerfile
    FROM node:22-alpine
    WORKDIR /app
    COPY server.js .
    USER node
    CMD ["node", "server.js"]
  2. Write compose.yaml with a restart policy, a loopback port, limits, a health check and log rotation.

    services:
      web:
        build: .
        restart: unless-stopped
        ports:
          - "127.0.0.1:8080:3000"
        mem_limit: 128m
        cpus: "0.5"
        healthcheck:
          test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/health"]
          interval: 5s
          timeout: 3s
          retries: 3
          start_period: 5s
        logging:
          driver: json-file
          options:
            max-size: "1m"
            max-file: "3"
  3. Start it and wait for healthy. ps shows (healthy), and stats shows the 128 MiB limit.

    docker compose up -d --build --wait
    docker compose ps
    docker stats --no-stream
    curl http://127.0.0.1:8080/
  4. Wait ten seconds, trigger the leak, then read the events. Expect oom, then die 137, then start, and RestartCount 1.

    curl http://127.0.0.1:8080/leak
    docker events --since 2m --until 0s --filter container=$(docker compose ps -q web) \
      --format '{{.Action}} {{index .Actor.Attributes "exitCode"}}'
    docker inspect -f 'restarts={{.RestartCount}}' $(docker compose ps -q web)
  5. Prove that unhealthy is not restarted: a container whose check always fails stays up, unhealthy, with zero restarts.

    docker run -d --name sick --restart unless-stopped \
      --health-cmd false --health-interval 2s alpine:3 sleep 600
    # about 15 seconds later:
    docker inspect -f '{{.State.Health.Status}} restarts={{.RestartCount}}' sick
    docker rm -f sick
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.Your container keeps dying with exit code 137, yet the V8 heap never gets near its 384 MB cap. What is the most likely cause?
  2. 2.A container has restart: unless-stopped. You stop it with docker stop for maintenance, and then the server reboots. Is it running afterwards?
  3. 3.You set max-file to 3 on a chatty container's json-file logs, but not max-size. Weeks later the disk is full. Why?
Think it through

Answer in your head, then check

  1. 1.The app's memory reaches the 512 MB limit and cannot be reclaimed. What happens, and what brings it back?Show the answer

    Answer:The kernel's OOM killer ends the process with SIGKILL, Docker records exit code 137 (128 plus 9), and restart: unless-stopped starts it again. docker events shows oom, die 137, then start.

  2. 2.The health check has said unhealthy for ten minutes. Has Docker restarted the container?Show the answer

    Answer:No. A failing check changes the status, but restart policies act only when the process exits. A monitor, an alert or a deploy that waits for healthy has to act on it.

  3. 3.Why set --max-old-space-size=384 and not 512 when the limit is 512 MB?Show the answer

    Answer:Because the process also uses memory outside the V8 heap. With a heap cap equal to the limit, the kernel kills the container before V8's garbage collection under pressure can help.

Go deeper

Related deep dives on this site