Docker Run to Compose: Flag Mapping, Override Files, and Pitfalls
Documentation always shows the quick path: docker run -p 5432:5432 -v ./data:/var/lib/postgresql/data postgres:16. That works for a one-off test, but the moment you need a second service, share it with a teammate, or commit it to a repository, a single long command falls apart. Compose turns that command into a declarative YAML file that version control, CI, and every developer on the team can read and reproduce. Use the Docker Compose Converter to translate any docker run command instantly.
Flag-to-Compose Mapping
Every flag you pass to docker run has a direct equivalent in compose.yaml. The table below covers the flags you will encounter in almost every project:
| docker run flag | compose.yaml field | Notes |
|---|---|---|
-p 8080:80 | ports: ["8080:80"] | host:container; use string form to avoid YAML octal parsing of port 0x |
-v ./data:/var/lib/data | volumes: ["./data:/var/lib/data"] | Bind mount; relative paths resolve from the compose file location |
-v myvolume:/var/lib/data | volumes: ["myvolume:/var/lib/data"] + top-level volumes: myvolume: | Named volume; must be declared at the top-level volumes key |
--network mynet | networks: [mynet] + top-level networks: mynet: | Custom network; declare at top level too |
--restart unless-stopped | restart: unless-stopped | Values: no, always, on-failure, unless-stopped |
-e DB_USER=app | environment: DB_USER: app | Or list form: - DB_USER=app |
--name postgres | container_name: postgres | Omit in production — fixed names break scaling with --scale |
--rm | No direct equivalent | Compose removes containers on down; --rm is implicit for one-off run |
-u 1000:1000 | user: "1000:1000" | Quote the value — YAML parses bare numbers differently |
--link app:app | Use networks instead | --link is deprecated; services on the same network resolve by service name |
--health-cmd "pg_isready" | healthcheck: test: ["CMD", "pg_isready"] | Also set interval, timeout, retries |
Before and After: Postgres + App
Here are two typical docker run commands that a README might show — one for Postgres, one for a web application that connects to it:
docker run -d \
--name postgres \
-p 5432:5432 \
-e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=appdb \
-v pgdata:/var/lib/postgresql/data \
--restart unless-stopped \
postgres:16-alpine
docker run -d \
--name web \
-p 3000:3000 \
-e DATABASE_URL=postgres://app:secret@postgres:5432/appdb \
--link postgres:postgres \
--restart unless-stopped \
myapp:latestThe equivalent compose.yaml:
services:
postgres:
image: postgres:16-alpine
container_name: postgres
ports:
- "5432:5432"
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 10s
timeout: 5s
retries: 5
web:
image: myapp:latest
container_name: web
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@postgres:5432/appdb
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:Notice that --link postgres:postgres is gone. Services on the same default Compose network reach each other by their service name (postgres) automatically. The depends_on with condition: service_healthy replaces the old pattern of sleeping before the web process starts.
Why Compose Beats Long Run Commands
- Multi-service: one
docker compose upstarts everything in dependency order. No shell scripts with sequentialdocker runcalls. - Reproducible: the file is the ground truth. New team members clone the repo and run one command.
- Versioned: changes to ports, environment variables, and volume mounts are tracked in git with diffs and blame.
- Fewer keystrokes:
docker compose up -dinstead of a 10-flag command you have to copy from a wiki page. - Readable: YAML with service names and comments is far clearer than a shell one-liner to a new hire.
Version Pinning
The single most common cause of "it worked last week" failures is image: postgres:latest. The :latest tag is a moving target — your CI pulls a different image than your laptop, and a major version bump silently breaks your schema migrations.
Pin to a specific minor version or patch:
# Avoid
image: postgres:latest
# Good — minor version pin
image: postgres:16-alpine
# Best — exact digest (for supply-chain security)
image: postgres:16.3-alpine3.20A practical strategy: pin to the minor version in compose.yaml (e.g., postgres:16-alpine), and upgrade deliberately by bumping the tag and running your test suite. Use exact digest pins only in production or for security-sensitive images where you need full reproducibility.
Volumes: Bind Mount vs Named Volume
Two volume syntaxes look similar but behave differently:
services:
db:
image: postgres:16-alpine
volumes:
# Bind mount — maps a host path into the container
# Good for: source code, config files you edit locally
- ./data:/var/lib/postgresql/data
# Named volume — Docker manages the storage location
# Good for: database data, anything that should survive container rebuilds
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: # must be declared here for named volumesUse bind mounts for files you want to edit on the host and see reflected immediately inside the container — config files, source code in development. Use named volumes for database data and anything that needs to persist across docker compose down without living in a predictable host path. Named volumes survive down but are removed by docker compose down -v.
Networks
Compose creates a default bridge network for every project. All services in the same compose.yaml join it automatically and can resolve each other by service name. You do not need --link.
Custom networks are useful when you want to isolate communication or connect services across multiple compose files:
services:
web:
image: myapp:latest
networks:
- frontend
- backend
api:
image: myapi:latest
networks:
- backend
postgres:
image: postgres:16-alpine
networks:
- backend
networks:
frontend:
backend:Here web can reach both api and postgres, but postgres cannot initiate connections to web — it is not on the frontend network. This mirrors a real network security boundary without any firewall rules.
On macOS, host networking (network_mode: host) does not work the same way as on Linux. The Docker Desktop VM sits between your host and the containers, so host-networking bypasses the VM network, not your Mac's network. Use explicit port mappings instead.
Environment Variables
Three ways to supply environment variables in Compose, from simplest to most flexible:
services:
web:
image: myapp:latest
# 1. Inline — fine for non-secret config
environment:
NODE_ENV: production
PORT: "3000"
# 2. env_file — keeps secrets out of compose.yaml
env_file:
- .env
# 3. Interpolation from shell or .env file in project root
environment:
DATABASE_URL: postgres://${DB_USER}:${DB_PASS}@postgres:5432/appdbThe .env file in the same directory as compose.yaml is automatically loaded for variable interpolation. This is different from env_file, which injects variables directly into the container environment. The distinction matters: interpolation variables are resolved at compose parse time; env_file variables are passed to the running container.
Never commit .env files containing real credentials. Add .env to your .gitignore and commit a .env.example with placeholder values.
Override Files
Compose merges multiple files when you pass them with -f. The canonical pattern uses a base file plus environment-specific overrides:
# compose.yaml — base, committed to git
services:
web:
image: myapp:latest
ports:
- "3000:3000"
postgres:
image: postgres:16-alpine# compose.override.yaml — dev overrides, also committed
# Compose loads this automatically alongside compose.yaml
services:
web:
build: . # build from source in dev instead of pulling
volumes:
- .:/app # live reload: mount source into container
environment:
NODE_ENV: development# compose.prod.yaml — production overrides, used with -f
services:
web:
environment:
NODE_ENV: production
deploy:
replicas: 3
restart_policy:
condition: on-failure# Development (compose.yaml + compose.override.yaml loaded automatically)
docker compose up -d
# Production
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Staging
docker compose -f compose.yaml -f compose.staging.yaml up -dThis pattern keeps your dev setup (live source mounting, debug ports) completely separate from production without duplicating the entire service definition.
Compose v2 vs Compose v1
You may see two different commands in older documentation:
| Command | Version | Status |
|---|---|---|
docker-compose up | Compose v1 (Python) | End-of-life since July 2023. Do not use. |
docker compose up | Compose v2 (Go plugin) | Current. Bundled with Docker Desktop and Docker Engine. |
The hyphen (docker-compose) is the legacy Python standalone binary. The space (docker compose) is the v2 plugin built into the Docker CLI. They read the same YAML format for most cases, but v2 added depends_on: condition: service_healthy, profiles, and the compose.yaml filename (v1 only looked for docker-compose.yml). Always use docker compose.
Pitfalls
docker compose updoes not rebuild on code change. If you change yourDockerfileor application code, you must rundocker compose up --buildordocker compose buildfirst. Otherwise Compose reuses the cached image.restart: alwaysvsrestart: unless-stopped. Withalways, the container restarts even after you manually stop it withdocker compose stop. Useunless-stoppedso a deliberate stop is respected until the next daemon restart.- Host networking on macOS.
network_mode: "host"does not give you host-level access on macOS or Windows — Docker Desktop runs containers inside a Linux VM. Bind the port explicitly withportsinstead. - Secrets in compose.yaml. Hardcoded passwords in
environmentblocks end up in git history. Useenv_filewith a gitignored.env, or Docker Secrets for production Swarm deployments. - Named volumes not declared at the top level. Referencing a named volume in a service without declaring it under the top-level
volumes:key causes a parse error. Bind mounts do not require this declaration. depends_ondoes not wait for readiness by default. Without a healthcheck condition,depends_ononly waits for the container to start, not for the database inside it to accept connections. Add ahealthcheckto the dependency service and usecondition: service_healthy.
Convert any docker run command to a complete compose.yaml with the Docker Compose Converter — paste your command and get valid YAML in one click, no account required.