Skip to content
DevOps2026-09-109 min read

docker run to docker-compose: The Complete Migration Guide (with Flag Map)

Why bother moving docker run to compose

A service launched with docker run -d --name web -p 8080:80 -v ... keeps its configuration only in the command you typed and your memory. Rebuild on another machine, six months later, or hand over to a colleague — you're archaeology-ing through shell history. Compose turns all of it into a version-managed YAML file:

Raw docker rundocker compose
Config lives in shell historyConfig lives in docker-compose.yml, tracked in git
Multi-container startup order by handdepends_on declares it
Rebuild = rm + retype the long commanddocker compose up -d is idempotent
Half-stopped services cleaned from memorydocker compose down cleans everything
Ports/volumes scatteredOne file shows the whole picture

Convert in thirty seconds

Paste the docker run command into the docker run to Compose converter, save the result, and run:

docker compose up -d

The conversion runs entirely in your browser — commands containing passwords or internal hosts never leave your machine.

Several paste shapes are tolerated: a sudo docker container run prefix is stripped; multi-line \ continuations paste as-is; the inline --name=web form works; and even flags written after the image name (docker run nginx -p 80:80, which docker genuinely accepts) are parsed into the right places.

The flag-by-flag map

docker rundocker-compose.ymlNotes
nginx:1.25image: nginx:1.25image
--name webcontainer_name: web (or omit for service name)Omitting is preferred
-p 8080:80ports: ["8080:80"]quote port pairs in YAML
-v ./site:/usr/share/nginx/htmlvolumes: ["./site:/usr/share/nginx/html"]bind mount
-v cache:/var/cachevolumes: ["cache:/var/cache"]named volume (declare at top level too)
-e TZ=UTCenvironment: ["TZ=UTC"]env var
--env-file .envenv_file: [.env]env file
--restart alwaysrestart: alwaysrestart policy
--network mynetnetworks: [mynet] + top-level blockcustom network
--network hostnetwork_mode: hostspecial networks use network_mode
-u 1000:1000user: "1000:1000"run user
-w /appworking_dir: /appworking dir
--entrypoint "sh -c ..."entrypoint: ...entrypoint
-m 512mmem_limit: 512mmemory limit
--cpus 1.5cpus: "1.5"CPU limit
command after imagecommand: ...container command
-d / -it / -i / -tnonecompose runs detached by default; omit
--label a=blabels: ["a=b"]labels
--hostname dbhostname: dbhostname
--platform linux/arm64platform: linux/arm64common when running x86 images on Apple Silicon
--privilegedprivileged: truestop and ask: does it really need privilege
--read-onlyread_only: trueread-only root filesystem
--mount type=bind,source=/a,target=/bvolumes: ["/a:/b"]folded to short syntax; tmpfs goes to warnings
unrecognized flag (e.g. --device)listed in warningsconversion always produces output; unmapped items are listed one by one

One implementation stance worth knowing: unrecognized flags never fail the conversion — they are collected into the warnings area below the output. A 15-flag command with 14 converted and 1 flagged beats a wholesale refusal; you fill in the one straggler by eye.

Deliberate choices in the generated YAML

The output is not a verbatim flag transcription. A few engineering decisions are baked in, and understanding them is understanding compose's traps:

Ports are always quoted. -p 8080:80 becomes ports: ["8080:80"], not - 8080:80 — old YAML parsers interpret values like 25:25 as sexagesimal numbers (a real YAML 1.1 trap). Quoting is the cheap defense. For other YAML quoting traps, see the YAML to JSON guide.

Environment uses list form. The output is environment: ["TZ=UTC"], not the map form TZ: UTC. Reason: when a value contains #, quotes, or spaces, the map form needs a second escaping layer at the YAML level, and stacked escaping is where mistakes breed. The list form quotes KEY=VALUE as one string — one layer only.

-e and env_file stay separate. -e KEY=VALUE goes to environment; --env-file .env goes to env_file, and both can coexist on one service. Note env_file paths resolve relative to the compose file, and .env belongs in .gitignore — the .gitignore generator fills that in.

Custom networks get a top-level block. --network mynet produces not only networks: [mynet] under the service but also:

networks:
  mynet:
    external: true

external: true means the network already exists outside compose (created with docker network create mynet). Remove it, and compose tries to create a project_mynet network of its own — and your containers stop finding each other.

Service name derivation. --name wins (sanitized to valid characters); otherwise the last segment of the image name minus the tag: registry.example.com/team/app:v2 → app. So when converting several commands into one compose file, watch for service-name collisions.

Three classic pitfalls

1. Bare -e passthrough can't convert

docker run -e DEBUG nginx   # references the HOST's DEBUG variable

A compose file is static and can't express "reference a host variable". Write it fully as -e DEBUG=value, or move everything to env_file.

2. Anonymous volumes have no equivalent

docker run -v /var/log nginx (container path only) creates an anonymous volume; compose needs a named volume or a bind path. The converter lists it in the warnings area — give it a name (logdata:/var/log) and declare it in the top-level volumes block.

3. --rm changes meaning

In raw run, --rm means "delete the container on exit"; compose manages the lifecycle via down and neither needs nor supports --rm. Not a lost feature — a different model.

The --link db:db from old tutorials is entirely redundant in compose — services in the same compose project reach each other by name. Drop it on sight. If it expressed a connection to a container outside this compose file, the correct move is to pull that container into the same project or share an external network.

5. The four --restart values actually differ

no / always / on-failure / unless-stopped differ only in what happens after the Docker daemon restarts and after a manual stop. Production services usually want unless-stopped: a host reboot brings the container back, but a deliberate docker stop stays stopped — always resurrects it. As an aside, if you rely on periodic restarts for cleanup, the more common pattern is host cron running docker compose restart; see Cron expressions explained for the syntax.

The reverse direction: compose back to docker run

Occasionally you need the inverse: unpack one service back into a docker run for a machine without compose. Walk the same table backwards:

  1. image: → the image name at the end of the command;
  2. ports / volumes / environment entries → -p / -v / -e respectively;
  3. network_mode: host → --network host; a networks: membership → --network <name>;
  4. restart: / user: / working_dir: / entrypoint: / mem_limit: / cpus: map straight back;
  5. a list-form command: appends in order after the image (each list item is one argv of the exec form — quote items containing spaces);
  6. depends_on / healthcheck / build: have no run equivalent — the first two become launch-order discipline and manual checks; build: needs a docker compose build (or docker build) before you can run anything.

Migration acceptance checklist

Don't up immediately after converting. Run through these five steps:

  1. Retire the old container: docker stop <old> && docker rm <old> — same-name conflicts are covered in container name conflict; a port that hasn't been released yet shows up as port already allocated.
  2. Clear the warnings area: every warning the converter lists either gets a manual mapping or an explicit "drop it" decision (e.g. --rm).
  3. Diff the runtime config: docker inspect <old container> against the generated YAML — ports, volumes, env — looking for what silently went missing.
  4. Hand over the data: export old anonymous volumes first (docker run --rm --volumes-from <old> -v $(pwd):/backup alpine tar czf /backup/data.tar.gz -C <container path> .), then load into the new named volume.
  5. Prove idempotency: after docker compose up -d, run docker compose down && docker compose up -d once more to confirm repeatability.

Multi-container: wire dependencies after converting

After converting each docker run into a service, add the dependency graph:

services:
  app:
    image: myapp:2.0
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

The service_healthy condition (compose v2.20+) solves the classic "app starts before the database is ready" race — the problem raw run solved with sleep scripts.

The translation table: run flags to compose keys

The mechanical mappings that cover 90% of conversions: port publishes become ports lists (host:container), volume mounts preserve bind syntax, environment flags become environment entries (the map form catches missing-value typos better than the list), restart policies map directly, and the service name replaces --name. The flags needing judgment: --link (deprecated — compose networking reaches services by name; drop it), --entrypoint overrides (if you need them, ask whether the image is wrong or the use is), and --rm (meaningless in compose — lifecycle is managed). The pattern: convert mechanically, then read the compose file as a fresh document — the flags you forgot to question surface on the second read.

FAQ

Q: Does the generated file need a version field? No. Modern docker compose (v2 CLI) deprecates version and warns when present. The converter emits the modern format.

Q: Must the file be named docker-compose.yml? compose.yaml / docker-compose.yml are picked up by default; use -f for anything else: docker compose -f prod.yml up -d.

Q: Will up conflict with already-running containers? Yes — same-name conflicts (container name already in use, see this guide). docker stop/rm the old container first, or rename the service for transition. Port conflicts: port already allocated.

Q: What's the difference between the service name and container_name? The service name is compose's internal unit of management (and how services reach each other on the network); container_name is the container's fixed name at the Docker layer. A fixed container_name breaks docker compose up --scale web=3 (names must be unique), which is why the converter only writes it when --name was given explicitly — generally, delete it and let compose manage naming.

Q: Can build: and image: coexist? Yes. build describes how to build; image names and tags the result. docker run has no build equivalent — docker build is a separate command. If the image is built locally rather than pulled, the Dockerfile generator guide covers the build side.

Q: "network not found" after converting? The generated networks: external: true assumes the network exists. Either docker network create mynet first, or drop external: true and let compose create it (but the name becomes project_mynet).

Triage notes

The section on what the generated YAML cares about exists because the output deliberately refuses verbatim flag transcription: ports are always quoted to dodge the real trap of older YAML parsers reading 25:25 as a sexagesimal number, and environment uses the list form to collapse escaping into a single layer. "Unknown flags don't error out but land in a warnings area" is a stated tradeoff — converting 14 of 15 flags beats refusing the whole conversion. Among the three classic pitfalls, the --rm entry notes explicitly that it is "not a lost feature but a different model": compose manages lifecycle via down and needs no delete-on-exit.


Provided by ToolVault. Related tools: docker run to Compose converter, Dockerfile Generator, Linux Cheatsheet. Related reading: Container name conflicts, Port already allocated. See the homepage for more developer tools.