Your auroralibros/aurora-api:1.1.0 image is published and it is spotless: unprivileged user, OCI metadata, healthcheck and a clean 0.3-second shutdown. And it still returns ECONNREFUSED on /books, exactly as the previous lesson warned you. This entire module exists to fix that, and it starts where it has to start: really understanding the command you have been using since lesson 01-06 without ever looking at it closely. docker run is not one command, it is two: it creates a container and it starts it. And it accepts more than a hundred options, of which about fifteen get used in day-to-day work. In this lesson you are going to see what run does under the hood, you are going to learn those fifteen options grouped into families, you are going to override an image's CMD and ENTRYPOINT from the command line, and you are finally going to bring up aurora-db and aurora-cache as real containers. By the end you will have three live containers... and the API still will not work. That failure, with its error message changed, is the clue that kicks off the rest of the module.

Contents

  1. What docker run really does: create + start
  2. Anatomy of the command and the order of the arguments
  3. Identity family: --name and --hostname
  4. Execution family: -d, -it, --rm, -w, -u, --entrypoint
  5. Network and ports family: the bare minimum to work
  6. Configuration family: -e and --env-file
  7. Data family: -v in its minimal form
  8. Overriding CMD and ENTRYPOINT from the command line
  9. Foreground, background and how to detach
  10. docker attach versus docker exec
  11. Hands-on: the Aurora Libros database and cache

  1. What docker run really does: create + start

docker run is a shortcut. Underneath it performs two distinct operations that you can invoke separately:

docker create --name lifecycle-test -p 8080:80 nginx:alpine
7f3a9c1e8b2d4a6f05c7e91b3d8a4b2c6e0b7d9a1f3c5e7b9d1a3f5c7e9b1d3a

Docker prints the container's full ID (64 hexadecimal characters) and hands control back to you. Notice what has happened and what has not:

docker ps -a --filter name=lifecycle-test --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
NAMES            STATUS    PORTS
lifecycle-test   Created

The container exists, it has its writable layer reserved and all its configuration stored (ports, variables, command), but the state is Created and the PORTS column is empty: no process is running and port 8080 is not listening on your machine. Check it:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080
000

Now start it:

docker start lifecycle-test
lifecycle-test

docker start prints the name of the container it started. And now, yes:

docker ps --filter name=lifecycle-test --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080
NAMES            STATUS         PORTS
lifecycle-test   Up 3 seconds   0.0.0.0:8080->80/tcp
200

This separation is not an academic detail: it explains the division of responsibilities you are going to rely on throughout the module.

Phase What it does What can be changed afterwards
create Reserves the writable layer, stores the configuration (name, ports, variables, mounts, command, limits) Almost nothing: ports, variables and mounts are frozen forever
start Creates the namespaces, applies the cgroups and launches the PID 1 process Can be repeated as many times as you like (stop/start)

The practical consequence, worth burning into your memory right now: you cannot add a port or an environment variable to a container that already exists. If you got it wrong, you delete it and create it again. That is exactly why in module 4 you will end up writing that configuration into a file instead of on the command line.

Clean up the demonstration:

docker rm -f lifecycle-test

The complete flow, now that you know the pieces from lesson 01-03:

flowchart TD
    A["docker run -d -p 8080:80 nginx:alpine"] --> B{"Is the image<br/>local?"}
    B -- No --> C["implicit docker pull<br/>from the registry"]
    B -- Yes --> D["CREATE PHASE"]
    C --> D
    D --> D1["Writable layer (copy-on-write)"]
    D --> D2["Configuration: name, ports,<br/>variables, command"]
    D1 --> E["START PHASE"]
    D2 --> E
    E --> E1["Namespaces: pid, net, mnt, uts, ipc"]
    E --> E2["Cgroups: memory and CPU"]
    E --> E3["Network rules and port publishing"]
    E1 --> F["runc launches the PID 1 process"]
    E2 --> F
    E3 --> F
    F --> G["Container in running state"]

  1. Anatomy of the command and the order of the arguments

docker run [OPTIONS] IMAGE [COMMAND] [ARGUMENTS...]

There is a single golden rule, and it causes half of all beginner errors:

Everything before the image is a Docker option. Everything after the image is the command that runs inside the container.

Watch it fail:

docker run alpine:3.20 -e MESSAGE=hello echo test
docker: Error response from daemon: failed to create task for container:
failed to create shim task: OCI runtime create failed: exec: "-e": executable file not found in $PATH

