Guide

Run your first container

  • Beginner
  • 20 min
  • Published
  • Verified

What you'll learn

  • Start a container in the background with docker run -d and give it a name with --name
  • Publish a port with -p, and explain which number is the host port and which is the container port
  • Run a second copy of the same image on a different host port
  • Check on containers with docker ps, docker ps -a, and docker logs
  • Run commands inside a running container with docker exec
  • Configure an app at startup with -e environment variables
  • Stop, restart, and remove containers, and explain why removing one loses its data

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

You have an app you want to try, and the instructions are a page of setup: install the right version of Node.js, install its packages, hope a native module compiles on your machine, and then start it. With a container, all of that is already done. It’s packaged into an image, and one command starts it.

In this guide, you’ll run a small to-do app in a container and reach it from your browser. Along the way you’ll run a second copy beside the first, change its configuration without touching its code, look inside it, and find out what happens to the to-dos you added when the container goes away.

If you’d like the ideas first, see What is a container?. This guide is the hands-on side.

Before you start

You can follow this whole guide in your browser. The terminals on this page are simulations: click through them to see what each command does and what it prints.

To run it on your own machine, you need Docker: install Docker Desktop or Docker Engine. That’s all. You don’t need Node.js or Git.

Your output will differ from the simulations in the details: container IDs, timestamps, and how long things take.

Step 1: Get the app’s image

A container always starts from an image: a read-only package holding the app, its runtime, and everything else it needs. Most images you’ll use are ready-made on Docker Hub, and docker run downloads them for you. The app in this guide, the getting-started to-do app, doesn’t publish one, so you’ll build it straight from its Git repository with a single command:

Terminal

docker build -t todo-app https://github.com/docker/getting-started-todo-app.git
[+] Building 83.4s (26/26) FINISHED              docker:default
 => [internal] load git source https://github.com/docker/  6.2s
 => [internal] load metadata for docker.io/library/node:2  1.1s
 => [base 1/2] FROM docker.io/library/node:22@sha256:826  25.3s
 => [base 2/2] WORKDIR /usr/local/app                      0.2s
 => [client-build 1/1] RUN npm run build                   2.1s
 => [test 1/1] RUN npm run test                            0.6s
 => [final 6/6] COPY --from=client-build /usr/local/app/d  0.0s
 => => exporting manifest list sha256:4edbe5e0f1d3396d664  0.0s
 => => naming to docker.io/library/todo-app:latest         0.0s
 => => unpacking to docker.io/library/todo-app:latest      4.4s
docker image ls
IMAGE             ID             DISK USAGE   CONTENT SIZE   EXTRA
todo-app:latest   4edbe5e0f1d3       1.67GB          413MB

The build takes about a minute, and Docker prints its progress for every step as it goes. The output here is trimmed to a few of them. Docker fetched the repository, followed the instructions in its Dockerfile, and saved the result as an image named todo-app. How that works, and how to write your own, is the subject of Write your first Dockerfile. For now, the image is just something to run.

docker image ls lists the images on your machine. todo-app:latest is the name you gave it, plus a tag, latest, which Docker adds when you don’t choose one.

Step 2: Start a container

Start a container from the image:

Terminal

docker run -d --name todo todo-app
0a62710da0f971131bdcef8ddf7053910d5c92043ad051e39889208c819c395e
docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS      NAMES
0a62710da0f9   todo-app   "docker-entrypoint.s…"   3 seconds ago   Up 3 seconds   3000/tcp   todo
docker logs todo
Using sqlite database at /etc/todos/todo.db
Listening on port 3000

Here’s what each part of that command did:

  • -d (detached) runs the container in the background and gives you your terminal back. Docker prints the new container’s ID and gets out of the way. Without -d, the app’s output streams into your terminal until you press Ctrl+C.
  • --name todo gives the container a name to use in every later command. Without one, Docker makes one up, like gracious_keldysh.
  • todo-app is the image to start from.

docker ps lists running containers, and docker logs shows what the app has printed. The app says it’s listening on port 3000. So try it:

Terminal

curl http://localhost:3000/api/greeting
curl: (7) Failed to connect to localhost port 3000 after 0 ms: Could not connect to server

Nothing answers, and that’s expected. The app is listening on port 3000 inside the container. A container has its own network, separate from your machine’s, so its port 3000 and your machine’s port 3000 are two different things. Look at the PORTS column in docker ps again: 3000/tcp with no arrow means the port is open inside the container and connected to nothing outside it.

Step 3: Publish a port

Prefer to watch? This video explains port publishing. It predates this guide, uses a different sample app, and shows Docker Desktop’s dashboard rather than the CLI.

To reach the app, you publish a port: a rule that forwards traffic from a port on your machine to a port inside the container. You set it with -p when the container is created, and you can’t add it to one that already exists. So remove this container and start a new one with the rule. docker rm -f stops a container and removes it in one go; Step 7 comes back to stopping and removing.

Terminal

