The moment for serious practice has arrived. In this lesson you are not going to read theory: you are going to type, make mistakes and see results in your browser. You will start with the simplest possible container —one that prints something and dies— to understand the shortest lifecycle there is. Then you will bring up a real web server in the background, publish its port and visit it from the browser, learning along the way what -p 8080:80 means exactly, which is probably the option you will type most often in your career. You will walk through that container's full cycle: seeing it, reading its logs, stopping it, starting it and deleting it. Then you will go inside a container with an interactive shell to poke at its isolated filesystem with your own hands. And you will finish by serving the first Aurora Libros web page from a container, mounting a file from your machine inside it.
Contents
- Before you start: checks
- Your first container: in the foreground
- What happens when the process ends
- A service container in the background
- Port mapping explained
- The full cycle: logs, stop, start and rm
- An interactive container: going inside
- Serving the Aurora Libros page with
-v - Final cleanup
- Before you start: checks
Make sure everything is in order:
The first must show the Client and Server blocks. The second, a table (probably empty) with no errors. If anything fails, go back to lesson 01-02.
Check as well that the port we are going to use is free:
ss -tln lists the listening TCP ports (-t TCP, -l listening, -n without resolving names). If there are no matches, grep fails and the || runs the echo. If something were listening on 8080, use 8081 in every command in this lesson.
- Your first container: in the foreground
Let's start with the bare minimum:
It looks trivial, but a lot has happened. Breaking down the line:
| Part | Meaning |
|---|---|
docker run |
Creates a new container and starts it |
alpine:3.20 |
The image to create it from |
echo "Hello from Aurora Libros" |
The command to run inside the container, replacing the image's default command |
And the internal sequence, which you already know from lesson 01-03: the client called the daemon, the daemon checked that it had alpine:3.20 locally, created a container by stacking a writable layer on the image's layers, asked containerd to start the echo process, that process wrote to its standard output, and that output was streamed through the socket to your terminal.
Since you did not use -d, the container runs in the foreground: your terminal stays connected to its output and does not get control back until the process ends.
Let's try something a little longer:
docker run alpine:3.20 sh -c "echo 'Catalog:'; echo '- Rayuela'; echo '- El jardín de senderos que se bifurcan'"Here the command is sh -c "...": a shell is launched inside the container and handed a string with several orders. It is the usual pattern when you need to chain commands, because docker run only accepts an executable with its arguments, not a shell line with ; or &&.
And something that takes a while, so you can see the foreground for real:
The lines come out one at a time, with a one-second pause. Your terminal is blocked in the meantime: that is the foreground. If you press Ctrl+C while it runs, you send an interrupt signal to the process and the container ends early.
- What happens when the process ends
This is the most important rule of the whole lesson:
A container lives exactly as long as its main process lives. When that process ends, the container moves to the Exited state. There is nothing else to "power down".
See for yourself:
Empty: none of the three previous containers is still alive.
NAMES COMMAND STATUS
happy_bardeen "sh -c 'for i in 1 2…" Exited (0) 10 seconds ago
festive_curie "sh -c 'echo Catalog…" Exited (0) 1 minute ago
vibrant_torvalds "echo 'Hello from Au…" Exited (0) 2 minutes agoAll three are there, stopped, with exit code 0 (they finished cleanly). Each takes up a little disk.
This behavior explains a classic beginner's stumble:
The second command shows nothing, and the typical reaction is "the container won't start". It did start: the alpine image's default command is /bin/sh, a shell that, having neither an interactive terminal nor any input, finds nothing to do and exits immediately. The container did its job and died in milliseconds.
For a container to stay alive it needs a process that does not end: a web server waiting for requests, a database, or a shell with an interactive terminal attached. That is what we will see now.
- A service container in the background
Let's bring up an Nginx web server, which is exactly that: a process that stays listening indefinitely.
Unable to find image 'nginx:alpine' locally
alpine: Pulling from library/nginx
f18232174bc9: Already exists
2c8d4f7e1a09: Pull complete
...
Status: Downloaded newer image for nginx:alpine
7c3e9a1f5b2d84e0a6c7d9f3b1e5a7c9d2f4b6e8a0c2d4f6b8e0a2c4d6f8b0e2Let's analyze it option by option, because each one solves a specific problem:
| Option | Long name | What it does |
|---|---|---|
-d |
--detach |
Runs the container in the background and hands your terminal back immediately, printing its ID |
--name aurora-web-demo |
Gives it a readable name instead of a random one. You will be able to refer to it by that name in every command | |
-p 8080:80 |
--publish |
Publishes the container's port 80 on your machine's port 8080 |
nginx:alpine |
The image. We give no command, so the image's own is used: start Nginx |
That long hexadecimal string on the last line is the container's full ID. Since you used --name, you will not need it.
Now, then:
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
7c3e9a1f5b2d nginx:alpine "/docker-entrypoint.…" Up 8 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp aurora-web-demoUp 8 seconds: it is alive, and it will stay alive because Nginx never ends on its own. Note that docker ps without -a already shows it, unlike the previous containers.
- Port mapping explained
The PORTS column deserves a section of its own because it is where most people get stuck.
A container has its own network stack: its own interface, its own internal IP address and its own ports. Nginx listens on port 80 of that internal network, which is not directly reachable from your machine. The -p option builds a bridge.
flowchart LR
NAV["Your browser<br/>http://localhost:8080"]
subgraph HOST["Your machine (host)"]
P8080["Port 8080<br/>(reserved by Docker)"]
subgraph NET["Docker bridge network · 172.17.0.0/16"]
subgraph CNT["Container aurora-web-demo · 172.17.0.2"]
P80["Port 80<br/>Nginx listening"]
end
end
end
NAV --> P8080
P8080 -->|"-p 8080:80<br/>forwarding"| P80
The syntax always reads from the outside in:
- The first is your machine's: the one you type in the browser. You choose it and it must be free.
- The second is the container's: the one the application listens on. You do not choose it, the application determines it (Nginx uses 80, PostgreSQL 5432, Redis 6379, and the Aurora Libros API will use 3000).
Useful variants:
| Syntax | Effect |
|---|---|
-p 8080:80 |
Port 8080 on all the host's interfaces → 80 in the container |
-p 127.0.0.1:8080:80 |
Reachable only from your own machine, not from the local network. More secure |
-p 80:80 |
The host's port 80 (on Linux it may require privileges) |
-p 8080:80 -p 8443:443 |
Several ports published at once |
-P |
Publishes every port declared by the image on random high ports |
Without -p, the container works perfectly well but is unreachable from outside. That is the right thing for internal services: in module 4, aurora-db and aurora-cache will not publish ports, because they should only be reachable from aurora-api, not from the internet.
Checking with curl and with the browser
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
...
</html>Headers only, to see the HTTP status:
-I asks for headers only (HEAD method). The 200 OK confirms the server responds.
Now open http://localhost:8080 in your browser: you will see Nginx's welcome page. You have just served a website from a container without having installed Nginx on your system. If you run nginx -v in your terminal, the command most likely does not exist: everything is inside the container.
- The full cycle: logs, stop, start and rm
Viewing the logs
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/08/04 10:15:03 [notice] 1#1: nginx/1.27.4
2026/08/04 10:15:03 [notice] 1#1: start worker processes
172.17.0.1 - - [04/Aug/2026:10:16:12 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0"
172.17.0.1 - - [04/Aug/2026:10:16:45 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0 ..."There are your two visits: the one from curl and the one from the browser, identified by their user agent. The IP 172.17.0.1 is your machine's as seen from Docker's internal network.
Very useful options:
docker logs -f aurora-web-demo # follow live (Ctrl+C to exit)
docker logs --tail 20 aurora-web-demo # only the last 20 lines
docker logs -t aurora-web-demo # with Docker's timestamp
docker logs --since 5m aurora-web-demo # only the last 5 minutesTry docker logs -f aurora-web-demo, reload the browser and you will see the line appear in real time. Ctrl+C stops the follow without affecting the container (a very common doubt: no, you are not stopping it).
Stopping the container
It returns the name of what it stopped. Verify:
Two things: docker ps no longer shows it, and in docker ps -a the PORTS column is empty (the port 8080 mapping has been released). Confirm it:
Nobody is listening. The container still exists, but it is not running.
Starting it again
It works again, with the same configuration. And here there is an important conceptual detail: you did not have to repeat -p 8080:80. The configuration was fixed when the container was created and is part of it. docker start merely resumes it.
Corollary: you cannot change the port mapping of an existing container. If you need a different port, you have to delete it and create a new one. It is a manifestation of the disposable-container philosophy.
You will see the logs from the first startup and those from the second, one after the other: logs are preserved for as long as the container exists.
Deleting it
If you try docker rm without stopping it first:
Error response from daemon: cannot remove container "aurora-web-demo": container is running:
stop the container before removing or force removeYou can force it with docker rm -f aurora-web-demo, which stops and deletes in one step. It is convenient in development, but it kills the process without giving it time to shut down cleanly.
Verify:
The container has disappeared from the list; the nginx:alpine image is still there. Once again: deleting a container does not delete its image.
- An interactive container: going inside
Up to now you have been running one-off commands. Now you are going to open a shell inside a container and move around in it.
Three new options:
| Option | Long name | What it does |
|---|---|---|
-i |
--interactive |
Keeps standard input open, so you can type |
-t |
--tty |
Allocates a virtual terminal: prompt, colors, formatting |
--rm |
Deletes the container automatically when you exit |
-i and -t almost always go together (-it): with -i and no -t you could type but with no prompt or formatting; with -t and no -i you would see the prompt but could not type.
Your prompt changes:
You are inside the container. Explore:
The hostname is the container's short ID, not your machine's.
A complete Linux filesystem... that is not yours. Check it:
Alpine, even if your machine is Ubuntu, Windows or macOS.
Empty: your documents are not here. Filesystem isolation is real.
This is revealing: there are only two processes, and the shell has PID 1. On your machine there are hundreds of processes and PID 1 is systemd. The container has its own process namespace and does not see the host's (the exact mechanism, in lesson 05-07).
And yet:
The kernel is your host's. Alpine user space, Ubuntu kernel. That sentence sums up what a container is.
Installing something inside
Alpine uses apk as its package manager:
Not installed. Let's install it:
fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main/x86_64/APKINDEX.tar.gz
(1/8) Installing ca-certificates (20240705-r0)
...
(8/8) Installing curl (8.12.1-r0)
Executing busybox-1.36.1-r29.trigger
OK: 13 MiB in 23 packagesapk addinstalls packages (the equivalent ofapt install).--no-cacheavoids storing the package index on disk; it is the standard option in containers because it reduces size.
It works. And something important: you have installed curl in the container, not on your machine. Your system has not been touched.
Create a file as well, for the next check:
Now leave:
You are back at your normal prompt.
The magic of --rm
It does not show up. Thanks to --rm, the container was deleted automatically when its process ended. With it went its writable layer, the curl you installed and the /test.txt file.
Confirm it by opening another one:
This is lesson 01-05 in the flesh: every container is born clean from the immutable image. Changes live in its writable layer and die with it.
--rm is an excellent option for tests and one-off commands: it saves you from piling up stopped containers. Do not use it on services you want to be able to resume.
- Serving the Aurora Libros page with
-v
-vNginx serving its default page is fine for testing, but we want our content. Let's create the first Aurora Libros page.
Creating the page
Create index.html with this content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aurora Libros · Online bookshop</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; color: #222; }
h1 { color: #6b3fa0; }
li { margin-bottom: .6rem; }
footer { margin-top: 2rem; font-size: .85rem; color: #666; }
</style>
</head>
<body>
<h1>Aurora Libros</h1>
<p>Your online bookshop. Featured catalog:</p>
<ul>
<li><strong>El jardín de senderos que se bifurcan</strong> — Jorge Luis Borges — €14.50</li>
<li><strong>Rayuela</strong> — Julio Cortázar — €19.90</li>
<li><strong>Cien años de soledad</strong> — Gabriel García Márquez — €17.95</li>
</ul>
<footer>Aurora Libros S.L. · Served from a Docker container</footer>
</body>
</html>Mounting it in the container
docker run -d --name aurora-web-demo -p 8080:80 \
-v ~/aurora-libros/web/index.html:/usr/share/nginx/html/index.html:ro \
nginx:alpineThe new option is -v (--volume), and its syntax reads just like -p, from the outside in:
| Part | Meaning |
|---|---|
~/aurora-libros/web/index.html |
The file on your machine. It must be an absolute path (~ is expanded by the shell) |
/usr/share/nginx/html/index.html |
Where it will appear inside the container. That path is where the Nginx image looks for its files |
:ro |
read-only: the container can read it but not modify it. Good practice when it should not write |
Check it:
And in the browser, at http://localhost:8080, you will see the Aurora Libros page with its catalog. The file is still on your disk; the container simply sees it mounted in its filesystem.
The advantage: live editing
Edit index.html in your editor and add a book:
Save and reload the browser. The change shows up instantly, without rebuilding anything or restarting the container. The mount is a live window between your disk and the container.
This pattern —mounting the source code inside the container to develop without rebuilding— is the basis of the local development workflow you will see in lesson 04-07.
Important note. This use of
-vis purely introductory: it is a bind mount, which mounts a path from your machine. There is another kind, Docker-managed volumes, which is whataurora-dbwill need so as not to lose the catalog. The differences, when to use each and the permission and performance implications are the full content of lesson 03-06.
- Final cleanup
Let's leave the system as we found it:
- The first two stop and delete the Nginx container.
container prune -fremoves any other stopped container you may have left along the way.
Check:
No containers; the images (alpine:3.20, nginx:alpine, hello-world) untouched, ready to be reused without downloading them again. Your ~/aurora-libros/web/index.html file is still in place too: you will need it in the coming lessons.
Common Mistakes and Tips
Bind for 0.0.0.0:8080 failed: port is already allocated. The host port is already taken, almost always by another of your containers. Find it withdocker psand stop it, or publish on a different port (-p 8081:80).- Reversing the order of
-p.-p 80:8080with Nginx does not work: you would be forwarding the host's port 80 to the container's 8080, where nobody is listening. Remember: host:container, from the outside in. docker run -dwith a command that finishes.docker run -d alpine echo hellostarts and dies instantly.-dkeeps nothing alive: it only detaches your terminal. To stay alive you need a process that does not end.- Relative paths in
-v.-v ./web:/usr/share/nginx/htmlmay fail depending on the version and the shell. Always use absolute paths:$(pwd)/webor~/.... - Mounting a directory over one that already has content. The mount hides whatever was at that path inside the image. If you mount an empty directory over
/usr/share/nginx/html, Nginx will return 403 or 404 because it no longer sees itsindex.html. - Confusing
Ctrl+Cwith stopping the container. Indocker logs -fordocker attach,Ctrl+Conly cuts your connection. In a foreground container, it does interrupt the process and end it. - Tip: use
--rmfor anything disposable. Tests, one-off commands, exploratory shells. It will save you cleanups. - Tip: always name with
--name.aurora-web-demois infinitely more manageable thanstupefied_swanson, and it makes name filters work. - Tip: if a container dies right after starting,
docker logsfirst. Logs are preserved even when it is stopped, and the explanation is almost always there.
Exercises
Exercise 1: two storefronts at once
Bring up two Nginx containers simultaneously from the same image: one called aurora-web-es on port 8080 and another aurora-web-en on 8081, each serving a different index.html (one in Spanish and one in English). Check with curl that each port returns its own page. Then answer: how many copies of the nginx:alpine image are on your disk? How much does each container's writable layer take up (docker ps -as)? When you are done, delete both in a single command.
Exercise 2: the log detective
Run this container, which is deliberately misconfigured:
docker run -d --name aurora-fail -p 8080:80 \
-v /tmp/unused-folder:/usr/share/nginx/html:ro \
nginx:alpine(Create the empty /tmp/unused-folder beforehand with mkdir -p /tmp/unused-folder.)
Visit http://localhost:8080 and observe the error. Then: (a) is the container running?, (b) which HTTP code does it return and why?, (c) use docker logs to find Nginx's exact error message, and (d) explain in terms of mounts what has happened and how you would fix it.
Exercise 3: comparative interactive exploration
Open an interactive alpine:3.20 container and an nginx:alpine one (in two terminals, both with --rm). In each, find out: (a) the user space's operating system, (b) the kernel version, (c) PID 1 and how many processes there are, and (d) whether the /usr/share/nginx/html directory exists and what it contains. Explain the differences and the similarities between the two containers, and what each one tells you about the nature of containers.
Solutions
Solution to exercise 1
mkdir -p ~/aurora-libros/web-es ~/aurora-libros/web-en
echo '<h1>Aurora Libros</h1><p>Bienvenido a nuestra librería.</p>' > ~/aurora-libros/web-es/index.html
echo '<h1>Aurora Books</h1><p>Welcome to our bookstore.</p>' > ~/aurora-libros/web-en/index.html
docker run -d --name aurora-web-es -p 8080:80 \
-v ~/aurora-libros/web-es:/usr/share/nginx/html:ro nginx:alpine
docker run -d --name aurora-web-en -p 8081:80 \
-v ~/aurora-libros/web-en:/usr/share/nginx/html:ro nginx:alpine
curl -s http://localhost:8080
curl -s http://localhost:8081<h1>Aurora Libros</h1><p>Bienvenido a nuestra librería.</p>
<h1>Aurora Books</h1><p>Welcome to our bookstore.</p>Here directories are mounted instead of individual files, which is the usual approach with Nginx. The two containers use different ports on the host (8080 and 8081) but both use 80 inside: there is no conflict, because each container has its own network stack and its own port 80.
Answers: there is only one copy of the image on disk, shared by both containers. Each writable layer takes up about 1 kB. The "virtual 52.5MB" includes the image's shared layers, which are not counted twice. Bringing up the second container cost practically zero disk: it is the density from lesson 01-01, measured.
rm -f stops and deletes in one step, and accepts several names.
Solution to exercise 2
(a) Yes, the container is running:
CONTAINER ID IMAGE STATUS PORTS NAMES
b2c8f4a1e7d9 nginx:alpine Up 20 seconds 0.0.0.0:8080->80/tcp aurora-failNginx started correctly. The problem is not the startup, but the content it serves.
(b) It returns 403 Forbidden:
It is 403 and not 404 because the root directory exists (you mounted it) but is empty: there is no index.html to serve and Nginx does not allow directory listing by default.
(c) The logs:
2026/08/04 11:02:41 [error] 30#30: *1 directory index of "/usr/share/nginx/html/" is forbidden,
client: 172.17.0.1, server: localhost, request: "GET / HTTP/1.1", host: "localhost:8080"The message is explicit: directory index ... is forbidden.
(d) What happened: the mount hides the original content at that path in the image. The nginx:alpine image ships its own index.html in /usr/share/nginx/html, but by mounting an empty directory from your machine on top, that content stops being visible (it is not deleted: it is still in the image's layer, simply covered up). Nginx finds an empty directory and answers 403.
Possible fixes: put an index.html in /tmp/unused-folder, mount a directory that does have content, or mount nothing at all if you wanted the default page.
echo '<h1>Aurora Libros</h1>' > /tmp/unused-folder/index.html
curl -s http://localhost:8080
docker rm -f aurora-failNote that you do not need to restart the container: the mount is live, so as soon as the file exists on your disk, Nginx serves it.
Solution to exercise 3
(The Nginx image also ships a shell, so you can go into it the same way.)
In each one:
Results and how to read them:
| Check | alpine:3.20 |
nginx:alpine |
What it means |
|---|---|---|---|
| (a) User space OS | Alpine Linux v3.20 | Alpine Linux (the version of Nginx's base image) | Both are built on Alpine: they share base layers, as you saw in 01-05 |
| (b) Kernel | Your host's (e.g. 6.8.0-52-generic) |
The same | Confirms that both containers share the host's kernel. There is no guest OS |
| (c) PID 1 and process count | sh as PID 1, 2 processes |
sh as PID 1, 2 processes |
Each container has its own process namespace, isolated. And note: Nginx is not running here, because by passing sh you replaced the image's default command |
(d) /usr/share/nginx/html |
Does not exist (ls: ... No such file or directory) |
Exists, with index.html and 50x.html |
The filesystem comes from the image: they differ because the images ship different content |
The important conclusions: containers share the kernel (b) but have isolated filesystems and process namespaces (c, d); two images can share their base and differ only in the upper layers (a); and passing a command to docker run replaces the image's default command, which is why going into the Nginx image with sh gives you Nginx's filesystem without Nginx running.
Conclusion
You have now created, managed and destroyed containers with your own hands. You take away the rule that governs everything else: a container lives as long as its main process lives; if that process ends, the container moves to Exited, and that is why docker run alpine "does nothing" while docker run nginx:alpine stays alive indefinitely.
You know how to launch containers in the foreground and in the background with -d, how to name them with --name, and above all how to publish ports with -p host:container, always reading the syntax from the outside in; you have confirmed that the mapping is fixed when the container is created and that docker start resumes it with the same configuration. You have walked the full cycle —logs, stop, start, rm—, you have gone inside a container with -it to see with your own eyes the filesystem and process isolation alongside the shared kernel, and you have confirmed with --rm that everything you write inside dies with the container. And you have served the first Aurora Libros page by mounting a file from your disk with -v, live editing included.
You have the three pieces of the puzzle —images, containers and commands— and a working web page. What you do not yet have is the real problem that justifies all of this. In the module's final lesson, The Course Project: The Aurora Libros Platform, you will get to know the company, its target architecture and its API's complete code, and you will try to start it without Docker so as to experience first-hand the ordeal that the rest of the course is going to solve.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