Docker did not read -e as one of its own options: because it came after alpine:3.20, it tried to run a program called -e inside the container. The correct version:

docker run --rm -e MESSAGE=hello alpine:3.20 sh -c 'echo $MESSAGE'
hello

Here --rm and -e MESSAGE=hello are Docker options (they go before the image) and sh -c 'echo $MESSAGE' is the command inside the container (it goes after). We need sh -c because expanding $MESSAGE is a shell's job, and without it Docker would pass the literal string.

An overview of the families you will meet in the sections that follow:

Family Options What for
Identity --name, --hostname, --label Being able to refer to the container and organize it
Execution -d, -it, --rm, -w, -u, --entrypoint How and with what identity the process runs
Network and ports -p, -P, --expose, --network Who can talk to it (in depth in 03-05)
Configuration -e, --env-file What values the application reads
Data -v, --mount, --tmpfs What outlives the container (in depth in 03-06)
Resources and resilience -m, --cpus, --restart How much it may consume and what happens if it fails (in depth in 03-07)

  1. Identity family: --name and --hostname

--name

Without --name, Docker invents a two-word name (nostalgic_hopper, elegant_bardeen) and forces you to copy IDs around. With a name, every subsequent command becomes readable and scriptable:

docker run -d --name aurora-cache redis:7-alpine
docker logs aurora-cache
docker stop aurora-cache

The rules for names:

Rule Detail
Valid characters [a-zA-Z0-9][a-zA-Z0-9_.-]*
Unique across the whole machine Stopped containers included
Doubles as a DNS name On a user-defined network, another container resolves it by that name (lesson 03-05)

That last point is the key to the whole module, and it is why the Aurora Libros names (aurora-db, aurora-cache, aurora-api, aurora-web) are not decorative: very soon they will be real hostnames.

The most common mistake with --name:

docker run -d --name aurora-cache redis:7-alpine
docker: Error response from daemon: Conflict. The container name "/aurora-cache" is already in use by container "a1b2c3d4e5f6".
You have to remove (or rename) that container to be able to reuse that name.

Careful: the conflict happens even if the previous container is stopped. An Exited container still occupies its name. You resolve it with docker rm aurora-cache (or docker rm -f if it is running).

--hostname

It changes the name the container sees for itself inside its own UTS namespace:

docker run --rm --name hostname-test --hostname aurora-node-1 alpine:3.20 hostname
aurora-node-1

Without --hostname, the hostname is the container's short ID:

docker run --rm --name hostname-test2 alpine:3.20 hostname
9c4e1a7b2f83

--name and --hostname are different things: --name is how you call it from outside (and how other containers call it over DNS); --hostname is what the container calls itself. It gets little use, but it shows up in application logs and in database clusters.

  1. Execution family: -d, -it, --rm, -w, -u, --entrypoint

Option What it does When you use it
-d, --detach Starts in the background and prints the ID Services: databases, APIs, web servers
-i, --interactive Keeps standard input open When you are going to type something to the process
-t, --tty Allocates a pseudo-terminal (prompt, colors, Ctrl+C) Interactive shells
-it The combination of the previous two Getting into a container with sh or bash
--rm Deletes the container when it finishes One-shot commands and tests
-w, --workdir Working directory, overrides the image's WORKDIR Running something in a different path without rebuilding
-u, --user User/UID the process runs as Adjusting permissions or getting into an unprivileged image as root
--entrypoint Replaces the image's ENTRYPOINT Debugging an image whose fixed executable gets in the way

-d versus the foreground

docker run -d --name web-background -p 8080:80 nginx:alpine
c3f8a1b7e2d94c6501fa7b3e8d2c9a4b6e0f1d3a5c7e9b1d3f5a7c9e1b3d5f7a

It returns the ID and leaves your terminal free. Without -d, the terminal stays busy showing the process's output and Ctrl+C stops it.

-it and why you need both letters

docker run --rm -it alpine:3.20 sh
/ # whoami
root
/ # exit

Try dropping one of the two letters to understand what each one contributes:

Command Result
docker run --rm alpine:3.20 sh The shell starts, finds no input and exits immediately. You are back at the host prompt
docker run --rm -i alpine:3.20 sh It works: you can type commands, but with no prompt, no colors and no history
docker run --rm -t alpine:3.20 sh You see the / # prompt, but what you type never arrives: the container has no stdin. It hangs
docker run --rm -it alpine:3.20 sh A complete interactive session

