The Dockerfile you closed the previous lesson with works: it builds auroralibros/aurora-api:1.0.0, starts Express in a container and responds to curl. But it is missing the pieces that separate an image that works from an image you can put into production without blushing. Right now the API runs as root inside the container, nobody knows who built it or from which commit, Docker has no way of knowing whether the process is alive but stuck, the Node version is hardcoded into the FROM, and you cannot run the image with a different command without losing its default behavior entirely. This lesson solves all five things. You are going to learn ARG and its critical difference from ENV, the ENTRYPOINT + CMD pairing with its four combinations, USER to stop being root, LABEL with the OCI standard, HEALTHCHECK against the /health endpoint you prepared in lesson 01-07, and the minor instructions — VOLUME, STOPSIGNAL, ONBUILD and SHELL — that are worth knowing even if you rarely use them. At the end you will have the professional aurora-api Dockerfile.

Contents

  1. ARG versus ENV: scope and timing
  2. ARG before the first FROM
  3. ENTRYPOINT versus CMD: the four combinations
  4. The docker-entrypoint.sh pattern and why exec "$@" matters
  5. USER: stop running as root
  6. LABEL and the standard OCI labels
  7. HEALTHCHECK: let Docker watch your service
  8. VOLUME and why declaring it is usually a bad idea
  9. STOPSIGNAL and signal handling
  10. ONBUILD and SHELL
  11. The professional aurora-api Dockerfile

  1. ARG versus ENV: scope and timing

Both define variables. The confusion between them is constant, and the difference is easy to state: ARG exists only during the build; ENV also exists inside the running container.

Aspect ARG ENV
When it exists Only during docker build During the build and at runtime
Visible inside the container No Yes (printenv, process.env)
Set from outside with --build-arg KEY=value -e KEY=value in docker run
Default value ARG KEY=value ENV KEY=value
Overridable at runtime It does not exist at runtime Yes, with -e
Does it show up in docker image history? Yes, its value Yes, its value
Typical use Versions, build paths, CI metadata The application's default configuration

An example that demonstrates everything. Create /tmp/arg-env/Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:3.21

# Build argument, with a default value
ARG GREETING_BUILD=hello-from-the-build

# Environment variable, with a default value
ENV GREETING_RUN=hello-from-the-run

# During the build, BOTH are available
RUN echo "BUILD sees ARG: $GREETING_BUILD" && echo "BUILD sees ENV: $GREETING_RUN"

CMD ["sh", "-c", "echo \"RUN sees ARG: [$GREETING_BUILD]\"; echo \"RUN sees ENV: [$GREETING_RUN]\""]
cd /tmp/arg-env
docker build --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD sees"
#5 0.153 BUILD sees ARG: hello-from-the-build
#5 0.154 BUILD sees ENV: hello-from-the-run

During the build, both are available. Now run it:

docker run --rm argenv
RUN sees ARG: []
RUN sees ENV: [hello-from-the-run]

The ARG is empty. It is not that it holds something else: it does not exist. It evaporated when the build finished. The ENV is still there.

And this is how you pass a value in from outside:

docker build --build-arg GREETING_BUILD=injected-value --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD sees ARG"
#5 0.148 BUILD sees ARG: injected-value

The bridge between the two

The most useful pattern is combining them: an ARG that feeds an ENV.

ARG BUILD_VERSION=1.0.0
ENV APP_VERSION=${BUILD_VERSION}

That way, --build-arg BUILD_VERSION=1.2.3 at build time ends up as an environment variable you can query inside the container. It is how version numbers and commit hashes are injected from a CI pipeline (lesson 06-02).

The important warning: ARG is not a secret

This one has to be burned in:

# ❌ NEVER DO THIS
ARG DB_PASSWORD
RUN echo "connecting with $DB_PASSWORD" > /app/config.txt
docker build --build-arg DB_PASSWORD=superSecret2026 -t leaked .
docker image history leaked --no-trunc | head -5
CREATED BY
|1 DB_PASSWORD=superSecret2026 /bin/sh -c echo "connecting with $DB_PASSWORD" > /app/config.txt

There is the password, in the clear, in the image's history. Anyone who pulls the image can read it with a single command, without starting anything. And by publishing to the public auroralibros/aurora-api repository, on the internet.

It is the same rule you have been carrying since lesson 01-07, now with an additional leak path: it is not only ENV that leaks secrets, ARG does too. To pass credentials into a build safely there are BuildKit secret mounts (RUN --mount=type=secret), which leave no trace in any layer; they are studied in lesson 05-05. For Aurora Libros the rule stays intact: credentials do not go into the image, neither at build time nor at runtime; they are injected when the container starts.

  1. ARG before the first FROM

ARG has a scoping quirk that surprises everybody: an ARG declared before the first FROM is only visible in the FROM lines, not inside the image.

# syntax=docker/dockerfile:1

# "Global" ARG: it lives outside any stage. Only the FROMs see it.
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# In here, NODE_VERSION no longer exists unless it is declared again
ARG NODE_VERSION
RUN echo "Building on Node ${NODE_VERSION}"

That double declaration (ARG NODE_VERSION with no value, inside the stage) is how you "re-import" the global argument. Without it, the variable would be empty inside the RUN.

The practical usefulness is enormous: parameterizing the base version without touching the Dockerfile.

# Default build: Node 22
docker build -t aurora-api:node22 .

# Test the API with Node 23 without editing anything
docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .

Real use cases for Aurora Libros:

Scenario Command
Test the next major Node version before adopting it --build-arg NODE_VERSION=23
Reproduce a bug on the old version --build-arg NODE_VERSION=20
Compatibility matrix in CI One job per version, the same Dockerfile

