Run your first container
- Beginner
- 20 min
- Published
- Verified
What you'll learn
- Start a container in the background with
docker run -dand 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, anddocker logs - Run commands inside a running container with
docker exec - Configure an app at startup with
-eenvironment variables - Stop, restart, and remove containers, and explain why removing one loses its data
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 todogives the container a name to use in every later command. Without one, Docker makes one up, likegracious_keldysh.todo-appis 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.
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 auxshows two processes: the app, as process 1, and thepsyou 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/todosfinds the app’s SQLite database, in the container’s writable layer. It’s where the log line from Step 2 said it would be.hostnameis the container’s ID. The container has its own hostname and network, separate from your machine’s./etc/os-releasesays 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# exitStep 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 runcreated and started a container from an image, with-dto run it in the background and--nameto name it.-p HOST_PORT:CONTAINER_PORTpublished 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, anddocker logstold you what’s running, what’s stopped, and what the app printed.docker execran commands inside a running container, where you found one isolated process, its own hostname, and its own filesystem.-econfigured the app at startup without changing the image.docker stopanddocker startpaused and resumed a container with its data intact.docker rmdeleted 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.
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.
Learning path
Get started with Docker containers
Step 2 of 4
- Step 1: What is a container?
- Step 2: Run your first container (this page)
- Step 3: Keep data with volumes and bind mounts
- Step 4: Run a multi-container app with Compose
Table of contents