A practical rule: -it for people, -d for services, nothing for one-shot commands. And never -it in an automated script or in CI: with no real terminal, -t triggers the input device is not a TTY.

--rm

docker run --rm alpine:3.20 date -u
Tue Aug  4 19:32:11 UTC 2026

The container runs, prints and disappears: it does not linger in docker ps -a and it takes up no writable layer. It is the option that stops you accumulating dozens of dead containers.

Two important warnings:

  • --rm is incompatible with debugging: if the container fails, it is deleted along with its logs and its inspect. When something goes wrong, drop the --rm so you can investigate (lesson 03-04).
  • --rm also deletes the associated anonymous volumes. With named volumes nothing happens, but it is a detail we come back to in lesson 03-06.

-w and -u

docker run --rm -w /tmp alpine:3.20 pwd
docker run --rm -u 1000:1000 alpine:3.20 id
docker run --rm -u root auroralibros/aurora-api:1.1.0 id
/tmp
uid=1000 gid=1000 groups=1000
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...
  • -w /tmp overrides the WORKDIR /app you set in the Dockerfile.
  • -u 1000:1000 forces a specific UID:GID even if that user does not exist inside the image (which is why id shows the numbers without names).
  • -u root cancels the USER node in your image. It is extremely handy for debugging (installing a tool inside an unprivileged container), and at the same time a reminder that USER is defense in depth, not an impassable barrier: whoever can launch containers can choose the user.

--entrypoint

You already used it in lesson 02-04, and here it gets its proper place:

docker run --rm -it --entrypoint sh auroralibros/aurora-api:1.1.0
/app $ ls
Dockerfile  node_modules  package-lock.json  package.json  server.js
/app $ node --version
v22.13.0

Without --entrypoint, your image has a fixed ENTRYPOINT ["node"] and anything you wrote after the image would be an argument to node. With --entrypoint sh you get in and look around. It is the service door of every well-built image.

  1. Network and ports family: the bare minimum to work

Only the essentials here; the full explanation arrives in lesson 03-05.

docker run -d --name aurora-web-demo -p 8080:80 nginx:alpine

-p HOST:CONTAINER means "anything arriving at port 8080 on my machine, forward it to port 80 of this container". The order is never reversed: host first, container second.

Syntax Meaning
-p 8080:80 Port 8080 on all host interfaces → port 80 of the container
-p 127.0.0.1:8080:80 Only from your own machine; not visible on the local network
-p 80 Port 80 of the container to a random free port on the host
-p 8080:80/udp Publishes UDP instead of TCP
-P Publishes all ports declared with EXPOSE, each on a random port
--expose 9000 Declares the port in the metadata, without publishing it on the host

Check -P with your own image, which declares EXPOSE 3000:

docker run -d --name random-api -P auroralibros/aurora-api:1.1.0
docker port random-api
3000/tcp -> 0.0.0.0:32768

Docker picked 32768 from the ephemeral range. docker port is the command that answers "which host port did this end up on?".

Two ideas worth fixing right away, even though they are developed in 03-05:

  • EXPOSE in the Dockerfile and --expose in run open nothing: they are documentation consumed by -P and other tools.
  • Publishing a port is what lets you, from the host, reach the container. For two containers to talk to each other, nothing needs to be published. That distinction is the one that is about to solve the API's mystery.

Clean up:

docker rm -f aurora-web-demo random-api

  1. Configuration family: -e and --env-file

Your server.js does not have a single hardcoded value: it reads PORT, DB_HOST, DB_USER, DB_PASSWORD, DB_NAME and REDIS_HOST from the environment. That decision from lesson 01-07 is what lets the same image serve your laptop, testing and production.

-e in its three forms

docker run --rm -e GREETING="Hello Aurora" alpine:3.20 printenv GREETING
export LOCAL_TOKEN=abc123
docker run --rm -e LOCAL_TOKEN alpine:3.20 printenv LOCAL_TOKEN
docker run --rm auroralibros/aurora-api:1.1.0 --version
Hello Aurora
abc123
v22.13.0
Form Effect
-e KEY=value Defines the variable with that literal value
-e KEY Copies whatever value that variable has in the host's shell
No -e The application uses the image's ENV or its default value in the code

The second form is very practical for keeping secrets off the command line, where they would end up in the shell history.

Variables defined with -e override those from the Dockerfile's ENV. Check it with your image, which ships ENV PORT=3000:

docker run --rm -e PORT=4000 --entrypoint printenv auroralibros/aurora-api:1.1.0 PORT
4000

And look at the whole precedence ladder again:

Precedence Source Example
1 (wins) -e / --env-file on docker run -e PORT=4000
2 The Dockerfile's ENV ENV PORT=3000
3 Default value in the code process.env.PORT || 3000

--env-file

Once you pass three variables, the command line becomes unreadable. Create ~/aurora-libros/aurora.env:

# Local Aurora Libros configuration — NOT baked into the image
PORT=3000
DB_HOST=aurora-db
DB_PORT=5432
DB_USER=aurora
DB_PASSWORD=aurora_secret
DB_NAME=aurora_books
REDIS_HOST=aurora-cache
REDIS_PORT=6379

And use it:

docker run --rm --env-file ~/aurora-libros/aurora.env \
  --entrypoint printenv auroralibros/aurora-api:1.1.0 DB_HOST DB_NAME
aurora-db
aurora_books

The format rules are strict and different from a shell script's. This is a spot where a lot of time gets lost:

Rule Correct Wrong, and why
One variable per line, KEY=value DB_USER=aurora export DB_USER=aurora → the key would be export DB_USER
No quotes unless they are part of the value DB_PASSWORD=aurora_secret DB_PASSWORD="aurora_secret" → the password would include the quotes
No variable expansion DATA_PATH=/app/data DATA_PATH=$HOME/data → the literal value would be $HOME/data
Comments with # at the start of the line # comment DB_PORT=5432 # the port → the value would be 5432 # the port
No spaces around the = PORT=3000 PORT = 3000 → the key would be PORT

And a security rule you already know from module 2, now with its concrete consequence:

echo "aurora.env" >> ~/aurora-libros/.gitignore
echo "aurora.env" >> ~/aurora-libros/api/.dockerignore

The environment file contains a password: it goes neither into the Git repository nor into the build context. The image stays free of secrets —as it should— and the configuration travels outside it, injected at startup. You can combine both options: --env-file for the bulk and -e for whatever you want to override on the spot, because -e beats --env-file regardless of the order you write them in.

  1. Data family: -v in its minimal form

You used it in lesson 01-06 to serve your index.html with Nginx:

docker run -d --name aurora-web-demo -p 8080:80 \
  -v ~/aurora-libros/web/index.html:/usr/share/nginx/html/index.html:ro \
  nginx:alpine

The minimal syntax is -v SOURCE:DESTINATION[:options], with the host path always absolute and :ro to mount read-only. That is enough until lesson 03-06, where you will see the three types of mount, managed volumes and why the book catalog you are about to create will vanish if you do nothing about it. For now, hold on to the idea from lesson 01-05: everything a container writes outside a mount lives in its writable layer and dies with it.

  1. Overriding CMD and ENTRYPOINT from the command line

We pick up the table from lesson 02-04, now from the execution side. Your image declares:

ENTRYPOINT ["node"]
CMD ["server.js"]

The effective command is the concatenation ENTRYPOINT + CMD → node server.js. And from docker run you can touch each half separately:

Command ENTRYPOINT CMD What runs Result
docker run IMG node server.js node server.js Starts the API
docker run IMG --version node --version node --version Prints v22.13.0
docker run IMG -e "console.log(2+2)" node -e console.log(2+2) node -e "console.log(2+2)" Prints 4
docker run --entrypoint sh IMG sh (discarded) sh Interactive shell
docker run --entrypoint sh IMG -c "ls /app" sh -c ls /app sh -c "ls /app" Lists the directory
docker run --entrypoint "" IMG ls -la /app (none) ls -la /app ls -la /app Runs ls directly

Check it:

docker run --rm auroralibros/aurora-api:1.1.0 -e "console.log('Aurora ' + (2+2))"
docker run --rm --entrypoint sh auroralibros/aurora-api:1.1.0 -c "ls /app | head -3"
docker run --rm --entrypoint "" auroralibros/aurora-api:1.1.0 ls -la /app/server.js
Aurora 4
Dockerfile
node_modules
package-lock.json
-rw-r--r--    1 node     node          4187 Aug  4 19:12 /app/server.js

Two subtleties that catch people out:

  • Overriding the ENTRYPOINT discards the image's CMD. In row 4 of the table, CMD ["server.js"] disappears: if you did not want sh to receive server.js as an argument, you have nothing to do, it is already gone.
  • --entrypoint "" (empty string) is the way to leave the image with no fixed executable, so that whatever you write after the image is the complete command. It is the trick that saves the day with images whose entrypoint is a complicated script.

  1. Foreground, background and how to detach

