Skip to main content
BlogDevOps

How to Deploy Docker Compose in Production for Beginners

Beginner guide to deploy Docker Compose in production: restart policy, .env, loopback port, memory limit, health check, safe updates and a table of fixes.

Matthews Wong

Software Engineer

October 202610 min read
How to Deploy Docker Compose in Production for Beginners

Key Takeaway

To deploy Docker Compose in production, declare the service with restart: unless-stopped, bind its port to 127.0.0.1, keep settings in a .env file, add mem_limit and a health check, then start it with docker compose up -d --wait. To update, change the image tag, pull and run up again. To roll back, restore the old tag.

Your app runs on your laptop with docker compose up. You copy the file to a server, run the same command, and everything seems fine, until the terminal closes and the app dies with it, or the server reboots and nothing comes back, or you scan your server and find the app port open to the whole internet. A Compose file that is fine for development is missing about five lines that production needs.

This guide builds that file in six steps, using the same choices as the docker-compose.yml behind this site: restart policy, a loopback-only port, a memory limit paired with Node's heap cap, a health check and log rotation. The two examples below are rewritten with placeholder names, so no real host or secret appears. Every flag is checked against the Docker documentation listed at the end. The CI side (building the image and pushing it to a registry) is in the guide to deploying Next.js to a VPS: how to deploy Next.js to a VPS with Docker and GitHub Actions. Follow the steps in order and use the error table at the end when something does not behave.

Want the concepts first?

This post is the hands-on walkthrough. The free lesson explains why each line exists, with an interactive diagram of a container hitting its memory limit: Docker Compose in Production: Limits, Health, Restarts. It also has a short quiz to check what you remember.

What you need

Prepare these four things first. All names below are placeholders: swap in your own.

  • A Linux server with Docker Engine and the Compose plugin installed, reachable over SSH (the examples use a user named deploy and the address 203.0.113.10).
  • An image already pushed to a registry, for example ghcr.io/example-user/my-app with a tag that never changes, such as a short commit hash. If the registry is private, run docker login on the server once.
  • A Node.js app that listens on port 3000 inside the container and answers /robots.txt or any other cheap URL. Adjust the numbers for another language.
  • A folder on the server for the project, for example /home/deploy/my-app, holding exactly two files: docker-compose.yml and .env.

Step 1: write the service with a restart policy and a loopback port

Start with three lines that development files usually skip. restart: unless-stopped restarts the container after any exit and after a server reboot, but leaves it down if you stopped it yourself. The ports line decides who can reach the app: if you leave out the host address, Docker binds to every interface, and its own docs warn this can bypass host firewall rules.

# /home/deploy/my-app/docker-compose.yml
services:
  web:
    image: ghcr.io/example-user/my-app:3f9c2a1
    # Wrong: no policy = the app stays dead after a crash or a reboot
    # Right: restart after any exit, but not after YOU stopped it
    restart: unless-stopped
    ports:
      # Wrong: "3002:3000" binds to every interface (0.0.0.0)
      # Right: host address first, then host port, then container port
      - "127.0.0.1:3002:3000"

# Start it and check the PORTS column
cd /home/deploy/my-app
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:3002

Binding to 127.0.0.1 means only the server itself can reach the app, so a reverse proxy has to sit in front. That is the job of nginx, covered in the nginx reverse proxy guide for Docker and Cloudflare. Until nginx is set up, test from the server itself with curl.

Expected output: docker compose ps lists web with a 127.0.0.1:3002->3000/tcp mapping, not 0.0.0.0. curl against 127.0.0.1:3002 on the server answers, while the same port on the server's public address does not answer from outside.

Docker's port publishing docs warn that an unbound port is reachable from outside the host. A firewall rule you wrote yourself may not stop it, because published-port traffic is routed before those rules. Fix the ports line, do not rely on a rule.

Step 2: move settings into .env and check what Compose resolves

