All posts

From docker run to docker-compose.yml: Converting One-Off Commands into Versioned Files

July 26, 2026 · DevTools

docker
docker-compose
devops
containers
yaml
developer-tools

The Postgres-on-my-laptop command started simple — docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:16. Then someone wanted persistent data, so we added a volume. Then someone wanted a Redis sidecar. Then we wanted a network so they could talk. The shell history grew into a paragraph, nobody could remember the exact incantation, and onboarding a new developer meant copy-pasting a six-line command from a wiki page that was six months out of date.

The fix is to put it in a docker-compose.yml file. The question is how to get there without losing flags in the translation.

You can try every example in this guide with the Docker Run to Compose Converter. It parses entirely in your browser — no command is executed, nothing is uploaded.

Why a Compose file beats a one-liner

A long docker run command has the same problem as a long shell command in any language: it's not a program, it's a moment in time. It cannot be reviewed, version-controlled, diffed, branched, or rolled back. A Compose file fixes all of that:

  • Diffable — the change "bump Postgres from 16 to 17" becomes one line in a diff
  • Reproducible — a new developer runs docker compose up and gets the same stack you have
  • Documentable — the file is the documentation; no separate wiki entry to keep in sync
  • Composable — multiple services, shared networks, named volumes, all expressed once
  • Shareable — checked into the repo, available to CI runners, packaged with the app

The shell-history approach works for the first container you ever run. By the third, the cost of not having a Compose file is bigger than the cost of writing one.

The modern Compose shape

The current Compose Specification dropped the version: key at the top of the file. You don't need it anymore, and Compose emits a warning if you include it. A minimal web + database stack looks like this:

services:
  web:
    image: nginx:latest
    ports:
      - "80:80"
    depends_on:
      - db
    restart: unless-stopped
  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=app
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  pgdata:

Two top-level keys you'll see in every Compose file: services: (the containers you want to run) and the named-resource keys (volumes:, networks:, configs:, secrets:) at the bottom. The named-resource block at the bottom is required for any named volume or network you reference from a service — forgetting it is the most common reason a Compose file silently does the wrong thing.

The flag mapping

The converter is essentially a flag-by-flag translator. Most docker run flags map cleanly:

docker run flagCompose keyNotes
--name NAMEcontainer_name: NAMEPick a name per service, not just per container
-p HOST:CONTAINERports: ["HOST:CONTAINER"]Quote to avoid YAML's base-60 number trap
-v HOST:CONTAINERvolumes: ["HOST:CONTAINER"]Bind mounts
-v VOLUME:CONTAINERvolumes: ["VOLUME:CONTAINER"]Named volumes — declare in top-level volumes:
-e KEY=VALUEenvironment: ["KEY=VALUE"] or environment: { KEY: VALUE }Both work
--env-file FILEenv_file: FILEPath relative to the Compose file
--network NAMEnetworks: [NAME]Top-level networks: required for named networks
--restart POLICYrestart: POLICYno, always, on-failure, unless-stopped
-w DIRworking_dir: DIR
-u USERuser: USER
--entrypoint CMDentrypoint: CMDList form is more portable
--label KEY=VALUElabels: [KEY=VALUE] or labels: { KEY: VALUE }
--add-host HOST:IPextra_hosts: [HOST:IP]

A few flags don't translate because they're properties of the invocation, not the service definition:

docker run flagCompose equivalentWhy
--rm(no equivalent)Compose manages removal via the project lifecycle
-dimplicitCompose always runs services in detached mode
-it / -i / -t(no equivalent)Interactivity isn't a service property
--linkdepends_on: + shared networkLinks are deprecated; use networks
--pid, --privilegedpid: host, privileged: trueThese do have Compose equivalents, but think twice before using them

A complete before-and-after

Realistic command — a Postgres with persistent storage, custom user, and a published port:

docker run -d \
  --name my-postgres \
  -p 5432:5432 \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=mydb \
  -v pgdata:/var/lib/postgresql/data \
  --restart unless-stopped \
  postgres:16

Translated into Compose form:

services:
  postgres:
    container_name: my-postgres
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  pgdata:

Five things to notice:

  1. --name my-postgres becomes container_name: my-postgres inside the service block — the service name (postgres) is the key under services:, and container_name is the explicit container name
  2. The port mapping "5432:5432" is quoted — YAML's bare 5432:5432 would be a base-60 number, and 08:80 would be an invalid octal. Always quote
  3. The environment block uses the list form (- KEY=VALUE) — the map form (POSTGRES_USER: app) is also valid; pick one and be consistent
  4. The named volume pgdata is declared at the top-level volumes: block. Without that declaration, Compose creates an anonymous volume, which is harder to back up and harder to share
  5. --restart unless-stopped is the right default for databases and long-running services: survives crashes and reboots, respects a deliberate docker compose stop

The converter handles all of this. You can paste the full command in, get the YAML out, and edit from there.

Gotchas worth knowing

Bind mounts vs. named volumes. They look identical in the flag list but behave differently:

volumes:
  - ./html:/usr/share/nginx/html   # bind mount — a host path
  - pgdata:/var/lib/postgresql/data # named volume — managed by Docker

A bind mount maps a directory on your machine into the container — great for live-editing source code. A named volume is storage Docker manages for you, with the right lifecycle hooks for databases. Bind mounts are great for development; named volumes are usually the right choice for any data you want to keep across docker compose down.

depends_on isn't a health check. It controls start order, not readiness. Postgres may accept connections seconds after its container starts, but the database server may take longer. For that, you need a healthcheck:

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

If your app is retrying connections anyway, you can ignore this. If it's not, your "Postgres is ready but the app crashes on first connection" debugging session begins.

The Compose project name. When you run docker compose up, Compose creates a project named after the directory (myapp_web_1, myapp_db_1). Override it with -p or the name: top-level key. This matters when you have multiple copies of the same stack on one host.

The Compose file extension. .yml and .yaml both work. Pick one and stick with it; mixing both is fine for tooling but annoying for humans.

The version: key. Removed in the current Compose Specification. If you see older files with version: '3.8' at the top, leave them alone — they're still valid — but don't add it to new files.

From shell history to a checked-in file

The real workflow is:

  1. Paste your docker run command into the Docker Run to Compose Converter
  2. Get a starting Compose file back — review the service name, ports, volumes, environment
  3. Add a second service if you have a sidecar (Redis, an API, an admin UI) — this is where Compose starts to shine
  4. Validate the YAML with the YAML Validator — the indentation foot-guns are real
  5. Run docker compose config locally — it re-parses and re-formats your file, catching structural errors the validator might miss
  6. Commit the file; throw away the shell-history paragraph

For the other direction — composing a new stack visually — the Docker Compose Builder is the inverse: a form for each service that emits clean YAML.

Everything stays in your browser

The Docker Run to Compose Converter does no execution. The command you paste in is parsed into tokens and translated into YAML structure — never run, never started, never logged. Useful when your command includes environment variables you don't want anyone else to see.

Next steps