A warning: parameterizing the base is convenient, but the default value must be the production one. If the ARG has no default value and somebody builds without --build-arg, they will get node:-alpine, which does not exist, and a cryptic manifest error.

  1. ENTRYPOINT versus CMD: the four combinations

CMD, which you already know, defines what runs when the container starts and is discarded entirely if you pass a command to docker run. ENTRYPOINT defines an executable that is not discarded: the docker run arguments are appended after it.

Combining the two produces four scenarios. This table is the reference worth keeping to hand:

# Dockerfile docker run image runs docker run image extra runs
1 CMD ["node","server.js"] only node server.js extra (the CMD is discarded)
2 ENTRYPOINT ["node","server.js"] only node server.js node server.js extra
3 ENTRYPOINT ["node"] + CMD ["server.js"] node server.js node extra (the CMD is replaced)
4 ENTRYPOINT ["node","server.js"] + CMD ["--port=3000"] node server.js --port=3000 node server.js extra

The rule that sums up all four rows: ENTRYPOINT is the fixed executable; CMD is the default arguments, and it is the only thing the user can replace.

Check it with the image you already have:

# Case 1: CMD only (your 1.0.0 image)
docker run --rm auroralibros/aurora-api:1.0.0 node --version
v22.14.0

The CMD ["node","server.js"] has vanished: the server does not start.

# Case 3: ENTRYPOINT + CMD
printf 'FROM auroralibros/aurora-api:1.0.0\nENTRYPOINT ["node"]\nCMD ["server.js"]\n' > /tmp/Dockerfile.ep
docker build -q -t aurora-ep -f /tmp/Dockerfile.ep /tmp

docker run --rm aurora-ep --version
v22.14.0

Here --version has replaced server.js, but node is still there: node --version was executed. The executable is untouchable.

The recommended pattern

ENTRYPOINT ["node"]
CMD ["server.js"]

Advantages over CMD alone:

  • The image has a clear identity. It is "the image that runs node", not "a generic image".
  • It is self-documenting. docker run yourimage --help just works.
  • It prevents accidents. Nobody carelessly starts a container that does not do what the image promises.

And its main drawback: debugging is harder. With CMD alone, a docker run --rm -it yourimage sh gives you a shell. With ENTRYPOINT ["node"], that command tries to run node sh and fails. That is what --entrypoint is for:

docker run --rm -it --entrypoint sh aurora-ep
/app #

--entrypoint replaces the fixed executable. Watch out for one confusing detail: --entrypoint accepts a single value, and everything after the image name is passed to it as arguments:

docker run --rm --entrypoint sh aurora-ep -c "ls /app && node --version"
node_modules
package-lock.json
package.json
server.js
v22.14.0

Shell and exec forms, again

Everything you learned in lesson 02-03 about CMD applies equally to ENTRYPOINT, only worse:

ENTRYPOINT ["node", "server.js"]     # ✅ Exec form
ENTRYPOINT node server.js            # ❌ Shell form

With ENTRYPOINT in shell form two bad things happen, not one:

  1. PID 1 is sh and it does not forward SIGTERM: the 10 seconds of waiting and the SIGKILL from the previous lesson.
  2. CMD is ignored entirely, and so are the docker run arguments. An ENTRYPOINT in shell form swallows the whole argument mechanism.

With ENTRYPOINT, the exec form is not a recommendation: it is mandatory.

  1. The docker-entrypoint.sh pattern and why exec "$@" matters

When startup needs logic — waiting for a dependency, generating a configuration file, applying migrations — the ENTRYPOINT points at a script.

Create ~/aurora-libros/api/docker-entrypoint.sh:

#!/bin/sh
# Startup script for aurora-api
# Runs before the main process and hands control over to it with exec "$@"

set -e   # Abort on the first error: better not to start than to start badly

echo "[entrypoint] Starting aurora-api in ${NODE_ENV:-development} mode"

# Validation of mandatory configuration: fail early and with a clear message
if [ -z "$DB_HOST" ]; then
  echo "[entrypoint] WARNING: DB_HOST is not set; localhost will be used"
fi

# Active wait until the database accepts connections.
# Avoids the classic ECONNREFUSED when the API starts before PostgreSQL.
if [ -n "$DB_HOST" ] && [ "$WAIT_FOR_DB" = "true" ]; then
  echo "[entrypoint] Waiting for ${DB_HOST}:${DB_PORT:-5432}..."
  attempts=0
  until nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; do
    attempts=$((attempts + 1))
    if [ "$attempts" -ge 30 ]; then
      echo "[entrypoint] ERROR: the database is not responding after 30 attempts"
      exit 1
    fi
    sleep 1
  done
  echo "[entrypoint] Database available after ${attempts}s"
fi

echo "[entrypoint] Handing control over to: $@"

# THE KEY LINE
exec "$@"

And the Dockerfile brings it in like this:

COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

The complete flow:

sequenceDiagram
    participant D as docker run
    participant E as docker-entrypoint.sh (PID 1)
    participant N as node server.js

    D->>E: Starts ENTRYPOINT with CMD as its arguments ($@)
    E->>E: set -e, checks variables
    E->>E: Waits for DB_HOST to respond
    E->>N: exec "$@" · REPLACES the process
    Note over E,N: node inherits PID 1;<br/>the script ceases to exist
    D->>N: docker stop → SIGTERM reaches node DIRECTLY
    N->>N: Clean shutdown in 0.3 s

Why exec "$@" and not simply "$@"

