All posts

docker run vs docker compose: when to use which

What each is for, common docker run flags mapped to Compose keys, project-prefixed volumes, and an app + Postgres + Redis stack converted step by step.

By Last reviewed 7 min readDockerDocker ComposeDevOps

docker run creates and starts one container, with everything about it spelled out in flags. Docker Compose reads a file that describes several containers plus their networks and volumes, and makes the running state match it. Both drive the same engine and can express nearly the same things. The real difference is how you work: a command you type once, or a file you keep, review and rerun.

If you already have working docker run commands, paste them into the docker run to Compose converter. It turns each command into a service, collects named volumes and networks at the top level, and lists every flag it couldn't map so you can add it by hand. It converts a compose file back into docker run commands too.

When to use which

Reach for docker run when the container is disposable or alone:

  • a throwaway shell: docker run --rm -it alpine:3.20 sh
  • a one-off job in CI or a script that runs one image and exits
  • poking at an image before you write any configuration for it

Use Compose as soon as containers need to talk to each other, or you'll run the setup again, or someone else will. docker compose up -d creates the network, volumes and containers in dependency order. Run it again after editing the file and Compose stops and recreates only the services whose configuration or image changed, keeping mounted volumes. docker compose down removes the containers and networks; named volumes survive unless you add -v.

docker run flags and their Compose keys

docker runComposeNotes
-dnoneDetaching is a property of the command: docker compose up -d.
--name apicontainer_name: apiUsually drop it. The service name is already a DNS name, and Compose can't scale a service that has one.
-p 8080:3000ports: ["8080:3000"]Always quote; 127.0.0.1:5432:5432 works the same.
-v pgdata:/var/lib/postgresql/dataservice volumes: plus top-level volumes: {pgdata:}A bare name is a named volume.
-v ./uploads:/app/uploadsvolumes: ["./uploads:/app/uploads"]A path is a bind mount, relative to the compose file's directory. docker run accepts relative paths since Engine 23.
--mount source=data,target=/dataa long-syntax volumes: entry with type, source and targetBind and tmpfs mounts too. tmpfs-mode=1770 becomes mode: 0o1770, since Compose reads a plain 1770 as decimal.
-e KEY=value, -e KEYenvironment:A bare KEY takes its value from your shell or .env.
--env-file api.envenv_file: api.envenvironment overrides values from the file.
--restart unless-stoppedrestart: unless-stoppedWrite restart: "no" in quotes.
--network shop-netnetworks: [shop-net]Often unnecessary: every service joins the project's default network.
--network hostnetwork_mode: hostCan't be combined with networks.
--health-cmd, --health-interval, --health-retrieshealthcheck: with test, interval, retriesA string test runs in the container's shell, like CMD-SHELL.
-itstdin_open: true, tty: true
--initinit: true
--memory 512m --cpus 1.5deploy.resources.limits or mem_limit / cpus
--rmnoneServices are long-lived; use docker compose run --rm for one-offs.
nonedepends_on with condition: service_healthyStart order has no docker run equivalent.

Anything after the image becomes command:. The converter maps every row that has a Compose equivalent, including --network host to network_mode: host and --mount to the long volume syntax, and names any other flag in a warning, such as "Not converted: --gpus, --shm-size". It drops --rm silently, since there is nothing to map it to.

Projects: why your volume is now shop_pgdata

Compose groups everything under a project name. It comes from -p, then COMPOSE_PROJECT_NAME, then a top-level name: in the file, then the name of the directory holding the file. In a project called shop:

  • the default network is shop_default, and services reach each other by service name;
  • a volume declared as pgdata is created as shop_pgdata;
  • containers without container_name are named like shop-db-1.

The volume prefix is the classic migration trap. Your docker run Postgres stored its data in a volume called pgdata; the first docker compose up creates an empty shop_pgdata, and Postgres initialises a fresh database next to the old one. To keep the existing data, point Compose at the volume you already have:

volumes:
  pgdata:
    external: true   # use the existing "pgdata"; error if it's missing

External volumes are never removed by docker compose down, even with -v. Compose also allocates a subnet for each project network; if one collides with a VPN route, the CIDR and subnet guide covers moving Docker's address pools.

up, run and exec

  • docker compose up -d builds, creates or recreates, and starts every service. --wait blocks until services are running or healthy, which suits deploy scripts.
  • docker compose run --rm api npm run migrate starts a new one-off container from the api service's configuration. It starts the services api depends on unless you pass --no-deps, and doesn't publish the service's ports unless you pass --service-ports. This is where --rm lives now.
  • docker compose exec db psql -U shop runs a command in the already running container, like docker exec. It allocates a TTY by default; add -T when piping, as in docker compose exec -T db pg_dump -U shop shop > backup.sql.

Compose v1, v2 and the obsolete version: key

