SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Docker Compose cheat sheet for real servers

The Compose commands you reach for every day, grouped by job: lifecycle, applying changes, logs, shells, networks, volumes, and cleanup that is safe.

The Compose commands you actually reach for

Docker Compose ships more than forty subcommands. Daily work on a server uses about a dozen. This page groups those by the job you are doing, gives one plain reason for each, and points at the deep dive when a command hides a trap.

Everything here uses Compose V2: docker compose with a space, not the old docker-compose script. V2 is a Go plugin that installs with Docker Engine, and V1 is gone from current packages, so a docker-compose: command not found on a fresh Ubuntu box as of July 2026 is expected rather than broken. Check with docker compose version. If that prints nothing, install the docker-compose-plugin package.

Every command below runs from the directory that holds your compose.yaml, because Compose takes the project name from that directory and finds the file relative to it. Run the same command one level up and Compose stops with no configuration file provided: not found. If the file format itself is new to you, start with a first Compose file on a VPS and come back here for the commands.

Lifecycle: the four you type, and the one that removes containers

docker compose up -d
docker compose up -d --wait
docker compose stop
docker compose start
docker compose restart web
docker compose down

up -d creates the network, creates the containers, starts them, and returns. It returns as soon as the containers are created, which is why a deploy script that follows it with a curl probe often fails on the first try. up -d --wait blocks until every service that declares a healthcheck reports healthy, and exits non-zero if one never gets there. The flag is only as good as the check behind it, so write a healthcheck Compose can trust before you lean on it in automation.

stop halts the containers and keeps them, so start brings the same containers back with the same writable layer. down stops them and then removes the containers and the project network. Anything written inside the container and outside a volume goes with them. That is the most expensive misunderstanding in Compose, and the full difference between down and stop covers where it bites.

restart is not a reload. It stops and starts the same container with the configuration it already has, so a changed environment variable, a new image tag or an edited port mapping has no effect at all. To apply a file change you run up -d again. Compose compares each service against its running container and recreates only the ones whose configuration changed.

Applying a change: recreate, pull, or rebuild

docker compose up -d --force-recreate
docker compose pull && docker compose up -d
docker compose build --no-cache web
docker compose up -d --build web

up -d on its own does nothing when nothing changed, which is what makes it safe to run repeatedly. --force-recreate overrides that comparison and replaces every container even when the configuration is identical, so it is the quickest way to clear odd in-container state.

Updating an image takes two commands because they do two different things. pull downloads the current image for each tag named in the file. up -d then sees that the service's image ID no longer matches its running container and recreates it. Skip the pull and up -d keeps last month's latest running with no error.

build applies to services that declare a build: section instead of an image:. up -d --build builds and starts in one step, which is the normal loop while you are changing code. Reach for --no-cache only when a cached layer is clearly stale, because it rebuilds every layer from scratch.

Seeing what is running

docker compose ps
docker compose ps -a
docker compose logs -f --tail=100
docker compose logs --since 15m --timestamps db
docker compose top
docker compose ls

ps lists running containers only. A service that crashed during start is invisible there until you add -a, so a container missing from ps while ps -a shows it as Exited (1) is the normal shape of a startup failure. Read the exit code, then read the logs.

logs -f follows every service at once and prefixes each line with the service name, which is the view you want when services talk to each other and the order of events matters. Name a service to narrow it. --tail=100 matters on a container that has been up for a month, because the default prints the entire history and floods the terminal. --since 15m answers the question you usually have, which is what happened during the restart you just did.

top lists the processes inside each container, which separates "the container is running" from "the process inside it is running". ls steps outside the current directory and lists every Compose project on the host with its status, so you can find the stack you started three months ago.

Getting a shell inside a service

docker compose exec web sh
docker compose exec -u root web sh
docker compose run --rm web env
docker compose run --rm --no-deps web sh

exec runs a command inside a container that is already up. run starts a new container from the same service definition, which is what you need when the service does not stay up long enough to exec into. Always pair run with --rm, because without it every invocation leaves a stopped container behind, and those pile up until docker compose ps -a is unreadable.

Try sh before bash. Alpine based images carry no bash, and the failure reads exec: "bash": executable file not found in $PATH. Adding --no-deps to run skips the service's dependencies, which stops a quick config check from booting your whole database.

run --rm web env is the fastest way to see the environment a service really got, after every .env file, environment: block and shell variable has been merged. When a value is wrong, the merge order is usually the reason, and how Compose resolves env files and secrets sets out which source wins.

Networks, ports, and name resolution

docker compose port web 80
docker compose exec web getent hosts db
docker compose config --networks