This is the part you really have to understand.

  • "$@" is all the arguments received, each one quoted separately. Since the ENTRYPOINT is the script and the CMD is ["node","server.js"], "$@" is node server.js. The quotes are essential: without them, an argument containing spaces would be split in two.
  • exec is the crucial part. Without exec, the shell spawns a child and waits: PID 1 is still the script, and you are in exactly the shell-form scenario from lesson 02-03 — SIGTERM to the script, which does not forward it, ten seconds and SIGKILL. With exec, the shell replaces itself with node: same PID, same file descriptors, the script disappears from the process tree and node becomes PID 1.

Check it with the script in place:

docker run -d --name t-entry auroralibros/aurora-api:1.1.0
docker exec t-entry ps -o pid,comm
PID   COMMAND
    1 node

PID 1 is node, not sh. The script ran, did its job and stepped aside. And therefore:

time docker stop t-entry
real    0m0.291s

If you removed the exec, that figure would be 10 seconds. One difference of four letters, and the container's entire shutdown behavior changes.

A practical note: the script uses nc (netcat), which is not installed in node:22-alpine. You would have to add RUN apk add --no-cache netcat-openbsd. The final version in this lesson chooses not to include the active wait, because Docker Compose solves startup dependencies more cleanly with depends_on and health conditions (lesson 04-02). The pattern is still worth knowing, though: you will run into it in the official PostgreSQL and MySQL images, which use it exactly this way.

  1. USER: stop running as root

Right now your API runs as root inside the container. Check it:

docker run --rm auroralibros/aurora-api:1.0.0 id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon)...

uid=0 is root. A container is isolated, but that isolation is not an impenetrable wall: if a runtime escape vulnerability appears or you mount a volume badly, root in the container can become root on the host. Running as an unprivileged user eliminates that whole class of problems for the cost of three lines.

The instruction is USER, and it affects all subsequent Dockerfile instructions and the container's process:

USER node
USER 1000
USER node:node
USER 1000:1000    # The most portable form: numeric

Creating the user on Alpine

node:22-alpine already ships a node user (uid 1000), so USER node would be enough. But it is worth knowing how to create one, because other bases do not have it:

RUN addgroup -g 1001 -S aurora && \
    adduser  -u 1001 -S aurora -G aurora

The options of Alpine's tools (BusyBox), which differ from Debian's:

Option Meaning
-g 1001 / -u 1001 Explicit GID and UID. Pinning them matters so that permissions on mounted volumes line up
-S System: a system account, with no password and no expiry
-G aurora The user's primary group
-D (in adduser) No password

On Debian/Ubuntu the equivalent would be groupadd -g 1001 aurora && useradd -u 1001 -g aurora -m -s /bin/sh aurora.

The correct order

This is the point where everybody trips up:

# ❌ WRONG: switching to USER before installing
USER node
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev      # EACCES: permission denied
# ✅ RIGHT: install as root, switch user at the end
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

The correct sequence is: install and copy as root (which can write wherever it likes), assign ownership in the same copy operation with --chown, and put USER right before the CMD. As you saw in 02-03, --chown in the COPY avoids a subsequent RUN chown -R that would double the layer's size because of copy-on-write.

Check the result on the final image:

docker run --rm auroralibros/aurora-api:1.1.0 id
uid=1000(node) gid=1000(node) groups=1000(node)

The immediate consequence: privileged ports

An unprivileged user cannot listen on ports below 1024. If your application used port 80, it would stop starting:

Error: listen EACCES: permission denied 0.0.0.0:80

That is why aurora-api listens on 3000, and why the Nginx container in module 4 will need care. The solution is never to go back to root: it is to listen on a high port and publish it wherever needed with -p 80:3000, since the mapping is done by Docker on the host, not by the process.

The complete hardening of runtime security — capabilities, --read-only, no-new-privileges, seccomp profiles, user namespaces — is lesson 05-03. Here you take away the essential point: a production container does not run as root.

  1. LABEL and the standard OCI labels

LABEL adds arbitrary metadata to the image as key-value pairs. It does not change behavior, it weighs nothing, and it answers questions that become urgent in production: which commit did this image come from? who maintains it? what version is it?

LABEL key="value"
LABEL key1="value1" key2="value2"

Always group several labels into a single instruction: each LABEL creates a metadata layer.

The OCI standard

So that tools can read this data there is a standard vocabulary, org.opencontainers.image.*, defined by the Open Container Initiative (the same one from lesson 01-03):

Label Content
org.opencontainers.image.title Human-readable name of the component
org.opencontainers.image.description Short description
org.opencontainers.image.version Version of the packaged software
org.opencontainers.image.authors The people responsible, with contact details
org.opencontainers.image.vendor The owning organization
org.opencontainers.image.licenses License in SPDX format
org.opencontainers.image.source URL of the code repository
org.opencontainers.image.documentation URL of the documentation
org.opencontainers.image.revision The exact commit hash
org.opencontainers.image.created Build date (RFC 3339)
org.opencontainers.image.base.name Base image used

Applied to Aurora Libros, combining ARG for whatever varies on each build:

ARG VERSION=1.1.0
ARG REVISION=unknown
ARG CREATED=unknown

LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="REST API for the Aurora Libros S.L. catalog" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.documentation="https://github.com/auroralibros/aurora-libros/blob/main/README.md" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:22-alpine"

The first three ARGs are filled in at build time, typically from the pipeline:

docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .

And this is how you read them back afterwards:

