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

  1. Who Aurora Libros S.L. is and what it needs
  2. The target architecture
  3. Repository structure
  4. The API code: package.json
  5. The API code: server.js
  6. The database: db/init.sql
  7. The static site: web/index.html
  8. Running the API without Docker: the ordeal
  9. The onboarding tally
  10. The course roadmap

  1. 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 pull and 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:

  1. That any developer can have the whole platform running locally in minutes, not days.
  2. That the development, test and production environments are the same.
  3. That deploying is repeatable and reversible, not a manual ritual.
  4. That they can scale the API during campaigns (Sant Jordi, Christmas) without redoing anything.
  5. 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.

  1. 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

  1. 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.html
mkdir -p ~/aurora-libros/api ~/aurora-libros/db ~/aurora-libros/web
cd ~/aurora-libros

Three 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.

  1. The API code: package.json

Create ~/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:

  • name and version: they identify the package. Version 1.0.0 will be the first tag of the image you build in module 2.
  • main: the entry file.
  • type: "commonjs": we will use require() instead of import. 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. express is the web framework; pg the PostgreSQL client; redis the Redis client. Each one implies an external service that must exist and be reachable.

  1. The API code: server.js

Create ~/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".

  1. The database: db/init.sql

Create ~/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 KEY generates auto-incrementing identifiers, which are the ones /books/:id will use.
  • isbn ... UNIQUE prevents duplicates. Combined with ON CONFLICT (isbn) DO NOTHING at 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, never FLOAT: with money, floating-point arithmetic produces rounding errors.
  • CHECK (price >= 0) is an integrity constraint at the database level.
  • TIMESTAMPTZ stores the timestamp with its time zone, which avoids the class of problems in the table from lesson 01-01.
  • CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS reinforce idempotency.

Eight books by Spanish-language authors: fictional but plausible data, enough to test the catalog, the cache and the single-book query.

  1. The static site: web/index.html

Reuse 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.

  1. 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

cd ~/aurora-libros/api
node server.js
node:internal/modules/cjs/loader:1215
  throw err;
  ^
Error: Cannot find module 'express'
Require stack:
- /home/junior/aurora-libros/api/server.js

The dependencies are missing. Makes sense.

Attempt 2: install dependencies

npm install

But first you need Node. Let's check which version is there:

node --version
v18.19.1

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 --version
v22.14.0

Four commands, a shell restart and a new tool installed on your machine just to have the right Node version. Now, at last:

npm install
added 112 packages in 6s

Attempt 3: start it again

node server.js
[aurora-api] failed to start: connect ECONNREFUSED 127.0.0.1:6379

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

sudo apt install -y redis-server
sudo systemctl enable --now redis-server
redis-cli ping
PONG

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

node server.js
[aurora-api] listening on port 3000
[aurora-api] database: localhost:5432/aurora_books
[aurora-api] cache: localhost:6379

It starts! Let's try it:

curl http://localhost:3000/health
{"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.

curl http://localhost:3000/books
{"error":"Could not fetch the catalog","detail":"connect ECONNREFUSED 127.0.0.1:5432"}

Attempt 6: install and configure PostgreSQL

sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql
psql --version
psql (PostgreSQL) 16.6

With 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;"
CREATE ROLE
CREATE DATABASE

And load the schema with the data:

PGPASSWORD=aurora psql -h localhost -U aurora -d aurora_books -f ~/aurora-libros/db/init.sql
CREATE TABLE
CREATE INDEX
INSERT 0 8

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

node server.js
[aurora-api] listening on port 3000
[aurora-api] database: localhost:5432/aurora_books
[aurora-api] cache: localhost:6379
curl http://localhost:3000/health
{"service":"aurora-api","version":"1.0.0","db":"ok","cache":"ok"}
curl -s http://localhost:3000/books | head -c 300
{"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:

curl -s http://localhost:3000/books | head -c 40
{"source":"cache","books":[{"id":5,...

source has changed from db to cache: the second request was served from Redis, without touching PostgreSQL. The cache works.

And a specific book:

curl -s http://localhost:3000/books/2
{"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.

  1. 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:

sudo systemctl stop redis-server postgresql
sudo systemctl disable redis-server postgresql

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.

  1. 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

  • ECONNREFUSED does not mean "wrong credentials". It means nobody is listening on that port. If you see ECONNREFUSED, 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.js had host: '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 does app.listen(PORT, '127.0.0.1') is unreachable from outside even if you publish the port. Use 0.0.0.0, as server.js does.
  • Forgetting nvm use in 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.sql did not have IF NOT EXISTS and ON 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 up and 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:

  1. Every command you had to run.
  2. Every error that came up, with its literal message.
  3. The total time spent.
  4. 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:3000

Exercise 3: prepare the ground for module 2

Without writing any Dockerfile yet (that is module 2), answer with your reasoning:

  1. Which base image would you choose for aurora-api and why? Check its size with docker pull and docker image ls.
  2. Which official images would you use for aurora-db and aurora-cache? Pull them and note each one's size.
  3. Of the files in ~/aurora-libros/api/, which should go into the API's image and which should not? Justify it.
  4. Should the DB_PASSWORD variable 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 file

The 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 pingPONG) 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

  1. Base image for aurora-api: node:22-alpine. Reasons: it satisfies the engines: node >=22.0.0 in package.json, it is official (the library/ namespace), and the Alpine variant weighs about 140 MB against the ~1.1 GB of the full node: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.

  1. postgres:16-alpine for aurora-db and redis:7-alpine for aurora-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}}"
REPOSITORY:TAG            SIZE
node:22-alpine            142MB
postgres:16-alpine        278MB
redis:7-alpine            41.4MB

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.

  1. 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.

  1. DB_PASSWORD must not go inside the image. Ever. Three cumulative reasons:

    • Security: an image's layers can be inspected by anyone who has it (docker image history shows 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.js reads all of its configuration from process.env. The values are injected when the container is run, not when it is built: with -e or --env-file (module 3), with variables in compose.yaml (lesson 04-05), or with specific secret mechanisms (lesson 05-03).

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

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved