You have learned what Docker is, you have installed it, you know its architecture, you handle its commands, you understand images and you have created your first containers. All of that is tooling. What is missing is the problem that justifies using it, and this lesson is going to put that problem in front of you with a name, a face and real code. You are going to meet Aurora Libros S.L., a fictional online bookshop that will be the thread running through the six remaining modules; you will see its target architecture, you will read the complete code of its API with Node.js 22, PostgreSQL 16 and Redis 7, and —most importantly— you will try to run it without Docker. You are going to count the manual steps it demands, you are going to see the errors that come up and you are going to measure the real cost of onboarding a new developer. By the time you reach the last section, the course roadmap will no longer look like a syllabus: it will look like a rescue plan.
Contents
- Who Aurora Libros S.L. is and what it needs
- The target architecture
- Repository structure
- The API code:
package.json - The API code:
server.js - The database:
db/init.sql - The static site:
web/index.html - Running the API without Docker: the ordeal
- The onboarding tally
- The course roadmap
- Who Aurora Libros S.L. is and what it needs
Aurora Libros S.L. is an independent bookshop in Valencia that has been selling online for three years. They started with a store built by a freelancer and now they have a small team: two developers, a junior developer who has just joined and one person who handles infrastructure part-time.
Their current situation:
- The website and the API run on a single rented server, configured by hand two years ago. Nobody knows how to rebuild it if it breaks.
- Deployment consists of connecting over SSH, running
git pulland restarting the process by hand. It is done on Tuesday mornings "just in case". - Every developer has their local environment set up differently. One uses PostgreSQL 14 installed with
apt, another uses 16 from Homebrew on her Mac, and the junior has spent three days trying to get his machine ready without succeeding. - There is no test environment: they test locally and cross their fingers.
- A month ago, a system update on the server changed the Node version and the API stopped starting. They were down for four hours.
What they need is concrete and not remotely exotic:
- That any developer can have the whole platform running locally in minutes, not days.
- That the development, test and production environments are the same.
- That deploying is repeatable and reversible, not a manual ritual.
- That they can scale the API during campaigns (Sant Jordi, Christmas) without redoing anything.
- That the configuration is versioned alongside the code, not kept in one person's head.
It is exactly the catalog of problems from lesson 01-01, with real faces attached. And it is exactly what Docker solves.
- The target architecture
By the end of the course, the Aurora Libros platform will have four pieces, each in its own container:
flowchart TB
USER["User<br/>browser"]
subgraph PLAT["Aurora Libros platform"]
WEB["aurora-web<br/>Nginx<br/>static site + reverse proxy<br/>port 80"]
API["aurora-api<br/>Node.js 22 + Express<br/>/health · /books · /books/:id<br/>port 3000"]
CACHE["aurora-cache<br/>Redis 7<br/>catalog cache<br/>port 6379"]
DB[("aurora-db<br/>PostgreSQL 16<br/>books table<br/>port 5432")]
end
USER -->|"HTTP :80"| WEB
WEB -->|"/api/* → proxy"| API
API -->|"cached query"| CACHE
API -->|"SQL"| DB
What each piece does and why it exists:
| Service | Technology | Responsibility | Exposed externally |
|---|---|---|---|
aurora-web |
Nginx | Serves the store's HTML, CSS and images and forwards /api/* calls to the API |
Yes, it is the front door |
aurora-api |
Node.js 22 + Express | Business logic and the catalog's REST API | Not directly; only through aurora-web |
aurora-cache |
Redis 7 | Keeps the catalog's most frequent queries in memory so as not to hit the database | No |
aurora-db |
PostgreSQL 16 | Stores the book catalog persistently | No |
Look at the last column, because it is an important design decision that you can already understand with what you learned in lesson 01-06: only aurora-web will publish ports externally. The database and the cache will be reachable only from inside the containers' internal network. That drastically reduces the attack surface, and it is trivial to achieve with Docker: you simply do not use -p on those services.
The API will have three endpoints:
| Endpoint | Method | What it returns |
|---|---|---|
/health |
GET | The state of the service and its dependencies. Used for health checks |
/books |
GET | The full catalog, cached in Redis |
/books/:id |
GET | A specific book by its identifier |
- Repository structure
Create this structure on your machine, because you will use it throughout the course:
aurora-libros/
├── api/
│ ├── package.json
│ └── server.js
├── db/
│ └── init.sql
└── web/
└── index.htmlThree folders, one per piece that contributes code of its own (the Redis cache needs none). Throughout the course you will keep adding files here: a Dockerfile in module 2, .dockerignore, compose.yaml in module 4, and deployment files in module 6.
- The API code:
package.json
package.jsonCreate ~/aurora-libros/api/package.json:
{
"name": "aurora-api",
"version": "1.0.0",
"description": "Catalog REST API for Aurora Libros S.L.",
"main": "server.js",
"type": "commonjs",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.2",
"pg": "^8.13.1",
"redis": "^4.7.0"
}
}What each block declares:
nameandversion: they identify the package. Version1.0.0will be the first tag of the image you build in module 2.main: the entry file.type: "commonjs": we will userequire()instead ofimport. It is the most compatible form and avoids distractions.engines.node: ">=22.0.0": this field is key to the lesson. It declares that the application needs Node 22 or newer. With Docker, that requirement is met by itself; without Docker, it is each machine's responsibility.scripts.start: how to start the application.dependencies: three libraries.expressis the web framework;pgthe PostgreSQL client;redisthe Redis client. Each one implies an external service that must exist and be reachable.
- The API code:
server.js
server.jsCreate ~/aurora-libros/api/server.js:
const express = require('express');
const { Pool } = require('pg');
const { createClient } = require('redis');
// --- Configuration from environment variables ---
// Fixed values are never written in: each environment (local, test, production)
// supplies its own. The defaults only make development easier.
const PORT = process.env.PORT || 3000;
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = process.env.DB_PORT || 5432;
const DB_USER = process.env.DB_USER || 'aurora';
const DB_PASSWORD = process.env.DB_PASSWORD || 'aurora';
const DB_NAME = process.env.DB_NAME || 'aurora_books';
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = process.env.REDIS_PORT || 6379;
const app = express();
// --- PostgreSQL connection ---
const pool = new Pool({
host: DB_HOST,
port: Number(DB_PORT),
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
max: 10,
connectionTimeoutMillis: 5000,
});
// --- Redis connection ---
const cache = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}` });
cache.on('error', (err) => console.error('[cache] error:', err.message));
const CACHE_TTL = 60; // seconds the catalog stays cached
// --- GET /health : state of the service and its dependencies ---
app.get('/health', async (req, res) => {
const status = { service: 'aurora-api', version: '1.0.0', db: 'ko', cache: 'ko' };
try {
await pool.query('SELECT 1');
status.db = 'ok';
} catch (err) {
status.errorDb = err.message;
}
try {
await cache.ping();
status.cache = 'ok';
} catch (err) {
status.errorCache = err.message;
}
const allOk = status.db === 'ok' && status.cache === 'ok';
res.status(allOk ? 200 : 503).json(status);
});
// --- GET /books : full catalog, with caching ---
app.get('/books', async (req, res) => {
try {
const cached = await cache.get('books:all');
if (cached) {
return res.json({ source: 'cache', books: JSON.parse(cached) });
}
const { rows } = await pool.query(
'SELECT id, title, author, isbn, price FROM books ORDER BY title'
);
await cache.setEx('books:all', CACHE_TTL, JSON.stringify(rows));
res.json({ source: 'db', books: rows });
} catch (err) {
console.error('[/books] error:', err.message);
res.status(500).json({ error: 'Could not fetch the catalog', detail: err.message });
}
});
// --- GET /books/:id : a specific book ---
app.get('/books/:id', async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'The identifier must be a positive integer' });
}
try {
const { rows } = await pool.query(
'SELECT id, title, author, isbn, price FROM books WHERE id = $1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Book not found' });
}
res.json(rows[0]);
} catch (err) {
console.error('[/books/:id] error:', err.message);
res.status(500).json({ error: 'Error querying the book', detail: err.message });
}
});
// --- Startup ---
async function start() {
await cache.connect();
app.listen(PORT, '0.0.0.0', () => {
console.log(`[aurora-api] listening on port ${PORT}`);
console.log(`[aurora-api] database: ${DB_HOST}:${DB_PORT}/${DB_NAME}`);
console.log(`[aurora-api] cache: ${REDIS_HOST}:${REDIS_PORT}`);
});
}
start().catch((err) => {
console.error('[aurora-api] failed to start:', err.message);
process.exit(1);
});Let's go through it piece by piece, because there are decisions here that will matter in later modules.
The configuration block. Every parameter is read from process.env, with a default value. This is fundamental: the application does not know where its database is until somebody tells it. Today, on your machine, DB_HOST will be localhost; in module 4, when everything is in containers, it will be aurora-db, the service's name. The same code, without touching a single line, will serve both scenarios. That is why environment variables are the standard configuration mechanism in containers (lesson 04-05).
The PostgreSQL pool. Pool maintains a set of reusable connections instead of opening one per request. connectionTimeoutMillis: 5000 means that, if the database does not respond, it fails in 5 seconds instead of hanging. That detail will be visible in section 8.
The Redis client. It is built from a URL of the form redis://host:port. The error handler prevents a cache failure from bringing down the entire process.
/health. It returns 200 if the database and cache respond, and 503 otherwise. It is not decorative: in module 3 you will use it for Docker's health checks, and in module 6 it will be what the load balancer queries to decide whether a container can receive traffic.
/books. It implements the cache-aside pattern: first it looks in Redis; if there is a result, it returns it marked as source: "cache"; if not, it queries PostgreSQL, stores the result in Redis with a 60-second lifetime (setEx) and returns it as source: "db". That source field will let you check at a glance whether the cache is working.
/books/:id. It validates the input before querying and uses a parameterized query ($1 with the value passed separately) instead of concatenating strings. It is the correct way to avoid SQL injection.
Startup. app.listen(PORT, '0.0.0.0', ...) listens on all interfaces. It is a detail that becomes critical inside a container: if an application listens only on 127.0.0.1, it will be unreachable from outside the container no matter how much -p you use. Make a note of it, because it is one of the most frequent causes of "I published the port but it doesn't respond".
- The database:
db/init.sql
db/init.sqlCreate ~/aurora-libros/db/init.sql:
-- Schema and initial data for the Aurora Libros S.L. catalog
CREATE TABLE IF NOT EXISTS books (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
author VARCHAR(150) NOT NULL,
isbn VARCHAR(17) NOT NULL UNIQUE,
price NUMERIC(8,2) NOT NULL CHECK (price >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_books_author ON books (author);
INSERT INTO books (title, author, isbn, price) VALUES
('El jardín de senderos que se bifurcan', 'Jorge Luis Borges', '978-84-206-3312-1', 14.50),
('Rayuela', 'Julio Cortázar', '978-84-376-0494-7', 19.90),
('Cien años de soledad', 'Gabriel García Márquez', '978-84-397-2071-7', 17.95),
('La sombra del viento', 'Carlos Ruiz Zafón', '978-84-08-04364-5', 21.00),
('Nada', 'Carmen Laforet', '978-84-233-4361-2', 12.75),
('La casa de los espíritus', 'Isabel Allende', '978-84-9838-618-3', 18.40),
('Los detectives salvajes', 'Roberto Bolaño', '978-84-339-6835-7', 23.60),
('El tiempo entre costuras', 'María Dueñas', '978-84-8365-351-1', 20.15)
ON CONFLICT (isbn) DO NOTHING;Comments on the design:
SERIAL PRIMARY KEYgenerates auto-incrementing identifiers, which are the ones/books/:idwill use.isbn ... UNIQUEprevents duplicates. Combined withON CONFLICT (isbn) DO NOTHINGat the end, it makes the script idempotent: you can run it a thousand times without duplicating books or causing errors. This property will matter in module 4, where PostgreSQL runs initialization scripts automatically.NUMERIC(8,2)for the price, neverFLOAT: with money, floating-point arithmetic produces rounding errors.CHECK (price >= 0)is an integrity constraint at the database level.TIMESTAMPTZstores the timestamp with its time zone, which avoids the class of problems in the table from lesson 01-01.CREATE TABLE IF NOT EXISTSandCREATE INDEX IF NOT EXISTSreinforce idempotency.
Eight books by Spanish-language authors: fictional but plausible data, enough to test the catalog, the cache and the single-book query.
- The static site:
web/index.html
web/index.htmlReuse the page you made in lesson 01-06 and extend it so that it consumes the API. Create or replace ~/aurora-libros/web/index.html:
<!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: 46rem; margin: 3rem auto; padding: 0 1rem; color: #222; }
h1 { color: #6b3fa0; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #ddd; }
.status { padding: .5rem .75rem; border-radius: .25rem; background: #f4f0fa; }
</style>
</head>
<body>
<h1>Aurora Libros</h1>
<p class="status" id="status">Loading catalog…</p>
<table id="table" hidden>
<thead><tr><th>Title</th><th>Author</th><th>ISBN</th><th>Price</th></tr></thead>
<tbody id="body"></tbody>
</table>
<script>
// The site calls /api/books. In module 4, Nginx will forward that path
// to aurora-api through a reverse proxy.
fetch('/api/books')
.then((r) => r.json())
.then((data) => {
document.getElementById('status').textContent =
`${data.books.length} books · data source: ${data.source}`;
document.getElementById('body').innerHTML = data.books
.map((b) => `<tr><td>${b.title}</td><td>${b.author}</td><td>${b.isbn}</td><td>${b.price} €</td></tr>`)
.join('');
document.getElementById('table').hidden = false;
})
.catch((err) => {
document.getElementById('status').textContent =
'Could not contact the API: ' + err.message;
});
</script>
</body>
</html>The interesting point is that the page calls /api/books, a relative path, not http://localhost:3000/books. That is deliberate: in the final architecture, Nginx will receive that request and forward it internally to aurora-api. That way the browser only talks to one origin, CORS problems are avoided and the API does not need to be exposed to the internet. The reverse proxy configuration will be done in module 4.
Right now, if you open this page, the message will be "Could not contact the API". That is expected: nothing has been set up yet.
- Running the API without Docker: the ordeal
Let's do what the Aurora Libros junior developer would do on his first day. Put yourself in his shoes.
Attempt 1: just start it
node:internal/modules/cjs/loader:1215
throw err;
^
Error: Cannot find module 'express'
Require stack:
- /home/junior/aurora-libros/api/server.jsThe dependencies are missing. Makes sense.
Attempt 2: install dependencies
But first you need Node. Let's check which version is there:
Node 18, and the package.json demands 22 or newer. Time to install the right version, and you cannot simply replace the system's because other projects on the machine depend on it. You need a version manager:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
node --versionFour commands, a shell restart and a new tool installed on your machine just to have the right Node version. Now, at last:
Attempt 3: start it again
ECONNREFUSED on port 6379: there is no Redis listening. Startup fails because cache.connect() finds nobody on the other side.
This is the error that gives this section its name, and it is worth understanding well: "connection refused" means the request reached the machine but nobody was listening on that port. It is not a firewall problem or a credentials problem: the service simply does not exist.
Attempt 4: install Redis
One more service installed permanently on your system, starting on every reboot whether it consumes resources usefully or not.
Attempt 5: start it once more
[aurora-api] listening on port 3000
[aurora-api] database: localhost:5432/aurora_books
[aurora-api] cache: localhost:6379It starts! Let's try it:
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ok",
"errorDb":"connect ECONNREFUSED 127.0.0.1:5432"}The cache works, but the database does not: another ECONNREFUSED, this time on 5432. There is no PostgreSQL. And notice that /health returns HTTP 503, exactly as designed.
Attempt 6: install and configure PostgreSQL
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql
psql --versionWith luck, your distribution ships 16. If it ships 14 —like the CI server from lesson 01-01—, you would have to add PostgreSQL's official repository, import its GPG key and force the version, repeating a dance very much like the Docker installation.
Now you have to create the user and the database, because a clean installation knows nothing about Aurora Libros:
sudo -u postgres psql -c "CREATE USER aurora WITH PASSWORD 'aurora';"
sudo -u postgres psql -c "CREATE DATABASE aurora_books OWNER aurora;"And load the schema with the data:
If this fails with Peer authentication failed for user "aurora", you will have to edit /etc/postgresql/16/main/pg_hba.conf, change the authentication method to md5 or scram-sha-256 and restart the service. It is a classic, and it costs anywhere between ten minutes and an entire afternoon depending on how familiar you are with PostgreSQL.
Attempt 7: at last
[aurora-api] listening on port 3000
[aurora-api] database: localhost:5432/aurora_books
[aurora-api] cache: localhost:6379{"source":"db","books":[{"id":5,"title":"Cien años de soledad","author":"Gabriel García Márquez","isbn":"978-84-397-2071-7","price":"17.95"},...Repeat the same command immediately:
source has changed from db to cache: the second request was served from Redis, without touching PostgreSQL. The cache works.
And a specific book:
{"id":2,"title":"El jardín de senderos que se bifurcan","author":"Jorge Luis Borges","isbn":"978-84-206-3312-1","price":"14.50"}The API works. It took seven attempts.
- The onboarding tally
Let's add up what you have just done, which is exactly what every new person on the team has to do:
| # | Manual step | Risk of failure |
|---|---|---|
| 1 | Clone the repository | Low |
| 2 | Discover the Node version is no good | — |
| 3 | Install nvm (a new tool on the system) |
Medium: depends on the shell |
| 4 | nvm install 22 and nvm use 22 |
Low, but you have to remember nvm use in every new terminal |
| 5 | npm install |
Low |
| 6 | Install Redis | Medium: a different package per OS |
| 7 | Enable and start the Redis service | Medium: there is no systemd on macOS |
| 8 | Install PostgreSQL 16 specifically | High: the distribution may ship a different version |
| 9 | Start the PostgreSQL service | Medium |
| 10 | Create the aurora user |
Medium |
| 11 | Create the aurora_books database |
Medium |
| 12 | Adjust pg_hba.conf for authentication |
High: different path and syntax per version and OS |
| 13 | Load init.sql |
Medium |
| 14 | Export the required environment variables | Medium: easy to forget |
| 15 | Start the API | Low |
Fifteen manual steps, two of them high risk and eight medium risk. And that list assumes you are on Linux: on macOS the package managers change (brew instead of apt), so does service management (brew services instead of systemctl) and so do the configuration paths. On Windows, even more so.
The problems that do not go away even if you complete all fifteen steps:
- System pollution. You now have Redis and PostgreSQL starting on every reboot of your machine, whether you use Aurora Libros or not.
- Conflicts between projects. If tomorrow you join another project that needs PostgreSQL 14, you have a serious problem.
- No guarantee of sameness. Your PostgreSQL 16.6 and your colleague's 16.2 are not exactly the same thing, and neither of them is production's.
- Nothing versioned. This whole process lives in a Confluence document that will go out of date in three weeks.
- Slow onboarding. Between 4 hours and 3 days depending on the person's experience and their bad luck.
- Impossible to replicate in CI. The integration server would need the same fifteen operations before every test run.
And now the promise, so you keep the contrast in mind: by the end of module 4, all of this will be reduced to a single command, docker compose up, which anyone will be able to run on Linux, macOS or Windows, getting exactly the same versions of everything, without installing Node, PostgreSQL or Redis on their machine, and leaving no trace behind afterwards.
Before moving on, it is a good idea to leave the system in peace. If you installed the services just for this exercise, you can stop them:
stop stops them now; disable prevents them from starting on every reboot. From module 3 onwards you will not need them: they will live in containers.
- The course roadmap
Here is the plan. Each module adds a concrete piece to Aurora Libros:
| Module | What you learn | What it adds to Aurora Libros |
|---|---|---|
| 1. Introduction (the one you have just finished) | Concepts, installation, architecture, commands, images, first container | The platform introduced and the problem measured: 15 manual steps |
| 2. Images | Docker Hub, Dockerfile, building, tagging, publishing | The aurora-api Dockerfile and its aurora-api:1.0.0 image published to a registry |
| 3. Containers | Running, lifecycle, inspection, networks, volumes, limits | The four services running as containers, on a network of their own, with a volume that saves the aurora-db catalog |
| 4. Docker Compose | Declarative services, environment variables, profiles, local development | The compose.yaml file with the complete stack: docker compose up and everything works |
| 5. Advanced | Networking in depth, storage, security, optimization, BuildKit, monitoring, runtime | Smaller and safer images (non-root user, multi-stage), centralized logs, well-managed secrets |
| 6. Production | Production images, CI/CD, Swarm, Kubernetes, scaling, deployments | Aurora Libros actually deployed, with an automated pipeline, replicas, load balancing and rollback |
| 7. Ecosystem | Provisioning, Compose versus Kubernetes, Docker Desktop, tooling, alternatives, the future | The context to decide with judgment what to use in each case |
Notice the progression: each module solves a real problem the previous one leaves open. Module 2 packages the API, but it will still need a database. Module 3 gets the four pieces running, but starting them by hand one by one will be tedious. Module 4 automates it, but the images will be improvable and not very secure. Module 5 hardens them, but they will still live only on your laptop. Module 6 takes them to production.
Take good care of the ~/aurora-libros folder: it is your project for the rest of the course.
Common Mistakes and Tips
ECONNREFUSEDdoes not mean "wrong credentials". It means nobody is listening on that port. If you seeECONNREFUSED, check first that the service exists and is running, not the password. And in the coming modules, when this happens between containers, the cause is usually a misspelled service name or a misconfigured network.- Hard-coding the configuration. If
server.jshadhost: 'localhost'baked in, the same application could not work locally and in containers. Environment variables are what allow the same artifact to serve every environment. - Listening only on
127.0.0.1. Inside a container, an application that doesapp.listen(PORT, '127.0.0.1')is unreachable from outside even if you publish the port. Use0.0.0.0, asserver.jsdoes. - Forgetting
nvm usein every terminal. A classic of container-free development: terminal A has Node 22 and terminal B has Node 18, and the resulting error is baffling. With Docker, the Node version travels inside the image and does not depend on the terminal. - Non-idempotent SQL scripts. If
init.sqldid not haveIF NOT EXISTSandON CONFLICT DO NOTHING, running it twice would produce errors or duplicate books. In module 4, PostgreSQL will run that script automatically; being idempotent avoids surprises. - Tip: keep the 15-step tally. When in module 4 you type
docker compose upand everything works, come back to this table. It is the best way to measure what you have gained. - Tip: do not delete
~/aurora-libros. That directory is the project for the entire course.
Exercises
Exercise 1: set up the project and document the pain
Create the complete aurora-libros/ structure with the four files (api/package.json, api/server.js, db/init.sql, web/index.html) and try to start the API without Docker on your system. Take notes in an ONBOARDING-NOTES.md file:
- Every command you had to run.
- Every error that came up, with its literal message.
- The total time spent.
- What software is left installed on your machine when you are done.
It does not matter if you cannot complete it: what matters is the record of the process.
Exercise 2: interpret the diagnostics
For each of these messages, state which piece is missing or wrong, and exactly what you would check:
a) Error: Cannot find module 'pg'
b) [aurora-api] failed to start: connect ECONNREFUSED 127.0.0.1:6379
c) {"service":"aurora-api","db":"ko","cache":"ok","errorDb":"database \"aurora_books\" does not exist"}
d) {"service":"aurora-api","db":"ko","cache":"ok","errorDb":"password authentication failed for user \"aurora\""}
e) Error: listen EADDRINUSE: address already in use 0.0.0.0:3000Exercise 3: prepare the ground for module 2
Without writing any Dockerfile yet (that is module 2), answer with your reasoning:
- Which base image would you choose for
aurora-apiand why? Check its size withdocker pullanddocker image ls. - Which official images would you use for
aurora-dbandaurora-cache? Pull them and note each one's size. - Of the files in
~/aurora-libros/api/, which should go into the API's image and which should not? Justify it. - Should the
DB_PASSWORDvariable and its like go inside the image? Why?
Solutions
Solution to exercise 1
There is no single solution, but your ONBOARDING-NOTES.md should look something like this:
# Aurora Libros onboarding without Docker — 4 August 2026
## Commands run
1. mkdir -p ~/aurora-libros/{api,db,web}
2. node --version → v18.19.1 (not enough, >=22 required)
3. curl ... nvm/install.sh | bash ; source ~/.bashrc
4. nvm install 22 && nvm use 22 → v22.14.0
5. cd api && npm install → 112 packages
6. node server.js → ECONNREFUSED :6379
7. sudo apt install -y redis-server && sudo systemctl enable --now redis-server
8. node server.js → starts; /health returns db:"ko"
9. sudo apt install -y postgresql
10. sudo -u postgres psql -c "CREATE USER aurora WITH PASSWORD 'aurora';"
11. sudo -u postgres psql -c "CREATE DATABASE aurora_books OWNER aurora;"
12. Edit /etc/postgresql/16/main/pg_hba.conf (peer → scram-sha-256) + restart
13. PGPASSWORD=aurora psql -h localhost -U aurora -d aurora_books -f ../db/init.sql
14. node server.js → OK
## Errors encountered
- Cannot find module 'express'
- connect ECONNREFUSED 127.0.0.1:6379
- connect ECONNREFUSED 127.0.0.1:5432
- Peer authentication failed for user "aurora"
## Total time
1 h 35 min (and I already knew PostgreSQL)
## Software left permanently installed
- nvm + Node 22 (on top of the system's Node 18)
- redis-server (service active at boot)
- postgresql-16 (service active at boot) + modified pg_hba.conf fileThe important reflection: that time and those leftovers are multiplied by every person on the team and by every machine, and none of it guarantees that everyone ends up with exactly the same versions.
Solution to exercise 2
(a) Cannot find module 'pg'. An npm dependency is missing, not a service. It is an error at process startup, before any connection. You would check that node_modules/ exists and that npm install was run in the right directory (api/, where the package.json is). Typical cause: having launched node server.js from the project root or from another folder.
(b) ECONNREFUSED 127.0.0.1:6379. Redis is missing: nobody is listening on port 6379. You would check whether the service exists and is running (systemctl status redis-server), whether it responds (redis-cli ping → PONG) and whether anything is listening on that port (ss -tln | grep 6379). Mind the distinction: refused means "there is nobody"; if the message were a timeout, it would point to a firewall or an unreachable machine.
(c) database "aurora_books" does not exist. PostgreSQL is running and does accept the connection (notice the error is no longer about the network, but from the server), but the database has not been created. The CREATE DATABASE aurora_books OWNER aurora; step is missing. You would check the existing databases with sudo -u postgres psql -c "\l".
(d) password authentication failed for user "aurora". The server responds and the database exists, but the credentials do not match. You would check: that the user exists (\du in psql), that the password matches DB_PASSWORD, and that pg_hba.conf uses a compatible method (scram-sha-256 instead of peer, which only works over the local socket). It is the slowest of the five to diagnose.
(e) EADDRINUSE: address already in use 0.0.0.0:3000. Nothing is missing: something is left over. There is already a process listening on port 3000, almost always another instance of the API itself that you left running in another terminal. You would find it with ss -tlnp | grep 3000 or lsof -i :3000, and stop it, or start this instance on another port with PORT=3001 node server.js. This is the "without Docker" version of the port is already allocated you saw in lesson 01-06.
Solution to exercise 3
- Base image for
aurora-api:node:22-alpine. Reasons: it satisfies theengines: node >=22.0.0inpackage.json, it is official (thelibrary/namespace), and the Alpine variant weighs about 140 MB against the ~1.1 GB of the fullnode:22. Check it:
docker pull node:22-alpine
docker pull node:22
docker image ls node --format "table {{.Tag}}\t{{.Size}}"In a pipeline that builds and downloads the image several times a day, that difference of almost a gigabyte translates into minutes of waiting and transfer costs. The trade-off is musl instead of glibc (lesson 01-05), which is no problem here because the three dependencies are pure JavaScript or ship Alpine-compatible binaries.
postgres:16-alpineforaurora-dbandredis:7-alpineforaurora-cache. Both are official and pin the major version, exactly as the project's brief requires.
docker pull postgres:16-alpine
docker pull redis:7-alpine
docker image ls --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"Note that we use latest in none of them: we know exactly which major version each service will have, honoring what you learned in lesson 01-05.
- What goes into the image and what does not:
| File or folder | Does it go in? | Reason |
|---|---|---|
package.json |
Yes | It defines the dependencies to install |
server.js |
Yes | It is the application |
package-lock.json |
Yes | It pins the exact versions; it is what makes npm ci reproducible |
node_modules/ |
No | It is installed inside the image. Copying yours from your machine can drag in binaries compiled for a different operating system or architecture |
.git/ |
No | Heavy history, irrelevant at runtime; besides, it may contain sensitive information |
ONBOARDING-NOTES.md, .env |
No | Local documentation and, above all, secrets, which must never travel inside an image |
The mechanism for excluding them is called .dockerignore and you will see it in module 2.
-
DB_PASSWORDmust not go inside the image. Ever. Three cumulative reasons:- Security: an image's layers can be inspected by anyone who has it (
docker image historyshows you the instructions). A password put in there is a published password, and deleting it in a later layer does not remove it from the lower one (lesson 01-05). - Portability: the development password and the production one are different. If it went inside, you would need a different image per environment, breaking the "build once, deploy everywhere" promise.
- Rotation: changing a password would force you to rebuild and redeploy the image.
That is why
server.jsreads all of its configuration fromprocess.env. The values are injected when the container is run, not when it is built: with-eor--env-file(module 3), with variables incompose.yaml(lesson 04-05), or with specific secret mechanisms (lesson 05-03). - Security: an image's layers can be inspected by anyone who has it (
Conclusion
Aurora Libros is no longer an abstract idea: it has a repository, an API with three endpoints, a catalog of eight books in PostgreSQL, a cache in Redis and a static site waiting for someone to wire it up. It also has a perfectly quantified problem. You have run the platform without Docker and you needed fifteen manual steps, two package managers, three services permanently installed on your system, a new tool to manage Node versions and a hand edit of pg_hba.conf. Along the way you have seen the ECONNREFUSED errors that are characteristic of "that service does not exist", you have discovered that the cache works by watching the source field, and you have ended up with a machine dirtier than when you started and with no guarantee whatsoever that your environment resembles your colleagues'.
You have also seen why the code is written the way it is: all the configuration is read from environment variables so the same server.js works on your laptop and inside a container; the API listens on 0.0.0.0 so as to be reachable from outside its network; init.sql is idempotent so it can run on every startup; and /health exists because in module 6 it will be what the load balancer queries before sending traffic to a replica. Every one of those decisions will make full sense in the coming modules.
With this you close module 1. You know what Docker is, you have it installed and verified, you understand its client-server architecture with dockerd, containerd and runc, you handle its CLI's grammar, you understand images from the inside —layers, copy-on-write, digests and tags— and you have created, published on a port, inspected and destroyed your first containers. You have the tools and you have the problem. In module 2, Working with Docker Images, you will start bringing the two together: you will get to know Docker Hub in depth and you will write your first Dockerfile to package aurora-api into an image of your own, buildable with one command and runnable on any machine in the world without installing Node on it. The first of the fifteen steps will disappear; the rest will fall one by one in the modules that follow.
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