docker image inspect auroralibros/aurora-api:1.1.0 \
  --format '{{range $k, $v := .Config.Labels}}{{$k}} = {{$v}}
{{end}}'
org.opencontainers.image.authors = [email protected]
org.opencontainers.image.base.name = docker.io/library/node:22-alpine
org.opencontainers.image.created = 2026-08-04T09:14:22Z
org.opencontainers.image.description = REST API for the Aurora Libros S.L. catalog
org.opencontainers.image.licenses = MIT
org.opencontainers.image.revision = 7a3f912
org.opencontainers.image.source = https://github.com/auroralibros/aurora-libros
org.opencontainers.image.title = aurora-api
org.opencontainers.image.vendor = Aurora Libros S.L.
org.opencontainers.image.version = 1.1.0

The real value of this becomes obvious at three in the morning on a Tuesday: production is failing, you have a container running and you need to know exactly what code is inside it. A docker inspect gives you commit 7a3f912, and with it git log tells you the whole story. Without that label, the archaeology begins.

Labels are also useful for filtering, with the syntax from lesson 01-04:

docker image ls --filter "label=org.opencontainers.image.vendor=Aurora Libros S.L."

  1. HEALTHCHECK: let Docker watch your service

A container can be Up and completely useless: the process is alive but the event loop is blocked, the connection pool is exhausted or the application is returning 500 to everything. docker ps would happily say Up 3 hours. HEALTHCHECK gives Docker a way to check its real health.

HEALTHCHECK [options] CMD <command>
Option Default What it controls
--interval 30s How often the check runs
--timeout 30s How long to wait for a response before considering it failed
--start-period 0s Initial grace period: failures here do not count
--start-interval 5s Interval during the grace period (recent versions)
--retries 3 Consecutive failures needed to declare it unhealthy

The command determines the state by its exit code: 0 = healthy, 1 = unhealthy. Any other value is treated as an error.

--start-period deserves special attention. Without it, an application that takes 20 seconds to start would be marked unhealthy during that legitimate startup, and in an orchestrator it would be killed and restarted in an endless loop. The grace period says: "during the first N seconds, if it fails, do not count it".

The aurora-api healthcheck

Aurora Libros has had the /health endpoint since lesson 01-07, designed precisely for this: it returns 200 if the database and the cache respond, and 503 if not.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

The details behind the choice of command:

  • wget and not curl: node:22-alpine ships wget (from BusyBox) but not curl. Using curl would force an apk add curl that adds around 4 MB. If you prefer curl, the option is curl -f http://localhost:3000/health || exit 1, where -f makes it return an error code on an HTTP 4xx/5xx.
  • --spider: it does not download the body, it only checks that the resource responds.
  • --tries=1: no internal retries; Docker already retries with --retries.
  • || exit 1: normalizes any failure to exit code 1, which is what Docker expects.
  • localhost: the check runs inside the container, so localhost is the service itself. It needs no published ports.
  • The timings: every 30 s, with 3 s of patience, 10 s of grace at startup and 3 consecutive failures before declaring it unhealthy. In the worst case, a downed service is detected in about 90 seconds.

Seeing it work

docker run -d --name aurora-hc -p 3000:3000 auroralibros/aurora-api:1.1.0
docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"

Just after starting:

NAMES        STATUS
aurora-hc    Up 3 seconds (health: starting)

health: starting is the grace period. Wait and look again:

sleep 40 && docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"
NAMES        STATUS
aurora-hc    Up 43 seconds (unhealthy)

unhealthy, and rightly so: /health returns 503 because there is no PostgreSQL and no Redis, exactly as in the previous lessons. The healthcheck is doing its job with complete precision: the process is alive, but the service is not operational. Precisely the distinction that Up cannot make.

The full history is in the metadata:

docker inspect aurora-hc --format '{{json .State.Health}}' | python3 -m json.tool
{
    "Status": "unhealthy",
    "FailingStreak": 3,
    "Log": [
        {
            "Start": "2026-08-04T09:32:11.442Z",
            "End": "2026-08-04T09:32:11.503Z",
            "ExitCode": 1,
            "Output": ""
        }
    ]
}

FailingStreak: 3 is the three consecutive failures that triggered the state change. The Log field keeps the most recent checks with their output, which is where you look when a healthcheck fails and you do not know why.

The possible states:

State Meaning
starting Within the --start-period; failures do not count
healthy The last check exited with code 0
unhealthy --retries consecutive failures were reached

And why it matters beyond the docker ps column:

  • Docker Compose can wait for a service to be healthy before starting another with depends_on: condition: service_healthy (lesson 04-02). It is the clean solution to the problem the script in section 4 was trying to solve by hand.
  • Docker Swarm automatically replaces unhealthy replicas (lesson 06-03).
  • Kubernetes has its own equivalent mechanism, probes (lesson 06-05).

Clean up:

docker rm -f aurora-hc

HEALTHCHECK NONE

If your base image defines a healthcheck that is no use to you, you can disable it:

HEALTHCHECK NONE

It is rare, but it comes up when you inherit from corporate images with checks that do not apply to your case.

  1. VOLUME and why declaring it is usually a bad idea

VOLUME declares that a directory in the image should be mounted as a volume:

VOLUME /var/lib/postgresql/data
VOLUME ["/data", "/logs"]

When you start a container from that image without specifying anything, Docker will automatically create an anonymous volume for that path.

It sounds useful, and yet the general recommendation is not to use it. Four reasons:

  1. It generates orphaned anonymous volumes. Every docker run without an explicit -v creates a volume with a random name that is not deleted when you remove the container (unless you use docker rm -v). On a development machine you accumulate tens of gigabytes of volumes with names like f3a9c2e1b8... that nobody knows whether they matter. You will see them in docker system df in lesson 02-05.
  2. It cannot be undone. Once an image declares VOLUME /data, whoever uses it cannot remove it. It imposes a storage decision on them that may not suit them.
  3. It breaks any later COPY. Anything you write to that path after the VOLUME in the Dockerfile is silently lost, with no warning at all. It is a maddening error to diagnose.
  4. It is a runtime decision, not an image decision. Who mounts what and where is decided by whoever deploys, with -v or with Compose's volumes: section.

