# 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.

URL: https://www.matthewswong.com/en/blog/cara-deploy-docker-compose-production-vps/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: DevOps

> **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.

Expected output

## 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.

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.

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.

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.

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.

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

.

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.

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.

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.

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.

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.

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.

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.

Symptom

Likely cause

Fix

The app port opens from the internet by IP address

The ports line has no host address, so it binds to every interface

Change 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 blank

The .env is not in the project folder, or the variable name has a typo

Run 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 again

It reached mem_limit and the kernel killed it

Confirm 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 running

A restart policy reacts to an exit, not to a failing check

Read 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 works

The probe tool, such as wget, does not exist inside the image

Run the probe by hand with docker compose exec, then switch to a tool the image really has

The status shows Restarting and never settles

The app crashes at boot, often a missing or wrong value in .env

Read 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 huge

json-file has no size limit by default, and max-file only works when max-size is set

Add the logging block with both options, then recreate the container so the new setting applies

Conflict: the container name is already in use

An older container made outside Compose already holds the container_name

Remove 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 run

container_name is set, so Compose cannot start a second copy

Remove 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:

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.

## Sources and official documentation

- Docker Docs: Compose file services reference (restart, ports, mem_limit, healthcheck, container_name)
- Docker Docs: docker compose up (--wait, --wait-timeout, --remove-orphans)
- Docker Docs: interpolation in Compose files (default values, .env)
- Docker Docs: start containers automatically (restart policies)
- Docker Docs: docker compose config

## Source links

- https://docs.docker.com/reference/compose-file/services/
- https://docs.docker.com/reference/cli/docker/compose/up/
- https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/
- https://docs.docker.com/engine/containers/start-containers-automatically/
- https://docs.docker.com/reference/cli/docker/compose/config/

## FAQ

### 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.

---

- HTML version: https://www.matthewswong.com/en/blog/cara-deploy-docker-compose-production-vps/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-deploy-docker-compose-production-vps/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