docker rm -f todo
todo
docker run -d --name todo -p 3000:3000 todo-app
ad73031989864ec28f7d542f783fa7caa1017027ad52586f046a5ca7ed426752
docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS                                         NAMES
ad7303198986   todo-app   "docker-entrypoint.s…"   4 seconds ago   Up 3 seconds   0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp   todo
curl http://localhost:3000/api/greeting
{"greeting":"Hello world!"}

The app answers. Now open http://localhost in your browser to see the to-do list itself, and add an item or two.

-p 3000:3000 reads as HOST_PORT:CONTAINER_PORT. The left number is the port on your machine; the right number is the port the app listens on inside the container. The PORTS column now shows the rule as 0.0.0.0:3000->3000/tcp: traffic arriving at your machine’s port 3000, on any of its network addresses, goes to the container’s port 3000. The second entry, [::], is the same rule for IPv6.

Run a second copy

Both numbers being 3000 hides which is which. Start a second container from the same image, published on a different host port:

Terminal

docker run -d --name todo-2 -p 8080:3000 todo-app
fc97cc81c851e059fbe0c663b1483f45f0be3da4e212306fc31a0b022f309b96
docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS                                         NAMES
fc97cc81c851   todo-app   "docker-entrypoint.s…"   3 seconds ago   Up 3 seconds   0.0.0.0:8080->3000/tcp, [::]:8080->3000/tcp   todo-2
ad7303198986   todo-app   "docker-entrypoint.s…"   7 seconds ago   Up 6 seconds   0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp   todo
curl http://localhost:8080/api/greeting
{"greeting":"Hello world!"}

Both apps listen on port 3000, and there’s no conflict, because each has its own container port 3000. Only the host ports have to differ: your machine has one port 3000, and todo already holds it. If you’d tried -p 3000:3000 again, Docker would have refused to start the second container because the port is taken.

Two containers from one image, published on two host ports On your machine, a browser or curl sends requests to localhost. Host port 3000 forwards to port 3000 inside the container named todo. Host port 8080 forwards to port 3000 inside the container named todo-2. Both apps listen on 3000 inside their own container; only the host ports differ. YOUR MACHINE Browser / curl localhost host :3000 host :8080 CONTAINER · todo app listening on :3000 CONTAINER · todo-2 app listening on :3000 -p 3000:3000 -p 8080:3000

Check that you can tell the two numbers apart:

Starting from docker run -d --name todo -p 3000:3000 todo-app:

Situation What to change
Another dev server already uses port 3000 on your machine The host port (left): -p 3001:3000
You want a second copy running alongside the first The host port (left): -p 8080:3000
A different app listens on port 5000 and you want it at localhost:5000 Both: -p 5000:5000. The right number must match the port the app listens on.
You want the to-do app to keep its database in a different folder An environment variable (-e SQLITE_DB_LOCATION=…), not a port. Step 6 does this.

Step 4: Each container has its own data

The two containers came from the same image, but they don’t share anything after that. Add a to-do through the first one, then ask each for its list. These are the same API calls the app’s web page makes:

Terminal

curl -X POST -H 'Content-Type: application/json' -d '{"name": "Buy milk"}' http://localhost:3000/api/items
{"id":"ae29f095-693f-468d-b577-a9fe7f6dafca","name":"Buy milk","completed":false}
curl http://localhost:3000/api/items
[{"id":"ae29f095-693f-468d-b577-a9fe7f6dafca","name":"Buy milk","completed":false}]
curl http://localhost:8080/api/items
[]

todo has the item; todo-2 has an empty list. You can see the same thing in your browser by opening http://localhost next to http://localhost.

The image is read-only, so the app can’t write its database into it. Instead, every container gets its own thin writable layer on top of the image, and everything the app writes, including its SQLite database, lands there. Two containers, two writable layers, two separate lists.

Step 5: Look inside a running container

docker exec runs a command inside a container that’s already running. Use it to look around from the app’s side:

Terminal

docker exec todo ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.9  0.3 985472 65568 ?        Ssl  20:19   0:00 node src/index.js
root          17  0.0  0.0   8432  4336 ?        Rs   20:19   0:00 ps aux
docker exec todo ls -l /etc/todos
total 8
-rw-r--r-- 1 root root 8192 Oct 21 20:19 todo.db
docker exec todo hostname
ad7303198986
docker exec todo cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

Each answer says something about what a container is:

  • ps aux shows two processes: the app, as process 1, and the ps you just ran. Nothing else from your machine is visible. A container is a process (or a few) that Docker keeps isolated from everything else.
  • ls -l /etc/todos finds the app’s SQLite database, in the container’s writable layer. It’s where the log line from Step 2 said it would be.
  • hostname is the container’s ID. The container has its own hostname and network, separate from your machine’s.
  • /etc/os-release says Debian, whatever your own machine runs. The container’s files come from the image, not from your computer.

To poke around for longer, open a shell inside the container. -it connects your keyboard and terminal to it, and exit leaves it again. The container keeps running:

$ docker exec -it todo bash
root@ad7303198986:/usr/local/app# ls
node_modules  package-lock.json  package.json  src
root@ad7303198986:/usr/local/app# exit

Step 6: Configure the app with an environment variable

Apps commonly read their settings from environment variables, so the same image can run in different setups without being rebuilt. The to-do app reads SQLITE_DB_LOCATION to decide where to keep its database, and defaults to /etc/todos/todo.db.

Like ports, environment variables are set when a container is created. Replace todo-2 with one that keeps its database somewhere else, using -e:

Terminal

docker rm -f todo-2
todo-2
docker run -d --name todo-2 -p 8080:3000 -e SQLITE_DB_LOCATION=/tmp/todos/todo.db todo-app
671744fd3cc24a876fd98a0d3ae362c1b95330e9ae85cb9a687f3a8cf0604614
docker logs todo-2
Using sqlite database at /tmp/todos/todo.db
Listening on port 3000
docker exec todo-2 printenv SQLITE_DB_LOCATION
/tmp/todos/todo.db
docker exec todo-2 ls /tmp/todos
todo.db
docker exec todo-2 ls /etc/todos
ls: cannot access '/etc/todos': No such file or directory

The log line changed, and the database is where you said. Notice that /etc/todos doesn’t exist at all in this container. It was never in the image: the app in todo created that directory when it started, in that container’s writable layer.

Repeat -e for each variable you need. For more than a few, --env-file reads them from a file; see the docker container run reference.

Step 7: Stop, start, and remove

A container keeps running until you stop it. Stop todo, then list containers again:

Terminal

docker stop todo
todo
docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS                                         NAMES
671744fd3cc2   todo-app   "docker-entrypoint.s…"   3 seconds ago   Up 3 seconds   0.0.0.0:8080->3000/tcp, [::]:8080->3000/tcp   todo-2
docker ps -a
CONTAINER ID   IMAGE      COMMAND                  CREATED          STATUS                              PORTS                                         NAMES
671744fd3cc2   todo-app   "docker-entrypoint.s…"   3 seconds ago    Up 3 seconds                        0.0.0.0:8080->3000/tcp, [::]:8080->3000/tcp   todo-2
ad7303198986   todo-app   "docker-entrypoint.s…"   22 seconds ago   Exited (0) Less than a second ago                                                 todo
docker start todo
todo
curl http://localhost:3000/api/items
[{"id":"ae29f095-693f-468d-b577-a9fe7f6dafca","name":"Buy milk","completed":false}]

docker ps shows only running containers, so todo disappears from it. docker ps -a shows all of them, and todo is still there, Exited. A stopped container isn’t gone: its configuration and its writable layer are kept. docker start runs it again with the same name, port, and settings, and “Buy milk” is still on the list.

Removing a container is different. docker rm deletes the container and its writable layer. Remove todo, start a fresh one with exactly the same command as before, and ask for the list:

Terminal

docker stop todo
todo
docker rm todo
todo
docker run -d --name todo -p 3000:3000 todo-app
1ef0261012a6d415157b818acb9040c3276a9acd8589b5ee2f67bb129770e09e
curl http://localhost:3000/api/items
[]

The list is empty. The new container started from the same unchanged image, with a brand-new writable layer, and the old one’s database went with it.

That’s by design. Containers are meant to be disposable: you replace them to upgrade, reconfigure, or recover, and anything that only lives inside one is lost when you do. Data you want to keep belongs outside the container, which is what Keep data with volumes and bind mounts covers.

Clean up

Remove both containers. docker rm won’t remove one that’s still running:

Terminal

docker rm todo-2
Error response from daemon: cannot remove container "todo-2": container is running: stop the container before removing or force remove
docker rm -f todo todo-2
todo
todo-2
docker ps -a
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

Either stop it first or use -f, which stops and removes in one step. The todo-app image is still on your machine, so the next docker run starts in seconds. When you no longer need it, docker image rm todo-app removes it.

What you did

  • docker run created and started a container from an image, with -d to run it in the background and --name to name it.
  • -p HOST_PORT:CONTAINER_PORT published a port, so traffic to your machine reached the app inside. Two containers can use the same container port, but never the same host port.
  • docker ps, docker ps -a, and docker logs told you what’s running, what’s stopped, and what the app printed.
  • docker exec ran commands inside a running container, where you found one isolated process, its own hostname, and its own filesystem.
  • -e configured the app at startup without changing the image.
  • docker stop and docker start paused and resumed a container with its data intact. docker rm deleted it, and its data with it.

docker run takes many more options, such as limits on memory and CPU, or a different command to run in place of the image’s default. The docker container run reference lists them all.

Next

Your to-dos disappeared with the container. In Keep data with volumes and bind mounts, you’ll keep them across docker rm, and see the database file on your own disk.

Learning paths

Part of a learning path

This works on its own and is also included in the paths below. Open a path to see the complete route.