A demonstration of point 3:

FROM alpine:3.21
VOLUME /data
RUN echo "hello" > /data/file.txt    # Lost
docker run --rm that-image ls /data
(empty)

The file is not there. It was written into a layer that the volume mount covers up.

No Dockerfile in Aurora Libros uses VOLUME. The API keeps no state — it reads from PostgreSQL and caches in Redis — and the catalog's persistence is handled by mounting a volume at runtime on the aurora-db container, which is the subject of lesson 03-06. Interestingly, the official PostgreSQL image itself does declare VOLUME /var/lib/postgresql/data, and that is exactly why orphaned anonymous volumes show up as soon as you experiment with it without -v.

  1. STOPSIGNAL and signal handling

STOPSIGNAL changes the signal Docker sends to PID 1 when you run docker stop:

STOPSIGNAL SIGTERM     # The default value
STOPSIGNAL SIGQUIT     # What Nginx needs
STOPSIGNAL SIGINT
STOPSIGNAL 15          # By number too

You already know the default behavior from lesson 02-03: docker stop sends SIGTERM, waits 10 seconds and sends SIGKILL, which can neither be caught nor ignored.

The problem is that not every program interprets SIGTERM the same way:

Program SIGTERM Signal for an orderly shutdown
Node.js Exits immediately SIGTERM (with your own handler)
Nginx Fast shutdown: cuts off in-flight connections SIGQUIT: graceful shutdown
PostgreSQL Smart shutdown: waits for clients SIGINT for a fast shutdown
Apache Immediate shutdown SIGWINCH for a graceful one

The Nginx case is the one that affects Aurora Libros: the aurora-web container in module 4 will want STOPSIGNAL SIGQUIT so it does not cut off half-served requests during a deployment.

For the API, SIGTERM (the default) is correct, but it is worth having server.js catch it in order to shut down cleanly. The pattern, which will be picked up again in lesson 06-01:

// Graceful shutdown: stop accepting new connections, finish the ones in
// flight and close the PostgreSQL pool and the Redis client.
const server = app.listen(PORT, '0.0.0.0', () => { /* ... */ });

async function shutdown(signal) {
  console.log(`[aurora-api] received ${signal}, shutting down gracefully...`);
  server.close(async () => {
    await pool.end();
    await cache.quit();
    console.log('[aurora-api] shut down cleanly');
    process.exit(0);
  });
  // Safety net: if something gets stuck, exit before the SIGKILL
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Notice the 8-second timer: it is deliberately lower than the 10 of docker stop, to force an exit before the SIGKILL arrives. And remember that none of this is any use if the CMD is in shell form: the signal would never reach the Node process.

  1. ONBUILD and SHELL

Two instructions of minority use that are worth recognizing.

ONBUILD

It registers an instruction that does not run now, but when somebody uses this image as a base with a FROM.

# In the "aurora-base-node" image
FROM node:22-alpine
WORKDIR /app
ONBUILD COPY package*.json ./
ONBUILD RUN npm ci --omit=dev && npm cache clean --force
ONBUILD COPY . .
CMD ["node", "server.js"]

Whoever uses it only writes:

FROM auroralibros/aurora-base-node:1

And at build time, the three registered steps fire automatically. It is a template for standardizing an organization's microservices.

Why it is rarely used: the hidden magic. Whoever reads that one-line Dockerfile cannot see what is going to happen; they have to go and inspect the base. Debugging a failure in an ONBUILD is especially frustrating, because the error points at a file that does not contain the guilty instruction. The official Node images themselves retired their onbuild variants years ago for this very reason. If you need standardization, today a shared Dockerfile template or a well-documented base file is preferable.

SHELL

It changes the interpreter used by the shell form of RUN, CMD and ENTRYPOINT:

SHELL ["/bin/bash", "-c"]
SHELL ["powershell", "-Command"]    # Windows containers

The default on Linux is ["/bin/sh", "-c"]. The most common and legitimate use case on Linux is enabling bash's strict mode in images with many chained RUNs:

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -s https://example.com/list.txt | grep aurora > /app/list.txt

Without pipefail, that RUN succeeds even if the curl fails, because a pipeline's exit code is that of the last command, and grep would still run. With pipefail, a failure in any link makes the whole instruction fail. It is a classic silent failure that produces images with empty files.

A note for Alpine: node:22-alpine does not ship bash, only BusyBox's ash. Using SHELL ["/bin/bash", …] there would require RUN apk add --no-cache bash. There is no SHELL at all in the aurora-api Dockerfile: there are no pipelines in its RUNs.

  1. The professional aurora-api Dockerfile

Everything together. Save this as ~/aurora-libros/api/Dockerfile:

# syntax=docker/dockerfile:1
# =============================================================================
# aurora-api · REST API for the Aurora Libros S.L. catalog
#
# Standard build:
#   docker build -t auroralibros/aurora-api:1.1.0 .
#
# Build with full metadata (what the pipeline will do in 06-02):
#   docker build \
#     --build-arg VERSION=1.1.0 \
#     --build-arg REVISION="$(git rev-parse --short HEAD)" \
#     --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
#     -t auroralibros/aurora-api:1.1.0 .
# =============================================================================

# --- Global arguments: only the FROM instructions see them ------------------
# They let you try another Node version without touching the file:
#   docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# --- Stage arguments: traceability metadata --------------------------------
# We re-import NODE_VERSION because global ARGs do not cross the FROM.
ARG NODE_VERSION
ARG VERSION=1.1.0
ARG REVISION=unknown
ARG CREATED=unknown

# --- OCI metadata -----------------------------------------------------------
# Costs zero bytes and answers "which commit is inside this?" at 3 AM.
LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="REST API for the Aurora Libros S.L. catalog" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:${NODE_VERSION}-alpine"

WORKDIR /app

# --- Dependencies before code (cache, lesson 02-02) -------------------------
# --chown in the COPY itself: avoids a later RUN chown -R that would double
# the layer's size because of copy-on-write.
COPY --chown=node:node package*.json ./

RUN npm ci --omit=dev && npm cache clean --force

# --- Application code -------------------------------------------------------
COPY --chown=node:node . .

# --- Default configuration, overridable with -e -----------------------------
# NO SECRETS HERE: they would end up in the metadata and in docker image history.
ENV NODE_ENV=production \
    PORT=3000 \
    APP_VERSION=${VERSION}

# --- Unprivileged user ------------------------------------------------------
# Goes AFTER installing and copying (root needed to write) and BEFORE the CMD.
# node:22-alpine already includes the "node" user with uid 1000.
USER node

# --- Port: documentation, not publishing ------------------------------------
# 3000 and not 80 because an unprivileged user cannot use ports below 1024.
EXPOSE 3000

# --- Health check -----------------------------------------------------------
# wget ships with BusyBox on Alpine; curl is not there and would add ~4 MB.
# A 10 s start-period so it is not marked unhealthy during a legitimate startup.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

# --- Main process -----------------------------------------------------------
# ENTRYPOINT fixes the executable; CMD is the replaceable arguments.
# Both in exec form: node is PID 1 and receives SIGTERM directly.
ENTRYPOINT ["node"]
CMD ["server.js"]

Build the new version:

cd ~/aurora-libros/api
docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo no-git)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .
[+] Building 10.8s (13/13) FINISHED
 => [1/5] FROM docker.io/library/node:22-alpine3.21@sha256:9f2c...        0.0s
 => [2/5] WORKDIR /app                                                    0.1s
 => [3/5] COPY --chown=node:node package*.json ./                         0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                8.7s
 => [5/5] COPY --chown=node:node . .                                      0.1s
 => exporting to image                                                    0.7s
 => => naming to docker.io/auroralibros/aurora-api:1.1.0                  0.0s

A full verification of everything you have added:

# 1. The user is no longer root
docker run --rm auroralibros/aurora-api:1.1.0 --eval "console.log(process.getuid())"
1000

Notice the detail: with ENTRYPOINT ["node"], the argument --eval "..." replaced the CMD ["server.js"] and node --eval ... was executed. Case 3 from the table in section 3, live.

# 2. ENTRYPOINT and CMD in the metadata
docker image inspect auroralibros/aurora-api:1.1.0 \
  --format 'Entrypoint: {{json .Config.Entrypoint}}
Cmd:        {{json .Config.Cmd}}
User:       {{.Config.User}}
Health:     {{json .Config.Healthcheck.Test}}'
Entrypoint: ["node"]
Cmd:        ["server.js"]
User:       node
Health:     ["CMD-SHELL","wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1"]
# 3. Startup, healthcheck and clean shutdown
docker run -d --name aurora-pro -p 3000:3000 auroralibros/aurora-api:1.1.0
sleep 5 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
sleep 40 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
time docker stop aurora-pro
docker rm aurora-pro
aurora-pro: Up 5 seconds (health: starting)
aurora-pro: Up 45 seconds (unhealthy)
aurora-pro
real    0m0.304s

All three things confirmed: the grace period, the correct detection that the service is not operational (PostgreSQL is missing, module 3) and a stop in 0.3 seconds thanks to the exec form.

Compare with the previous version:

Aspect 1.0.0 (lesson 02-03) 1.1.0 (this lesson)
Runtime user root (uid 0) node (uid 1000)
Node version Hardcoded in the FROM Parameterizable with --build-arg
Traceability None 11 OCI labels, with commit and date
Service health Only "the process is alive" Real healthy/unhealthy against /health
Executable Fully replaceable Fixed (node), with replaceable arguments
Size 167 MB 167 MB (metadata weighs nothing)

All of that improvement has cost zero bytes.

Common Mistakes and Tips

  • Using ARG for secrets. They stay in docker image history in the clear. For build-time credentials there are BuildKit secrets (lesson 05-05); for runtime credentials, -e and environment files (lesson 04-05).
  • Expecting a global ARG to be available inside the stage. ARGs before the first FROM are only visible to the FROMs. They have to be declared again, with no value, inside the stage.
  • ENTRYPOINT in shell form. It breaks two things: PID 1 becomes sh (a 10 s stop) and the docker run arguments and the CMD are ignored entirely. Exec form always.
  • An entrypoint script without exec. The script stays as PID 1, does not forward SIGTERM and you are back to the ten seconds and the SIGKILL. The last line must be exec "$@", with quotes.
  • USER too far up. If you switch user before npm ci, the step fails with EACCES. Install as root, copy with --chown and put USER right before the CMD.
  • RUN chown -R after copying. It doubles that layer's size because of copy-on-write. Use COPY --chown.
  • An unprivileged user listening on port 80. EACCES: permission denied. Listen on a high port and publish with -p 80:3000.
  • HEALTHCHECK without --start-period. The container is marked unhealthy during its legitimate startup and the orchestrator enters a restart loop.
  • A healthcheck that queries external dependencies and does not distinguish. If /health returns 503 because the database is down, the orchestrator will restart the API, which is not at fault for anything. For Aurora Libros it is fine in development; in production it is worth separating liveness (is the process alive?) from readiness (can it serve?), and that is covered in lesson 06-05.
  • VOLUME in the Dockerfile. It generates orphaned anonymous volumes, it cannot be undone and it silently makes whatever you later write to that path disappear. Decide storage at runtime.
  • Tip: put your LABELs in a single instruction, with \ to split lines. Each LABEL is a metadata layer.
  • Tip: test the healthcheck command by hand with docker exec before putting it in the Dockerfile. It saves entire build cycles.

