All posts

blog

Deploying Docker Compose over SSH: what the commands actually do

Published

Deploying a Compose stack by hand takes three commands. Doing it a second and a third time, without losing data or leaving stray containers behind, is where the details start to matter.

The minimal version

Create a directory on the server, copy the compose file into it, and start the stack. That is a complete deployment.

ssh deploy@203.0.113.10 'mkdir -p ~/apps/shop'
scp compose.yml deploy@203.0.113.10:~/apps/shop/compose.yml
ssh deploy@203.0.113.10 'cd ~/apps/shop && docker compose up -d'

The directory name is not cosmetic. Compose uses it as the project name, and the project name is how Compose finds this stack’s containers, networks and volumes again.

The project name is how Compose remembers

Run from ~/apps/shop, the containers become shop-web-1, shop-db-1 and so on. Run the same file from a directory called shop-new and Compose sees a different project: it starts a second copy and leaves the first one running.

Pin the name in the file so a renamed directory cannot do that by accident. The top-level name: key sets the project name wherever the file lives.

compose.yml with the project name pinned

name: shop

services:
  web:
    image: ghcr.io/example/shop:1.4.0
    env_file: .env
    depends_on: [db]
  db:
    image: postgres:16
    env_file: .env
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Where the .env goes

Secrets belong in a .env file next to the compose file on the server, not in the repository. Commit a .env.example with the variable names and placeholder values, and add .env to .gitignore before the first commit — once a secret is in Git history, deleting the file does not remove it.

Compose reads .env from the project directory to fill in ${VARIABLE} references in the compose file. It does not pass the file into containers on its own; a service gets it only through env_file: or an environment: entry, as above.

When you copy it, make sure other users on the server can never read it, not even for a moment:

ssh deploy@203.0.113.10 'umask 077 && cat > ~/apps/shop/.env' < .env

Piping through SSH with umask 077 creates the file with mode 600 from the start. Copying first and running chmod afterwards leaves a short window in which it is readable.

Compose v2 or the old docker-compose

Current Docker installations ship Compose as a plugin, invoked as docker compose with a space. The standalone docker-compose binary — Compose v1 — stopped receiving updates in July 2023. If a tutorial uses the hyphen, its commands mostly carry over; the binary should not.

Check what the server has with docker compose version. If Docker replies that compose is not a docker command, install the docker-compose-plugin package from Docker’s own repository instead of the distribution’s old docker-compose.

The second deploy

Updating is a pull and another up. Compose compares the running containers with the file and recreates only the services whose image or configuration changed.

ssh deploy@203.0.113.10 \
  'cd ~/apps/shop && docker compose pull && docker compose up -d --remove-orphans'

--remove-orphans removes containers for services you deleted from the file. Without it, a renamed service keeps its old container running next to the new one.

Volumes keep the data until you tell them not to

Containers are disposable; named volumes are not. Recreating db on every deploy is fine, because the database files live in the db_data volume, which survives up, down and image changes.

The command to treat with care is docker compose down -v. The -v removes the project’s named volumes along with the containers, and the data with them. Useful in a test environment, destructive everywhere else.

Bind mounts such as ./data:/data point at a directory next to the compose file instead. That makes backups easy to reason about and makes file permissions your job.

The parts that stay tedious

  1. 01Remembering which server runs which stack, in which directory.
  2. 02Copying the extra files a stack needs — an init script, a config file, the .env — and keeping them in step with the compose file.
  3. 03Knowing what changed in the last deploy. Nothing records it unless you do.
  4. 04Rolling back, which means knowing the previous image tag — and having used pinned tags instead of latest in the first place.
  5. 05Reading logs across services: docker compose logs -f --tail 100 works, from inside the right directory, on the right server.

The same workflow in MihraOps

MihraOps runs these steps over SSH and keeps the result an ordinary Compose project. Each stack lives in ~/.mihraops/compose/<project>/ on your server, with its compose file and extra files next to it, so you can still cd there and run docker compose by hand.

The docs page walks through it click by click, including how updates and image pulls work from the Compose editor.

Read next