Skip to content
OpsAtlas

Migrating from docker run to Docker Compose

By OpsAtlas · · 3 min read

Table of contents

Most container setups start the same way: a docker run command copied from a README, then a second one for the database, then a shell script that runs both with the right flags in the right order. It works until someone else has to run it, or you have to remember which -v was which six months later.

Docker Compose describes the same containers in one declarative file. This guide moves a typical two-container app, a web server and a Postgres database, from docker run to compose.yaml, and points out what does not translate one to one.

The starting point

Here is the kind of script we want to replace:

docker network create appnet

docker run -d --name db --network appnet \
  -e POSTGRES_PASSWORD=supersecret \
  -v pgdata:/var/lib/postgresql/data \
  --restart unless-stopped \
  postgres:16

docker run -d --name web --network appnet \
  -p 8080:3000 \
  -e DATABASE_URL=postgres://postgres:supersecret@db:5432/postgres \
  --restart unless-stopped \
  ghcr.io/acme/web:2.3.1

Three commands, an ordering dependency that only exists in your head, and a password in your shell history.

Step 1: convert the commands

Each docker run becomes a service. Flags map to keys almost mechanically: -p to ports, -e to environment, -v to volumes, --restart to restart. Paste the script into the docker run to Compose converter to do this part automatically. It also declares the named volume and network at the top level:

services:
  db:
    image: postgres:16
    container_name: db
    networks:
    - appnet
    environment:
    - POSTGRES_PASSWORD=supersecret
    volumes:
    - pgdata:/var/lib/postgresql/data
    restart: unless-stopped
  web:
    image: ghcr.io/acme/web:2.3.1
    container_name: web
    networks:
    - appnet
    ports:
    - "8080:3000"
    environment:
    - DATABASE_URL=postgres://postgres:supersecret@db:5432/postgres
    restart: unless-stopped
volumes:
  pgdata: {}
networks:
  appnet: {}

That file already runs with docker compose up -d. But a straight translation carries over habits that only made sense with docker run.

Step 2: drop what Compose does for you

Remove the custom network. Compose creates a default network per project and every service joins it, reachable by its service name. Unless you need isolation between groups of services, delete both networks: blocks.

Remove container_name. Compose already names containers predictably (<project>-web-1), and a fixed name stops you from running two copies of the project or scaling a service with --scale. Other services reach it by the service name db, not the container name.

Step 3: move the secret out

POSTGRES_PASSWORD=supersecret is now committed to version control. Put it in a .env file next to compose.yaml, add .env to .gitignore, and reference it:

services:
  db:
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}

The :? form makes docker compose up fail with a clear message when the variable is missing, instead of starting Postgres with an empty password.

Step 4: express the startup order

With docker run, you started the database first and hoped it was ready. Compose can wait for it, but only if the database reports its health:

services:
  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
  web:
    depends_on:
      db:
        condition: service_healthy

depends_on without a condition only waits for the container to start, which for Postgres is several seconds before it accepts connections.

Step 5: bind ports deliberately

-p 8080:3000 publishes on every interface of the host, including public ones, and Docker’s iptables rules bypass most host firewalls such as ufw. If the app sits behind a reverse proxy on the same machine, bind to localhost:

    ports:
      - "127.0.0.1:8080:3000"

The result

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  web:
    image: ghcr.io/acme/web:2.3.1
    ports:
      - "127.0.0.1:8080:3000"
    environment:
      DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

volumes:
  pgdata:

One file, one command (docker compose up -d), no secret in git, and a startup order that is enforced rather than remembered.

Checklist

  • Convert each docker run into a service (the converter does this in one paste).
  • Delete user-defined networks and container_name unless you really need them.
  • Move secrets into .env and use ${VAR:?message}.
  • Add healthchecks and depends_on: condition: service_healthy.
  • Bind published ports to 127.0.0.1 when a proxy fronts the app.
  • Pin every image to a version tag.

Try it yourself