Exercises

Exercise 1: demonstrate the difference between ARG and ENV

Build an image based on alpine:3.21 that:

  1. Takes an ARG BUILD_ENVIRONMENT with the default value local.
  2. Defines an ENV RUN_ENVIRONMENT from that ARG.
  3. Prints both values during the build with a RUN.
  4. Prints both when the container runs.

Then:

  • Build it without --build-arg and run it. What does each variable print?
  • Build it with --build-arg BUILD_ENVIRONMENT=production and run it. What changes?
  • Run the image with -e RUN_ENVIRONMENT=overridden. Can you do the same with the ARG?
  • Check with docker image history whether the ARG's value is visible. Explain what that implies for passwords.

Exercise 2: walk through the four ENTRYPOINT and CMD combinations

Create four images based on alpine:3.21, one per row of the table in section 3, using echo as the executable. For each one, run docker run --rm <image> and docker run --rm <image> bye and write down the actual output. Then:

  1. Fill in the table with what you observed and compare it with the theoretical one.
  2. Explain in one sentence the rule that governs all four rows.
  3. Use --entrypoint to run ls / on the image from the fourth row.

Exercise 3: a healthcheck that does distinguish

The current HEALTHCHECK for aurora-api marks the container unhealthy when PostgreSQL fails, even though the API is working perfectly. Design an alternative:

  1. Explain why that is problematic in an orchestrator that restarts unhealthy containers.
  2. Propose two different endpoints and what each one should check.
  3. Write the HEALTHCHECK you would use in the Dockerfile and justify the choice.
  4. Demonstrate with a real container the difference between checking /health (which returns 503 with no database) and checking only that the port responds.

Solutions

Solution to exercise 1

mkdir -p /tmp/ex-argenv && cd /tmp/ex-argenv

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21

ARG BUILD_ENVIRONMENT=local
ENV RUN_ENVIRONMENT=${BUILD_ENVIRONMENT}

RUN echo "[build] ARG=${BUILD_ENVIRONMENT} ENV=${RUN_ENVIRONMENT}"

CMD ["sh", "-c", "echo \"[run] ARG=[${BUILD_ENVIRONMENT}] ENV=[${RUN_ENVIRONMENT}]\""]
EOF

docker build --no-cache --progress=plain -t ex:default . 2>&1 | grep "\[build\]"
docker run --rm ex:default
#5 0.142 [build] ARG=local ENV=local
[run] ARG=[] ENV=[local]

With --build-arg:

docker build --no-cache --progress=plain --build-arg BUILD_ENVIRONMENT=production -t ex:prod . 2>&1 | grep "\[build\]"
docker run --rm ex:prod
#5 0.139 [build] ARG=production ENV=production
[run] ARG=[] ENV=[production]

The analysis:

  • At build time, both exist. The ENV takes the ARG's value: that is the bridge pattern.
  • At runtime, the ARG is always empty. It never reached the container.
  • The ENV keeps the value frozen at build time.

Overriding at runtime:

docker run --rm -e RUN_ENVIRONMENT=overridden ex:prod
[run] ARG=[] ENV=[overridden]

The ENV can indeed be overridden with -e. The ARG cannot, because it does not exist at runtime: -e BUILD_ENVIRONMENT=x would create a brand new environment variable with no relation whatsoever to the build's ARG.

The history:

docker image history ex:prod --no-trunc --format "{{.CreatedBy}}" | head -4
CMD ["sh" "-c" "echo \"[run] ARG=[${BUILD_ENVIRONMENT}] ENV=[${RUN_ENVIRONMENT}]\""]
|1 BUILD_ENVIRONMENT=production /bin/sh -c echo "[build] ARG=${BUILD_ENVIRONMENT} ENV=${RUN_ENVIRONMENT}"
ENV RUN_ENVIRONMENT=production
ARG BUILD_ENVIRONMENT=local

The value production appears in the clear on the RUN line. If instead of production it had been superSecret2026, anyone pulling the image would read it with a single command and without starting anything. That is exactly why ARG is not suitable for secrets, however much it "disappears" at runtime: it disappears from the environment, not from the history.

Solution to exercise 2

mkdir -p /tmp/ex-ep && cd /tmp/ex-ep

printf 'FROM alpine:3.21\nCMD ["echo", "hello-cmd"]\n' > D1
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "hello-entry"]\n' > D2
printf 'FROM alpine:3.21\nENTRYPOINT ["echo"]\nCMD ["hello-combo"]\n' > D3
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "prefix"]\nCMD ["suffix"]\n' > D4

for n in 1 2 3 4; do docker build -q -t ep:$n -f D$n . ; done

for n in 1 2 3 4; do
  echo "--- Case $n ---"
  echo -n "no arguments: "; docker run --rm ep:$n
  echo -n "with 'bye':   "; docker run --rm ep:$n bye
done
--- Case 1 ---
no arguments: hello-cmd
with 'bye':   /bin/sh: bye: not found
--- Case 2 ---
no arguments: hello-entry
with 'bye':   hello-entry bye
--- Case 3 ---
no arguments: hello-combo
with 'bye':   bye
--- Case 4 ---
no arguments: prefix suffix
with 'bye':   prefix bye