When you start without -d, your terminal stays hooked to the input and output of PID 1:

docker run --name web-foreground -p 8080:80 nginx:alpine
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/08/04 19:41:07 [notice] 1#1: start worker processes

And there it stays. If you press Ctrl+C, you send SIGINT to the process and the container stops. That is rarely what you want with a service.

The alternative, if you started with -it, is to detach without killing it using the sequence Ctrl+P followed by Ctrl+Q:

docker run -it --name alpine-detach alpine:3.20 sh
/ # sleep 300
<you press Ctrl+P and then Ctrl+Q>
read escape sequence
docker ps --filter name=alpine-detach --format "{{.Names}}: {{.Status}}"
alpine-detach: Up 22 seconds

Still alive. Three conditions for the sequence to work, and their absence explains 90% of the "it doesn't work for me" cases:

  1. The container must have been started with -t (it needs the pseudo-terminal).
  2. It must be hooked to stdin, that is, with -i.
  3. The sequence is pressed inside the session, not in another terminal.

If you need Ctrl+P for something else (in bash it recalls the previous command), change it:

docker run -it --detach-keys="ctrl-e,e" --name alpine-keys alpine:3.20 sh

Now the combination is Ctrl+E followed by e. You can make it permanent in ~/.docker/config.json:

{
  "detachKeys": "ctrl-e,e"
}

A summary of the three ways to start:

Form Command Terminal Ctrl+C
Foreground docker run IMG Busy showing the output Stops the container
Interactive foreground docker run -it IMG sh Busy, with a session Goes to the process inside
Background docker run -d IMG Free immediately Not applicable

Clean up:

docker rm -f web-foreground alpine-detach alpine-keys

  1. docker attach versus docker exec

A container in the background can be "looked at again" in two radically different ways:

docker run -d --name aurora-cache-demo redis:7-alpine
docker attach aurora-cache-demo

docker attach connects you to the input and output of the PID 1 process that already exists. It launches nothing new. And that is where its danger lies:

1:M 04 Aug 2026 19:45:03.221 * Ready to accept connections tcp
<you press Ctrl+C>
docker ps -a --filter name=aurora-cache-demo --format "{{.Names}}: {{.Status}}"
aurora-cache-demo: Exited (0) 4 seconds ago

You just stopped Redis. Ctrl+C during an attach goes straight to PID 1. To leave without killing it you have to use Ctrl+P Ctrl+Q, or connect in safe mode from the start:

docker attach --sig-proxy=false aurora-cache-demo

With --sig-proxy=false, Ctrl+C gives you the prompt back without sending the signal to the container.

docker exec, in contrast, launches a new process inside the container:

docker start aurora-cache-demo
docker exec -it aurora-cache-demo redis-cli PING
PONG

That redis-cli has nothing to do with PID 1: it is a second process that shares the container's namespaces and that can finish without affecting the service.

docker attach docker exec
What it does Connects to the existing PID 1 Creates a new process
Can it be used several times at once? Yes, but everyone sees the same thing Yes, independently
Ctrl+C Kills the service (unless --sig-proxy=false) Ends only your process
Leaving without damage Ctrl+P Ctrl+Q exit
Typical use Watching the live output of an interactive process Opening a shell, running diagnostics
Recommendation Avoid it except in specific cases The default choice

The practical rule: to watch output use docker logs, and to get in use docker exec. Both are studied in depth in lesson 03-04. docker attach is reserved for processes that genuinely wait for your keyboard input.

docker rm -f aurora-cache-demo

  1. Hands-on: the Aurora Libros database and cache

The moment has arrived. Until now the API started on its own and failed because there was neither PostgreSQL nor Redis. Let's put them in place.

The database

docker run -d \
  --name aurora-db \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secret \
  -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine

Let's go over each line, because each one has its reason:

Fragment Why
-d It is a service: we do not want the terminal tied up
--name aurora-db Two lessons from now this name will be the hostname the API uses
-e POSTGRES_USER/PASSWORD/DB These are the three variables the official PostgreSQL image uses to initialize itself the first time. They match what server.js expects
-p 127.0.0.1:5432:5432 We publish locally only so we can connect with a client from the host. If you wrote -p 5432:5432, your database would be reachable from the entire local network
postgres:16-alpine The same image you pulled in module 2

Check that it is alive:

docker ps --filter name=aurora-db --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
docker exec aurora-db pg_isready -U aurora
NAMES       IMAGE                STATUS
aurora-db   postgres:16-alpine   Up 12 seconds
/var/run/postgresql:5432 - accepting connections

pg_isready is PostgreSQL's official tool for asking "are you accepting connections?". We run it inside the container with docker exec, with no need to have PostgreSQL installed on the host. That detail —using the tools that already ship in the image— is one of Docker's most underrated conveniences.

And check that the database exists, even if it is still empty:

docker exec aurora-db psql -U aurora -d aurora_books -c "\dt"
Did not find any relations.

Correct: the aurora_books database is created, but there are no tables. db/init.sql still needs to run, and that will happen automatically and elegantly in lesson 03-06, when you mount that file inside the container.

The cache

docker run -d \
  --name aurora-cache \
  -p 127.0.0.1:6379:6379 \
  redis:7-alpine

Redis needs no variables: it starts with its default configuration.

docker exec aurora-cache redis-cli PING
docker exec aurora-cache redis-cli SET test "Aurora Libros"
docker exec aurora-cache redis-cli GET test
PONG
OK
"Aurora Libros"

Redis works. Two live containers.

And now the API... which still does not work

docker run -d \
  --name aurora-api \
  -p 3000:3000 \
  --env-file ~/aurora-libros/aurora.env \
  auroralibros/aurora-api:1.1.0
b7e3d1a9f4c25e8b06d3a7f1c9e5b2d4a8f0c6e3b1d7a5f9c3e1b7d5a9f3c1e7

Docker accepted the command without complaining. But:

sleep 3
docker ps -a --filter name=aurora-api --format "table {{.Names}}\t{{.Status}}"
NAMES        STATUS
aurora-api   Exited (1) 2 seconds ago

The container did not even stay running. It started and died with exit code 1. Let's see why:

docker logs aurora-api
[cache] error: getaddrinfo ENOTFOUND aurora-cache
[aurora-api] failed to start: getaddrinfo ENOTFOUND aurora-cache

Look carefully at the message, because it has changed since module 2 and that change is a first-rate clue:

Configuration Error What it means
DB_HOST=localhost (module 2) ECONNREFUSED 127.0.0.1:5432 The name did resolve (to itself), but nobody is listening on that port inside the container
DB_HOST=aurora-db (now) getaddrinfo ENOTFOUND aurora-db The name could not even be resolved: as far as this container is concerned, aurora-db does not exist

And here is the paradox that gives the rest of the module its purpose: aurora-db and aurora-cache are running right now, on the same machine, publishing their ports. You can verify it from the host:

docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
NAMES          STATUS          PORTS
aurora-cache   Up 4 minutes    127.0.0.1:6379->6379/tcp
aurora-db      Up 6 minutes    127.0.0.1:5432->5432/tcp

They are alive, they are published, and even so aurora-api cannot find them. Why?

The short answer: because they do not share a network. Each container has its own network stack, and on Docker's default network there is no name resolution between containers. The long answer, with the complete solution and the curl that returns the eight books, is lesson 03-05. For now, leave the question open and delete nothing: these two containers will keep you company throughout the module.

docker rm aurora-api

We only delete the API (it is stopped and it is useless). aurora-db and aurora-cache stay.

Common Mistakes and Tips

  • Putting options after the image. docker run alpine -e VAR=1 sh tries to run a program called -e. Options go before the image, always.
  • Expecting to add a port or a variable to an existing container. You cannot: they are frozen in the create phase. The solution is docker rm and create it again. When that starts to wear you down, you will be ready for module 4.
  • Using --rm while debugging. If the container fails and gets deleted, you are left with no logs and no inspect. During an investigation, drop the --rm.
  • -p 5432:5432 on a database. It publishes PostgreSQL on all interfaces, including the café's Wi-Fi. Use -p 127.0.0.1:5432:5432, or simply do not publish it: containers do not need published ports to talk to each other.
  • Reversing the order in -p. -p 80:8080 with Nginx does not work and the error is confusing, because Docker happily publishes host port 80 towards an 8080 where nobody is listening. Host first, container second.
  • -t in scripts and CI. the input device is not a TTY. In automation use -i or nothing, never -t.
  • Quotes in an --env-file. DB_PASSWORD="aurora_secret" stores the password with the quotes included and causes a baffling password authentication failed. No quotes.
  • Tip: always use --name. An unnamed container is a container you will be hunting for by ID ten minutes from now in a twenty-line docker ps -a.
  • Tip: write long docker run commands across several lines with a trailing \, and keep them in a notes file. You are going to repeat them many times, and in lesson 03-07 you will see how much they have grown.