Do not edit the YAML for every release. Put the values that change (the image tag, the host port, the app's secrets) in a .env file next to docker-compose.yml. That one file does two different jobs. Compose reads a .env in the project folder to fill in the dollar-and-braces placeholders inside the YAML, and the env_file line passes the same file's variables into the container. In the interpolation syntax, a colon and a minus give a default: the value is used when set and non-empty, otherwise the default applies.

# /home/deploy/my-app/.env  (never commit this file)
IMAGE=ghcr.io/example-user/my-app:3f9c2a1
APP_PORT=3002
DATABASE_URL=postgres://app:CHANGE_ME@db.internal:5432/app

# docker-compose.yml, using those values
services:
  web:
    # Compose fills the variable from .env; after :- is the fallback
    image: ${IMAGE:-ghcr.io/example-user/my-app:latest}
    restart: unless-stopped
    # env_file passes the SAME variables into the container itself
    env_file: .env
    environment:
      NODE_ENV: production
    ports:
      - "127.0.0.1:${APP_PORT:-3002}:3000"

# Run these from the folder that holds docker-compose.yml
echo ".env" >> .gitignore
docker compose config
docker compose config --quiet && echo "file is valid"

Always run docker compose config before the first start. It prints the file after variables are filled in, so a typo shows up as an empty value instead of a container that behaves strangely. Keep the .env out of git, and read the guide to storing Docker secrets safely before you put real passwords in it: Docker's own docs advise against passing sensitive values through environment variables.

Expected output: The config output shows image with your pinned tag and the ports line as 127.0.0.1:3002:3000. If a variable is missing from .env, Compose warns that it is not set and substitutes a blank value.

Step 3: set a memory limit, and a lower heap cap inside the image

By default a container has no resource limit, so one memory leak can starve everything else on the server. mem_limit sets a hard ceiling, and when the container reaches it the kernel kills the process. Docker then reports exit code 137. For a Node app, pair it with --max-old-space-size, which caps V8's heap. The pair matters because the heap is not the only memory a process uses: runtime and native buffers sit outside it.

# docker-compose.yml, inside services.web
    mem_limit: 512m
    cpus: "1.0"

# Dockerfile, in the final stage. Keep it clearly BELOW mem_limit:
# 384 MB is 75% of 512 MB, leaving room for memory outside the heap.
# Wrong: --max-old-space-size=512 (equal to the limit, the kernel wins first)
ENV NODE_OPTIONS="--max-old-space-size=384"

# After the next start, read real numbers once
docker stats --no-stream

# If a container died, ask Docker whether the OOM killer did it
docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' my-app-web

This site sets mem_limit to 512m and the heap cap to 384, about 75 percent, and its Dockerfile comment records a measured steady state of 130 MB. The heap cap was once set equal to the limit, which would have let the kernel kill the container before V8 could react. Use the same ratio as a starting point, then watch real numbers with docker stats. More image-side habits are in Docker containerization best practices.

Expected output: docker stats --no-stream lists web with MEM USAGE / LIMIT, and the limit column shows the ceiling you set. Right after a start the usage should sit well under it.

Step 4: add a health check and cap the log size

A health check is a command Docker runs inside the container on a schedule. The status starts as starting, becomes healthy on the first pass, and becomes unhealthy after the configured number of failures in a row. Be clear about what it does not do: a restart policy reacts to a process that exited, and a hung process has not exited, so an unhealthy container keeps running. The check is a signal for you and for --wait in the next step, not a repair.

# docker-compose.yml, inside services.web
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/robots.txt"]
      interval: 30s
      timeout: 5s
      retries: 3
      # Failures during the first 20 s do not count: the app is still booting
      start_period: 20s
    logging:
      driver: json-file
      options:
        # Wrong: no options = one log file that grows until the disk is full
        # Right: 3 files of 10 MB each, oldest rotated away
        max-size: "10m"
        max-file: "3"

The logging block fixes a quieter problem. The json-file driver has no size limit by default, so a chatty container slowly fills the disk. Setting max-size and max-file keeps a bounded number of rotated files; this site keeps three files of 10 MB.

Expected output: After the next start, docker compose ps shows (health: starting) first and (healthy) after the first passing check. If it never leaves starting or turns unhealthy, see the table below.

The probe command runs inside the image, so it must exist there. wget works on Alpine-based images because BusyBox ships it; a minimal image without wget or curl needs a different probe, otherwise the check fails forever even though the app is fine.

Step 5: validate, then start it the first time

Run validation first, then start. config --quiet only validates and prints nothing when the file is fine. up -d runs in the background. Adding --wait makes the command wait until the services are running and healthy, with --wait-timeout as the upper bound in seconds. Without it, up -d returns the moment the container starts, so a release that never becomes healthy still looks like a success.

# 1. Validate only: silence means the file is fine
docker compose config --quiet

# 2. Start in the background and wait for running + healthy (max 120 s)
docker compose up -d --wait --wait-timeout 120

# 3. Check status, then the app's own log
docker compose ps
docker compose logs --tail 50 web

If the command fails, do not guess. Read the container's own log first; the next lines show how. The restart policy can hide a crash loop, because the container keeps coming back, so the log is where the real error is.

Expected output: The first command prints nothing. The second finishes once web is healthy, or ends with an error if it is not healthy within the timeout. docker compose logs shows the app's startup lines, such as the address it listens on.

Step 6: update without breaking things, and roll back

An update is three moves: point IMAGE at the new tag, pull, and run up with --wait. Compose recreates the container because its image changed. This is why the tag must not be latest: with an immutable tag such as a commit hash, the previous value in .env is also your rollback. Write down the old tag before you replace it.

cd /home/deploy/my-app

# 1. Write down the tag that works right now
grep '^IMAGE=' .env

# 2. Point IMAGE at the new tag (never "latest")
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:7b1e9d4|' .env

# 3. Pull, then recreate only if the image changed; fail loudly if unhealthy
docker compose pull
docker compose up -d --wait --wait-timeout 120
docker compose ps

# Rollback: put the OLD tag back and run the same up
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:3f9c2a1|' .env
docker compose up -d --wait --wait-timeout 120

# Wrong: this removes containers AND, with -v, the volumes (database data)
# docker compose down -v

Be honest about one limit. With a single container, the old one stops and the new one starts, so there is a short gap. For many small apps that is acceptable; if it is not, read zero-downtime rolling deploys. Note that container_name also stops Compose from running more than one copy of the service, which Docker's docs state directly.

Expected output: up --wait ends after the new container is healthy, and docker compose ps shows the new tag under IMAGE. After a rollback it shows the old tag again.

Do not use docker compose down -v as a way to restart. down stops and removes the containers, which is more downtime than an in-place up, and the -v flag also removes volumes, which is where a database keeps its data.

Common errors and how to fix them

Match the symptom in the first column, confirm the cause, then apply the fix. Always read the container log before changing the file.

SymptomLikely causeFix
The app port opens from the internet by IP addressThe ports line has no host address, so it binds to every interfaceChange it to 127.0.0.1:3002:3000, run up -d again, check docker compose ps
Compose warns that a variable is not set, or the image name is blankThe .env is not in the project folder, or the variable name has a typoRun from the folder holding docker-compose.yml, fix the name, confirm with docker compose config
The container exits with code 137 and restarts again and againIt reached mem_limit and the kernel killed itConfirm with docker inspect, lower the heap cap below the limit or raise the limit, and look for a leak
Status stays unhealthy but the container is still runningA restart policy reacts to an exit, not to a failing checkRead the probe result with docker inspect, fix the app or the probe URL, and use up --wait so a deploy fails loudly
Always unhealthy, though curl on the server worksThe probe tool, such as wget, does not exist inside the imageRun the probe by hand with docker compose exec, then switch to a tool the image really has
The status shows Restarting and never settlesThe app crashes at boot, often a missing or wrong value in .envRead docker compose logs --tail 50 web, fix the value, run up -d --wait again
The disk slowly fills and a log file under /var/lib/docker is hugejson-file has no size limit by default, and max-file only works when max-size is setAdd the logging block with both options, then recreate the container so the new setting applies
Conflict: the container name is already in useAn older container made outside Compose already holds the container_nameRemove that one container once with docker rm -f, then run up -d; Compose owns the name from then on
up --scale web=2 refuses to runcontainer_name is set, so Compose cannot start a second copyRemove container_name if you need more than one copy, and put a proxy in front

When none of the rows match, check from the inside out. These four commands answer most questions, in this order:

# 1. What state is it in, and is it restarting?
docker compose ps

# 2. What did the app say before it died or went unhealthy?
docker compose logs --tail 50 web

# 3. What did the health check see? (the last results)
docker inspect --format '{{json .State.Health}}' my-app-web

# 4. Is the config what you think it is?
docker compose config

For more ways to read container output, see the beginner guide to Docker logs.

Production checklist

  • restart: unless-stopped is set, and I know it will not act on an unhealthy status.
  • Every ports entry starts with 127.0.0.1: when only a proxy should reach it.
  • The image tag comes from .env and is immutable, never latest, and the previous tag is written down.
  • mem_limit is set, and the Node heap cap is clearly below it.
  • A health check exists, its tool exists in the image, and deploys use up --wait.
  • Log rotation is set with both max-size and max-file, and .env is not in git.

Go deeper

You now have a Compose file that survives a reboot, hides its port, limits its memory, reports its health and can be rolled back. The free lesson walks through the same file line by line, with an explainer you can step through and a quiz.

Open the lesson: Docker Compose in Production

Next, put nginx in front of the loopback port, then automate the update with CI.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What does restart: unless-stopped do in Docker Compose?

It restarts the container after any exit and after the Docker daemon or server restarts, but leaves it down if you stopped it yourself. It reacts to the process exiting, not to a failing health check. A hung container that is marked unhealthy keeps running.

Why does Docker Compose expose my port to the internet?

A ports entry without a host address, such as 3000:3000, binds to every interface, and Docker's docs warn this can bypass host firewall rules. Write 127.0.0.1:3000:3000 instead so only the server itself can reach it. Then put a reverse proxy like nginx in front.

Does docker compose up -d wait until the app is healthy?

No. up -d returns as soon as the container starts, so a release that never becomes healthy still looks like a success. Add --wait to wait until the services are running and healthy, and use --wait-timeout to set the limit in seconds.

How do I update a Docker Compose app without breaking it?

Change the image tag in .env, run docker compose pull, then docker compose up -d --wait. Use an immutable tag such as a commit hash, never latest. With one container there is a short gap while the old one stops and the new one starts.

Why does my container exit with code 137?

Exit code 137 is 128 plus signal 9, which means the process was killed, usually because it reached mem_limit and the kernel's OOM killer stepped in. Keep the Node heap cap clearly below the memory limit and watch real usage with docker stats. Then look for a leak.