Compose puts every service on one project network, and each service name is a DNS name on it. Running getent hosts db inside web prints the container IP when resolution works and prints nothing when it does not, so it answers "can these containers see each other" in two seconds. If the name resolves but the connection is refused, the process inside db is bound to 127.0.0.1 rather than 0.0.0.0, so it never accepts a packet from another container. The rest of that model is in how Compose networks and service DNS work.

port web 80 prints the host address and port a container port is published on, which saves guessing when the mapping came from a variable. Publishing a port also writes a firewall rule that Docker manages itself, and that rule sits ahead of yours, so a service you believed was private can be open to the internet. That case is covered in why published Docker ports bypass ufw.

Volumes and data

docker compose config --volumes
docker compose cp db:/etc/postgresql/pg_hba.conf ./pg_hba.conf
docker compose down -v

config --volumes prints the named volumes the project declares, one per line. That list is what you have to back up. cp copies a file into or out of a container without opening a shell, using the service:path form on whichever side is the container.

down -v removes those named volumes along with the containers. It is the right command for tearing down a test stack and the wrong one for anything holding data you care about, because there is no confirmation and no undo. Bind mounts survive it, since they live on the host filesystem. That gap in blast radius is one reason to choose deliberately between bind mounts and named volumes.

Cleanup that frees disk without losing data

docker compose down --remove-orphans
docker system df
docker image prune -a
docker builder prune

--remove-orphans deletes containers that belong to the project but no longer appear in the file, which is exactly what you get after renaming a service. Without it those containers keep running, invisible to docker compose ps.

docker system df shows where the disk went before you delete anything, splitting images, containers, local volumes and build cache with a reclaimable figure for each. image prune -a removes every image no tag points at, and on a box that has pulled several versions of a large image that is usually the biggest win. builder prune clears the build cache, which grows quietly on any server that builds its own images.

None of those touch a named volume. Only docker volume prune and docker compose down -v do.

Checking the file before it breaks something

docker compose config --quiet
docker compose config --services
docker compose --dry-run up -d

config --quiet validates and prints nothing on success, so it belongs in a pre-deploy step or a git hook. Plain config prints the fully merged and interpolated file, which is how you confirm a variable resolved and an override file layered the way you expected. An unset variable appears there as an empty value, next to the warning The "X" variable is not set. Defaulting to a blank string.

--dry-run is a global flag rather than a subcommand flag, so it goes before up. It prints every action Compose would take and changes nothing, which is thirty seconds well spent before a down on a stack that matters.

Working across files, profiles, and projects

docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose --profile debug up -d
docker compose -p staging up -d

Multiple -f flags merge in order, and later files override earlier ones key by key. That is the standard way to keep one base file with a small production override, though the rules differ for lists and maps, so read how Compose merges multiple files before you debug a surprise.

--profile starts services tagged with that profile alongside the untagged ones, which keeps debug tooling out of a normal up. -p sets the project name, so two copies of one stack can run side by side with separate networks and separate volume names. Getting the stack back after a reboot is not a command you type at all, it is a unit that runs one for you, described in starting Compose stacks on boot.

FAQ

What replaced docker-compose with a hyphen?

Compose V2, invoked as docker compose with a space. It is a plugin bundled with Docker Engine, and the V1 Python tool is no longer installed by current packages. If the space form prints nothing, install the docker-compose-plugin package for your distribution. Update old scripts to the space form rather than adding an alias, because V2 has flags V1 never had.

Why does docker compose restart not pick up my config change?

restart stops and starts the existing container with the configuration it was created with, and it never re-reads compose.yaml. Any change to environment variables, ports, volumes or the image tag needs docker compose up -d, which compares each service against its running container and recreates the ones that differ. Add --force-recreate when you want the replacement to happen even though nothing in the file changed.

How do I update a service to a newer image?

Run docker compose pull, then docker compose up -d. The pull fetches the current image for each tag in the file, and up -d recreates any service whose image ID no longer matches its container. Running up -d on its own reuses the image already on disk, which is how a stack pinned to latest sits on a months old build without printing any error.

Which cleanup commands are safe on a live server?

docker system df, docker image prune -a and docker builder prune remove images and cache only, so running services keep working and named volumes are untouched. The dangerous pair is docker compose down -v and docker volume prune, which delete named volumes with no prompt. Run docker compose config --volumes first so you know what is at risk.

Can I run one command without starting the whole stack?

Yes. docker compose run --rm --no-deps web sh starts a single container from the web service definition, skips its dependencies, and removes the container when you exit. Use exec instead when the container is already running, because exec joins the live process and shows you the state the service is actually in.