Exercises

Exercise 1: demonstrate the separation between create and start

Without using docker run at any point, create an nginx:alpine container called lifecycle-exercise published on port 8090 and with the variable ENVIRONMENT=tests. Before starting it, answer with commands: what state is it in? Does port 8090 respond? Does it show up in docker ps? Then start it, check the three things again, and try to add a second port (8091) without deleting it. Explain what happens and why.

Exercise 2: master overriding ENTRYPOINT and CMD

Using only the auroralibros/aurora-api:1.1.0 image and without rebuilding it, produce these five outputs, writing the exact command in each case:

  1. The installed Node version.
  2. The contents of /app/package.json.
  3. An interactive shell inside the container.
  4. The result of console.log(process.env.DB_HOST) with DB_HOST set to aurora-db.
  5. The file listing of /app by running ls directly, with no node and no sh involved.

Exercise 3: file-based configuration for two environments

Create two environment files, aurora-dev.env and aurora-test.env, that differ in PORT (3000 and 3100), DB_NAME (aurora_books and aurora_books_test) and REDIS_HOST. Start two API containers with the same command except for the --env-file and the --name, verify with printenv that each one has its own configuration, and then start a third that uses aurora-dev.env but with PORT=3200 overridden from the command line. Answer: how many different images did you need? And which line of your .gitignore stops this ending up in the repository?

Solutions

Solution to exercise 1

docker create --name lifecycle-exercise -p 8090:80 -e ENVIRONMENT=tests nginx:alpine
2d8f4a1c7e93b5061fa8c2e7d4b9a6f3c1e8d5b2a9f7c4e1b8d5a2f9c6e3b1d7

State before starting:

docker ps -a --filter name=lifecycle-exercise --format "table {{.Names}}\t{{.State}}\t{{.Ports}}"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8090
docker ps --filter name=lifecycle-exercise -q
NAMES                STATE     PORTS
lifecycle-exercise   created

000