1. The observed table matches the theoretical one. Case 1 with an argument is especially illustrative: bye replaced the entire CMD and Docker tried to run a program called bye, which does not exist. In case 2, bye was appended to the ENTRYPOINT. In cases 3 and 4, bye replaced the CMD but the ENTRYPOINT remained.

2. The rule: ENTRYPOINT is the fixed executable and CMD is its default arguments; what you write after the image name in docker run replaces the CMD only.

3. With --entrypoint:

docker run --rm --entrypoint ls ep:4 /
bin
dev
etc
home
lib
...

--entrypoint ls replaces echo, and the / after the image name replaces the CMD, giving ls /. It is the command you will need every time you want to inspect an image with its own ENTRYPOINT:

docker run --rm -it --entrypoint sh auroralibros/aurora-api:1.1.0

Solution to exercise 3

1. The problem. The current healthcheck checks /health, which returns 503 if PostgreSQL or Redis do not respond. In Swarm or Kubernetes, an unhealthy container is restarted or replaced. If the database goes down for five minutes, every API replica will be marked unhealthy and will enter a restart cycle despite being perfectly healthy. Worse still: when PostgreSQL comes back, it will meet an avalanche of reconnections from freshly started replicas, with empty caches. A dependency failure has been turned into a total outage and a retry storm.

2. Two endpoints with different responsibilities:

Endpoint The question it answers What it checks Action if it fails
/health/live (liveness) Is the process working? Only that Express responds. No dependencies Restart: the process is broken
/health/ready (readiness) Can it serve requests? Database and cache reachable Take it out of the load balancer, without restarting

The distinction is the key: restarting fixes a stuck process, but it does not fix a downed database. Faced with a dependency failure, the right thing to do is to stop sending traffic to that replica and wait, not to kill it.

3. The Dockerfile's HEALTHCHECK:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health/live || exit 1

The justification: Docker's HEALTHCHECK has a single state, and its natural consequence is a restart or a replacement. It must therefore reflect liveness. Readiness is queried by the load balancer or the orchestrator on its own — in Compose, with depends_on: condition: service_healthy (lesson 04-02); in Kubernetes, with a separate readinessProbe (lesson 06-05).

The new endpoint in server.js would be as simple as:

// Liveness: deliberately does not touch any external dependency
app.get('/health/live', (req, res) => res.status(200).json({ status: 'alive' }));

4. The demonstration. With the current healthcheck, with no database:

docker run -d --name hc-health auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-health --format "{{.Names}}: {{.Status}}"
hc-health: Up 45 seconds (unhealthy)

Now overriding the healthcheck at runtime to check only that the port responds:

docker run -d --name hc-port \
  --health-cmd="wget -q --tries=1 --spider http://localhost:3000/books/abc || exit 1" \
  --health-interval=10s --health-start-period=10s --health-retries=3 \
  auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-port --format "{{.Names}}: {{.Status}}"
hc-port: Up 45 seconds (healthy)

The same container, with the same missing database, is unhealthy with one check and healthy with the other. The request to /books/abc returns a 400 validation error — without touching PostgreSQL, because the identifier is not an integer — which proves that Express is alive and routing correctly. That is exactly the liveness signal you are after.

Notice in passing the --health-* options of docker run: they let you override an image's healthcheck without rebuilding it, which is extremely handy for experimenting.

docker rm -f hc-health hc-port

Conclusion

Your image has gone from "it works" to "it is professional", and without gaining a single byte. You can tell ARG from ENV by their scope and timing — the first evaporates when the build ends, the second travels inside the container — and you know that neither of them is any good for secrets, because a --build-arg with a password ends up written in the clear in docker image history. You have used ARG before the FROM to parameterize the Node version and be able to test Node 23 without editing a line.

You have mastered the ENTRYPOINT + CMD pairing and its four combinations, with the rule that sums them up: the ENTRYPOINT is the fixed executable, the CMD is the replaceable arguments, and --entrypoint is your way in when you need to debug. You have seen why a docker-entrypoint.sh script must end in exec "$@": without that exec, the script stays as PID 1, does not forward SIGTERM and you are back to the ten-second wait and the SIGKILL. With USER node you have stopped running the API as root — uid=1000 confirmed — installing as root first and copying with --chown so as not to duplicate layers, and you understand why that forces you to listen on 3000 and not on 80.

With eleven OCI labels the image now says who made it, which commit it comes from and when it was built, which is what you need at three in the morning. With HEALTHCHECK, Docker watches the /health endpoint you prepared in lesson 01-07, and you have seen the full startingunhealthy cycle in docker ps, with the failure history in docker inspect. And you know why VOLUME in a Dockerfile is usually a bad idea — orphaned volumes, an irreversible decision imposed on the user and later writes that vanish without warning — what STOPSIGNAL is for (Nginx needs SIGQUIT) and what ONBUILD and SHELL do in the few cases where they are worth it.

auroralibros/aurora-api:1.1.0 is an image that runs as an unprivileged user, identifies itself, diagnoses itself and stops cleanly in 0.3 seconds. You now know how to build it; next it is worth knowing how to administer it. In the next lesson, Managing Docker Images, you will take care of the local store: listing and filtering with templates, extracting specific fields with inspect --format, auditing with history which layer ate 80 MB, deleting images that have containers using them, understanding where those mysterious <none>:<none> images that pile up build after build come from, cleaning up with the prune family without destroying what you should not, diagnosing disk space with docker system df and taking an image to another machine without any registry at all, with docker save and docker load.

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