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:3002Binding 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-webThis 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 webIf 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 -vBe 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.
| 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:
# 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 configFor 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