The three answers: state created, the port does not respond (code 000, connection refused) and it does not appear in docker ps (the third command's output is empty), because docker ps without -a only lists running containers. The configuration is stored, but there is no process and no network rule.

docker start lifecycle-exercise
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8090
docker exec lifecycle-exercise printenv ENVIRONMENT
lifecycle-exercise
200
tests

Now it works: it appears in docker ps, the port returns 200 and the variable you defined in the create phase is present.

The attempt to add a port:

docker update -p 8091:80 lifecycle-exercise
unknown flag: -p

There is no way to do it. docker update only modifies resources (memory, CPU, restart policy — lesson 03-07), never ports, variables or mounts: those are frozen when the container is created because they involve network namespaces and forwarding rules that are built at startup. The only way out is to recreate it:

docker rm -f lifecycle-exercise
docker run -d --name lifecycle-exercise -p 8090:80 -p 8091:80 -e ENVIRONMENT=tests nginx:alpine
docker rm -f lifecycle-exercise

This rigidity is precisely the argument in favor of declaring configuration in a versioned file instead of on the command line, which is what Docker Compose does in module 4.

Solution to exercise 2

# 1. Node version: the CMD "server.js" is replaced by "--version",
#    and the ENTRYPOINT ["node"] stays in place → node --version
docker run --rm auroralibros/aurora-api:1.1.0 --version

# 2. Contents of package.json: we need "cat", which is not node.
#    We cancel the entrypoint entirely.
docker run --rm --entrypoint cat auroralibros/aurora-api:1.1.0 /app/package.json

# 3. Interactive shell: entrypoint sh plus the two interactivity letters
docker run --rm -it --entrypoint sh auroralibros/aurora-api:1.1.0

# 4. Evaluating code with an environment variable: Docker's -e (before the
#    image) and node's -e (after). The same dash, two meanings.
docker run --rm -e DB_HOST=aurora-db auroralibros/aurora-api:1.1.0 \
  -e "console.log(process.env.DB_HOST)"

# 5. Running ls directly, with no node and no sh in between
docker run --rm --entrypoint "" auroralibros/aurora-api:1.1.0 ls -1 /app
v22.13.0
{
  "name": "aurora-api",
  ...
}
/app $
aurora-db
Dockerfile
node_modules
package-lock.json
package.json
server.js

Case 4 is the most instructive of the exercise: there are two -e flags in the same command and they mean different things. The first is before the image, so it is Docker's --env option; the second is after, so it is an argument Docker hands over untouched to ENTRYPOINT ["node"], and there -e is Node's option for evaluating code. The rule from section 2 explains it without ambiguity.

Case 5 shows the difference between --entrypoint ls and --entrypoint "". With --entrypoint ls you would still have to write the arguments after the image, but with --entrypoint "" the line reads naturally: the image stops imposing an executable and everything that follows is the complete command.

Solution to exercise 3

cat > ~/aurora-libros/aurora-dev.env <<'EOF'
PORT=3000
DB_HOST=aurora-db
DB_USER=aurora
DB_PASSWORD=aurora_secret
DB_NAME=aurora_books
REDIS_HOST=aurora-cache
EOF

cat > ~/aurora-libros/aurora-test.env <<'EOF'
PORT=3100
DB_HOST=aurora-db
DB_USER=aurora
DB_PASSWORD=aurora_secret
DB_NAME=aurora_books_test
REDIS_HOST=aurora-cache-test
EOF

Verification with printenv, without starting the API (which would fail for reasons you already know):

docker run --rm --env-file ~/aurora-libros/aurora-dev.env \
  --entrypoint printenv auroralibros/aurora-api:1.1.0 PORT DB_NAME REDIS_HOST

docker run --rm --env-file ~/aurora-libros/aurora-test.env \
  --entrypoint printenv auroralibros/aurora-api:1.1.0 PORT DB_NAME REDIS_HOST
3000
aurora_books
aurora-cache
3100
aurora_books_test
aurora-cache-test

The third one, with a one-off override:

docker run --rm --env-file ~/aurora-libros/aurora-dev.env -e PORT=3200 \
  --entrypoint printenv auroralibros/aurora-api:1.1.0 PORT DB_NAME
3200
aurora_books

PORT is 3200 (the -e won) and DB_NAME keeps the file's value. -e always takes precedence over --env-file, regardless of the order you write them in on the command line: try swapping them around and you will get the same result.

The two final answers:

  • A single image. auroralibros/aurora-api:1.1.0 served three different configurations without being rebuilt even once. This is the build once, deploy everywhere principle you saw in lesson 02-06: what changes between environments is the injected configuration, never the image.
  • The .gitignore line is *.env (or aurora-*.env). These files contain DB_PASSWORD=aurora_secret; they do not go into the repository, and not into the .dockerignore either, so that they never end up in the build context nor, by accident, inside an image layer.

Conclusion

docker run has stopped being a magic formula. You know it is two operations, create and start, and you have proved it by running them separately: the create phase reserves the writable layer and freezes the configuration —which is why you cannot add a port or a variable to an existing container— and the start phase sets up namespaces, cgroups and network rules and launches PID 1. You know the golden rule about argument order, which separates Docker's options from the command inside, and you have the five families of options sorted out: identity, execution, network, configuration and data.

You handle -d for services, -it for people and --rm for throwaway commands, knowing that --rm becomes your enemy the moment something fails. You know how to override an image's CMD half and ENTRYPOINT half separately, including the complete cancellation with --entrypoint "". You know how to detach from a session with Ctrl+P Ctrl+Q without killing the process, and why docker attach is a sharp tool that can take a service down with one distracted Ctrl+C, as opposed to docker exec, which launches an independent process. And you know how to keep configuration outside the image with --env-file, with its strict format rules and its password that never enters Git or the build context.

Above all, the platform has started to move: aurora-db and aurora-cache are running right now on your machine, with PostgreSQL 16 accepting connections and Redis answering PONG. And aurora-api no longer gives ECONNREFUSED: it gives getaddrinfo ENOTFOUND aurora-db, a different error that says something very specific —the name does not even resolve— and that points straight at the solution. On top of that, the container did not end up unhealthy: it died in two seconds with exit code 1.

And that raises the question the course continues with: why do some containers keep running indefinitely while others die instantly? In the next lesson, Container Lifecycle, you are going to see the seven states a container passes through and the transitions between them, the rule that a container lives exactly as long as its PID 1 process lives, the difference between an orderly stop with SIGTERM and a SIGKILL ten seconds later, and the table of exit codes —that 1, and also 125, 137 and 143— that turns a cryptic number into a diagnosis. And you will teach server.js to shut down cleanly, closing the PostgreSQL pool and the Redis client before it goes.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved