Migrating from docker run to Docker Compose
By OpsAtlas · · 3 min read
Table of contents
Most container setups start the same way: a docker run command copied from a README, then a second one for the database, then a shell script that runs both with the right flags in the right order. It works until someone else has to run it, or you have to remember which -v was which six months later.
Docker Compose describes the same containers in one declarative file. This guide moves a typical two-container app, a web server and a Postgres database, from docker run to compose.yaml, and points out what does not translate one to one.
The starting point
Here is the kind of script we want to replace:
docker network create appnet
docker run -d --name db --network appnet \
-e POSTGRES_PASSWORD=supersecret \
-v pgdata:/var/lib/postgresql/data \
--restart unless-stopped \
postgres:16
docker run -d --name web --network appnet \
-p 8080:3000 \
-e DATABASE_URL=postgres://postgres:supersecret@db:5432/postgres \
--restart unless-stopped \
ghcr.io/acme/web:2.3.1
Three commands, an ordering dependency that only exists in your head, and a password in your shell history.
Step 1: convert the commands
Each docker run becomes a service. Flags map to keys almost mechanically: -p to ports, -e to environment, -v to volumes, --restart to restart. Paste the script into the docker run to Compose converter to do this part automatically. It also declares the named volume and network at the top level:
services:
db:
image: postgres:16
container_name: db
networks:
- appnet
environment:
- POSTGRES_PASSWORD=supersecret
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
web:
image: ghcr.io/acme/web:2.3.1
container_name: web
networks:
- appnet
ports:
- "8080:3000"
environment:
- DATABASE_URL=postgres://postgres:supersecret@db:5432/postgres
restart: unless-stopped
volumes:
pgdata: {}
networks:
appnet: {}
That file already runs with docker compose up -d. But a straight translation carries over habits that only made sense with docker run.
Step 2: drop what Compose does for you
Remove the custom network. Compose creates a default network per project and every service joins it, reachable by its service name. Unless you need isolation between groups of services, delete both networks: blocks.
Remove container_name. Compose already names containers predictably (<project>-web-1), and a fixed name stops you from running two copies of the project or scaling a service with --scale. Other services reach it by the service name db, not the container name.
Step 3: move the secret out
POSTGRES_PASSWORD=supersecret is now committed to version control. Put it in a .env file next to compose.yaml, add .env to .gitignore, and reference it:
services:
db:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
The :? form makes docker compose up fail with a clear message when the variable is missing, instead of starting Postgres with an empty password.
Step 4: express the startup order
With docker run, you started the database first and hoped it was ready. Compose can wait for it, but only if the database reports its health:
services:
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
web:
depends_on:
db:
condition: service_healthy
depends_on without a condition only waits for the container to start, which for Postgres is several seconds before it accepts connections.
Step 5: bind ports deliberately
-p 8080:3000 publishes on every interface of the host, including public ones, and Docker’s iptables rules bypass most host firewalls such as ufw. If the app sits behind a reverse proxy on the same machine, bind to localhost:
ports:
- "127.0.0.1:8080:3000"
The result
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
web:
image: ghcr.io/acme/web:2.3.1
ports:
- "127.0.0.1:8080:3000"
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:
One file, one command (docker compose up -d), no secret in git, and a startup order that is enforced rather than remembered.
Checklist
- Convert each
docker runinto a service (the converter does this in one paste). - Delete user-defined networks and
container_nameunless you really need them. - Move secrets into
.envand use${VAR:?message}. - Add healthchecks and
depends_on: condition: service_healthy. - Bind published ports to
127.0.0.1when a proxy fronts the app. - Pin every image to a version tag.
Try it yourself
docker run to Docker Compose converter
Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.
Docker