Compose v1 was the Python docker-compose binary; Docker lists it as retired and no longer maintained. v2 is the Go docker compose plugin. Releases jumped from v2 to v5 in 2025 to avoid confusion with the old file-format labels "2" and "3", and Docker describes v5 as functionally identical to v2. Scripts that still call docker-compose should move to docker compose. v1 also named containers with underscores (shop_db_1), so anything that greps for names needs updating.

File formats 2.x and 3.x were merged into the Compose Specification, and Compose always validates against the latest schema. A top-level version: key is ignored, and Compose 5.5 warns: "the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion". Delete the line. The preferred file name is compose.yaml; docker-compose.yml still works. The converter's default output has no version: key; its "3" and "2" options add one for older tooling, and "2" writes mem_limit and cpus instead of deploy.

Worked example: an API with Postgres and Redis

Here is a small stack started by hand. The password comes from the shell, so it never appears in the commands:

docker network create shop-net

docker run -d \
  --name db \
  --network shop-net \
  -e POSTGRES_USER=shop \
  -e POSTGRES_DB=shop \
  -e POSTGRES_PASSWORD \
  -v pgdata:/var/lib/postgresql/data \
  --health-cmd "pg_isready -U shop -d shop" \
  --health-interval 5s \
  --health-retries 5 \
  --restart unless-stopped \
  postgres:16-alpine

docker run -d \
  --name cache \
  --network shop-net \
  -v redis-data:/data \
  --restart unless-stopped \
  redis:7-alpine redis-server --appendonly yes

docker run -d \
  --name api \
  --network shop-net \
  -p 8080:3000 \
  -e NODE_ENV=production \
  -e DATABASE_URL="postgres://shop:$POSTGRES_PASSWORD@db:5432/shop" \
  -e REDIS_URL=redis://cache:6379 \
  -v ./uploads:/app/uploads \
  --restart unless-stopped \
  ghcr.io/example/shop-api:1.4.2

The converter skips the network create line and produces:

services:
  db:
    image: "postgres:16-alpine"
    container_name: db
    restart: unless-stopped
    volumes:
      - "pgdata:/var/lib/postgresql/data"
    environment:
      - POSTGRES_USER=shop
      - POSTGRES_DB=shop
      - POSTGRES_PASSWORD
    networks:
      - shop-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      retries: 5

  cache:
    image: "redis:7-alpine"
    container_name: cache
    command:
      - redis-server
      - --appendonly
      - "yes"
    restart: unless-stopped
    volumes:
      - "redis-data:/data"
    networks:
      - shop-net

  api:
    image: "ghcr.io/example/shop-api:1.4.2"
    container_name: api
    restart: unless-stopped
    ports:
      - "8080:3000"
    volumes:
      - "./uploads:/app/uploads"
    environment:
      - NODE_ENV=production
      - "DATABASE_URL=postgres://shop:$POSTGRES_PASSWORD@db:5432/shop"
      - "REDIS_URL=redis://cache:6379"
    networks:
      - shop-net

networks:
  shop-net:
    driver: bridge

volumes:
  pgdata:
  redis-data:

with no warnings: the three --health-* flags became the healthcheck block, with CMD-SHELL because --health-cmd runs in the container's shell. Notice "yes" is quoted so no YAML parser can read it as a boolean; the YAML gotchas guide explains why. $POSTGRES_PASSWORD survives too: Compose interpolates $VAR and ${VAR} from your shell or a .env file next to the compose file, just as the shell did (write $$ for a literal dollar sign).

That file is valid as it stands; docker compose config accepts it. Four edits make it worth keeping:

  1. Give api a depends_on that waits for db to be healthy, not just started.
  2. Drop container_name, so containers get project-scoped names and services can scale.
  3. Drop shop-net; the default network does the same job, and the service names db and cache still resolve.
  4. Make the password required with ${POSTGRES_PASSWORD:?…}, so a missing .env stops Compose up front instead of leaving the API with an empty password in DATABASE_URL.
name: shop

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

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data

  api:
    image: ghcr.io/example/shop-api:1.4.2
    restart: unless-stopped
    ports:
      - "8080:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://shop:${POSTGRES_PASSWORD}@db:5432/shop
      REDIS_URL: redis://cache:6379
    volumes:
      - ./uploads:/app/uploads
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  pgdata:
  redis-data:

Put the password in .env as POSTGRES_PASSWORD=changeme, with your real value, and keep that file out of Git. Run docker compose config to see the file after interpolation; without the variable it stops with "required variable POSTGRES_PASSWORD is missing a value: set it in .env". If you are moving from the docker run setup, stop and remove the old containers first, since they hold port 8080, and add the external: true step for pgdata before the first up. On Linux, bind-mounted directories keep their host ownership and modes, so if the container user can't write to ./uploads, the chmod guide covers the fix.

Next steps

Paste your own commands into the docker run to Compose converter, act on every warning, then apply the five edits above. To check how the finished file parses, convert it in the YAML / JSON converter, or run docker compose config to print what Compose will use.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.