FormatterHQ

DevOps

How to Convert docker run to Docker Compose, Flag by Flag

A practical walkthrough for turning docker run commands into a compose.yaml, with a flag-to-key mapping table, a multi-container example and a checklist of what to review after converting.

FormatterHQ Team Published Updated 9 min read

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 db resolves to the database container.
  • One lifecycle: docker compose up -d and down replace 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 flagCompose keyNotes
-p, --publishportsEvery mapping is quoted to avoid the YAML base-60 trap
--exposeexposeContainer-only ports, not published to the host
-v, --volumevolumes (short syntax)Named volumes are also declared under top-level volumes:
--mountvolumes (long syntax)type, source, target and read_only become separate keys
-e, --envenvironmentList of KEY=value, or a map with the key: value option
--env-fileenv_filePath is resolved relative to the Compose file
--namecontainer_nameAlso used as the service name
--restartrestartSame values: no, always, on-failure, unless-stopped
--networknetworks or network_modehost, none, bridge and container:… become network_mode
--network-aliasnetworks.<name>.aliasesOnly emitted together with --network
-w, -u, --entrypointworking_dir, user, entrypointCopied verbatim
-i, -tstdin_open, ttyUseful for shells and REPLs
-m, --cpusmem_limit, cpusResource limits on the service
--health-cmd, --health-interval, --health-retrieshealthcheckThe command is wrapped as CMD-SHELL
arguments after the imagecommandOne 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:

  1. The service name api comes from --name. Without it, the image name would be used (myorg/api:1.4 becomes api).
  2. Everything after the image, node server.js, became the command list.
  3. api-cache is a named volume, so it is declared under the top-level volumes: key. ./uploads is a bind mount and is not.
  4. The backend network 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).
  5. -d disappeared: detached mode is a property of docker 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. pgdata becomes something like myproject_pgdata, which is a new, empty volume. To reuse data a previous docker run stored in pgdata, mark the volume as external: 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-level version key 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 prevent docker compose up --scale and clash between projects. Service names are usually enough.
  • Drop links. --link is converted to the legacy links key, which is unnecessary on a Compose network.
  • Read the header comments. --rm and -P have no Compose equivalent; for one-off containers use docker compose run --rm <service>.
  • Add depends_on where start-up order matters.
  • Check port bindings. 127.0.0.1:3000:3000 stays private to the host, 3000:3000 listens 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.

  • Docker
  • Docker Compose
  • YAML
  • DevOps
  • Containers

Frequently asked questions

Do I still need the version key in docker-compose.yml?
No. The top-level version key is obsolete in the Compose Specification, and current Docker Compose ignores it and prints a warning. Only add it if an older tool in your pipeline still expects it.
What is the Compose equivalent of docker run -d?
There is no key for it in the file. Detached mode is chosen when you start the stack with docker compose up -d, so the -d flag is simply dropped during conversion.
How do I convert docker run --rm to Docker Compose?
Compose services are long-lived, so --rm has no key. For one-off tasks, define the service and start it with docker compose run --rm <service>, which removes the container when it exits.
Why does my database start empty after switching to Compose?
Compose prefixes named volumes with the project name, so pgdata becomes a new volume such as myproject_pgdata. Declare the old volume with external: true (or give it an explicit name:) to reuse the data created by docker run.

Tools used in this guide