Long docker run commands work, but they live in shell history, READMEs and chat messages, and nobody remembers which flags belong to which container. When you convert docker run to Docker Compose you get one declarative compose.yaml that can be reviewed, versioned in Git and started with a single command. This guide walks through the conversion flag by flag, shows a multi-container example, and lists what you still need to check by hand after using the docker run to Docker Compose converter.
A Compose file describes the desired state of several containers at once: images, ports, volumes, environment, networks and restart behaviour. Compose creates everything and names it after the project (by default the directory name). Compared with loose run commands you gain:
- Reproducibility: the same file starts the same stack on every machine.
- Service discovery: services reach each other by service name, so
dbresolves to the database container. - One lifecycle:
docker compose up -danddownreplace a series of run, stop and rm commands.
docker run flags and their Compose keys
Most docker run options have a direct counterpart in the Compose file. The table lists the mappings the converter applies to the most common flags; less frequent ones such as --cap-add, --add-host, --ulimit, --log-driver and --gpus are handled too. Any option it does not recognise is kept out of the YAML and reported in a # Option --x was not converted. comment at the top of the output.
| docker run flag | Compose key | Notes |
|---|---|---|
-p, --publish | ports | Every mapping is quoted to avoid the YAML base-60 trap |
--expose | expose | Container-only ports, not published to the host |
-v, --volume | volumes (short syntax) | Named volumes are also declared under top-level volumes: |
--mount | volumes (long syntax) | type, source, target and read_only become separate keys |
-e, --env | environment | List of KEY=value, or a map with the key: value option |
--env-file | env_file | Path is resolved relative to the Compose file |
--name | container_name | Also used as the service name |
--restart | restart | Same values: no, always, on-failure, unless-stopped |
--network | networks or network_mode | host, none, bridge and container:… become network_mode |
--network-alias | networks.<name>.aliases | Only emitted together with --network |
-w, -u, --entrypoint | working_dir, user, entrypoint | Copied verbatim |
-i, -t | stdin_open, tty | Useful for shells and REPLs |
-m, --cpus | mem_limit, cpus | Resource limits on the service |
--health-cmd, --health-interval, --health-retries | healthcheck | The command is wrapped as CMD-SHELL |
arguments after the image | command | One argument stays a string, several become a list |
-d | (none) | Detached mode is chosen by docker compose up -d |
--rm, -P | (dropped) | The output gets a # comment explaining why |
The Compose services reference documents every key if you need an option that has no docker run equivalent.
How to convert docker run to Docker Compose step by step
Take a typical API container started with a bind mount, a named volume, an env file and a user-defined network:
docker run -d --name api --restart unless-stopped \
-p 127.0.0.1:3000:3000 -e NODE_ENV=production --env-file .env \
-v ./uploads:/app/uploads -v api-cache:/app/cache \
--network backend -w /app myorg/api:1.4 node server.js
Pasting it into the converter produces:
# Volume api-cache: Compose creates a new one named <project>_<name>. To reuse one made with docker volume create, add "external: true" under it.
# Network backend: Compose creates a new one named <project>_<name>. To reuse one made with docker network create, add "external: true" under it.
services:
api:
image: myorg/api:1.4
container_name: api
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env
volumes:
- ./uploads:/app/uploads
- api-cache:/app/cache
networks:
- backend
working_dir: /app
command:
- node
- server.js
volumes:
api-cache: {}
networks:
backend: {}
A few things happened that are worth understanding:
- The service name
apicomes from--name. Without it, the image name would be used (myorg/api:1.4becomesapi). - Everything after the image,
node server.js, became thecommandlist. api-cacheis a named volume, so it is declared under the top-levelvolumes:key../uploadsis a bind mount and is not.- The
backendnetwork is declared at the top level, so Compose will create it. The two comments at the top point out that this network and the volume are new, project-prefixed ones (see the section on volumes below). -ddisappeared: detached mode is a property ofdocker compose up -d, not of the file.
Save the result as compose.yaml in the project directory. If you want to tidy indentation or check the syntax after hand edits, run it through the YAML formatter or the YAML validator.
Multi-container stacks: one file, one network
The real payoff comes with several containers. Paste all the run commands one after another, each starting with docker run. Helper commands such as docker network create can stay in the paste: the converter only converts docker run and docker create lines and lists anything else in a # Skipped … comment, and Compose creates the network itself. With the Environment as key: value map option enabled, this input:
docker run -d --name db --restart unless-stopped --network backend \
-e POSTGRES_USER=app -e POSTGRES_PASSWORD=change-me -e POSTGRES_DB=app \
-v pgdata:/var/lib/postgresql/data \
--health-cmd "pg_isready -U app" --health-interval 10s --health-retries 5 \
postgres:16-alpine
docker run -d --name cache --restart unless-stopped --network backend redis:7-alpine
docker run -d --name api --restart unless-stopped --network backend \
-p 8080:3000 -e DATABASE_URL=postgres://app:change-me@db:5432/app -e REDIS_URL=redis://cache:6379 \
myorg/api:1.4
becomes a three-service file:
# Volume pgdata: Compose creates a new one named <project>_<name>. To reuse one made with docker volume create, add "external: true" under it.
# Network backend: Compose creates a new one named <project>_<name>. To reuse one made with docker network create, add "external: true" under it.
services:
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
networks:
- backend
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: change-me
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- pg_isready -U app
interval: 10s
retries: 5
cache:
image: redis:7-alpine
container_name: cache
restart: unless-stopped
networks:
- backend
api:
image: myorg/api:1.4
container_name: api
restart: unless-stopped
networks:
- backend
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://app:change-me@db:5432/app
REDIS_URL: redis://cache:6379
volumes:
pgdata: {}
networks:
backend: {}
The DATABASE_URL and REDIS_URL values keep working because the host names db and cache are now service names on the same network. What a set of separate run commands cannot express is start-up order, so add it by hand. This hand-written addition makes the API wait for the database health check:
api:
image: myorg/api:1.4
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
Do you still need a custom network?
Without any networks: entries, Compose puts every service on a project-wide default network. If --network backend only existed so containers could talk to each other, you can delete it; keep it to isolate groups of services.
Named volumes vs bind mounts
A named volume (pgdata:/var/lib/postgresql/data) is storage managed by Docker. A bind mount (./html:/usr/share/nginx/html) maps a host path into the container. The converter treats a source that starts with ., /, ~ or $ as a path and anything else as a named volume. Two details matter after conversion:
- Compose resolves relative bind paths against the directory of the Compose file, and it does not run shell command substitution. The converter therefore rewrites a
$(pwd)prefix to.; any other$(…)substitution is copied as-is, flagged in a comment, and has to be replaced by hand. - Named volumes are prefixed with the project name.
pgdatabecomes something likemyproject_pgdata, which is a new, empty volume. To reuse data a previousdocker runstored inpgdata, mark the volume asexternal: true.
This input mixes a $(pwd) bind mount with a --mount flag:
docker run -d -v "$(pwd)/html:/usr/share/nginx/html:ro" \
--mount type=volume,source=logs,target=/var/log/nginx -p 80:80 nginx:1.27-alpine
# Volume logs: Compose creates a new one named <project>_<name>. To reuse one made with docker volume create, add "external: true" under it.
services:
nginx:
image: nginx:1.27-alpine
volumes:
- ./html:/usr/share/nginx/html:ro
- type: volume
source: logs
target: /var/log/nginx
ports:
- "80:80"
volumes:
logs: {}
The --mount option is written in long syntax, and the $(pwd) prefix became ./. To reuse existing resources created outside Compose, as the comment at the top suggests, declare them like this:
volumes:
pgdata:
external: true
networks:
backend:
external: true
Environment variables and .env files
Compose has two mechanisms that are easy to confuse. env_file: (from --env-file) injects every line of the file into the container. A file literally named .env next to compose.yaml is read by Compose itself to substitute ${VAR} placeholders in the YAML. The second lets you keep secrets out of the committed file:
# .env (next to compose.yaml, read by Compose for ${...} substitution)
POSTGRES_PASSWORD=change-me
# compose.yaml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Inline -e values are copied as they are (a literal $ from a single-quoted value is doubled to $$ so Compose does not read it as a variable), so passwords from your shell history end up in the generated YAML. Move them to an ignored .env file before committing. The .env to JSON converter helps when you need to inspect or reuse those variables elsewhere.
Restart policies and the Compose lifecycle
--restart maps one to one to restart: and accepts the same values: no, always, on-failure (optionally on-failure:3) and unless-stopped. Once the file is ready, the day-to-day commands use the Compose v2 CLI:
docker compose config # validate and print the resolved file
docker compose up -d # create networks and volumes, start all services
docker compose ps # list the project's containers
docker compose logs -f api # follow one service's logs
docker compose down # stop and remove containers and networks
docker compose down -v # ...and also remove the named volumes
Run docker compose config first: it fails on invalid keys and shows the file after variable substitution. Note that down -v deletes database data stored in named volumes.
What to check by hand after converting
A converter translates flags faithfully, but some choices only make sense in the Compose world:
- Leave out
version:. The top-levelversionkey is obsolete in the Compose Specification and current Compose prints a warning when it is present. The converter omits it by default. - Reconsider
container_name. Fixed names preventdocker compose up --scaleand clash between projects. Service names are usually enough. - Drop
links.--linkis converted to the legacylinkskey, which is unnecessary on a Compose network. - Read the header comments.
--rmand-Phave no Compose equivalent; for one-off containers usedocker compose run --rm <service>. - Add
depends_onwhere start-up order matters. - Check port bindings.
127.0.0.1:3000:3000stays private to the host,3000:3000listens on all interfaces.
Next steps
Converting is mostly mechanical: map each flag, declare named volumes and networks, then review naming, secrets and start-up order. Paste your commands into the docker run to Docker Compose tool, validate the result, and keep the file in version control. If you are deciding between configuration formats, JSON vs YAML vs TOML compares them, and the JSON to YAML converter helps when a service's config arrives as JSON. When you later need to change configuration through an API rather than a file, the guide to JSON Merge Patch vs JSON Patch explains the two partial-update formats.