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 run | Compose | Notes |
|---|---|---|
-d | none | Detaching is a property of the command: docker compose up -d. |
--name api | container_name: api | Usually drop it. The service name is already a DNS name, and Compose can't scale a service that has one. |
-p 8080:3000 | ports: ["8080:3000"] | Always quote; 127.0.0.1:5432:5432 works the same. |
-v pgdata:/var/lib/postgresql/data | service volumes: plus top-level volumes: {pgdata:} | A bare name is a named volume. |
-v ./uploads:/app/uploads | volumes: ["./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=/data | a long-syntax volumes: entry with type, source and target | Bind and tmpfs mounts too. tmpfs-mode=1770 becomes mode: 0o1770, since Compose reads a plain 1770 as decimal. |
-e KEY=value, -e KEY | environment: | A bare KEY takes its value from your shell or .env. |
--env-file api.env | env_file: api.env | environment overrides values from the file. |
--restart unless-stopped | restart: unless-stopped | Write restart: "no" in quotes. |
--network shop-net | networks: [shop-net] | Often unnecessary: every service joins the project's default network. |
--network host | network_mode: host | Can't be combined with networks. |
--health-cmd, --health-interval, --health-retries | healthcheck: with test, interval, retries | A string test runs in the container's shell, like CMD-SHELL. |
-it | stdin_open: true, tty: true | |
--init | init: true | |
--memory 512m --cpus 1.5 | deploy.resources.limits or mem_limit / cpus | |
--rm | none | Services are long-lived; use docker compose run --rm for one-offs. |
| none | depends_on with condition: service_healthy | Start 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
pgdatais created asshop_pgdata; - containers without
container_nameare named likeshop-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 -dbuilds, creates or recreates, and starts every service.--waitblocks until services are running or healthy, which suits deploy scripts.docker compose run --rm api npm run migratestarts a new one-off container from theapiservice's configuration. It starts the servicesapidepends on unless you pass--no-deps, and doesn't publish the service's ports unless you pass--service-ports. This is where--rmlives now.docker compose exec db psql -U shopruns a command in the already running container, likedocker exec. It allocates a TTY by default; add-Twhen piping, as indocker 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:
- Give
apiadepends_onthat waits fordbto be healthy, not just started. - Drop
container_name, so containers get project-scoped names and services can scale. - Drop
shop-net; the default network does the same job, and the service namesdbandcachestill resolve. - Make the password required with
${POSTGRES_PASSWORD:?…}, so a missing.envstops Compose up front instead of leaving the API with an empty password inDATABASE_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.