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
ARGversusENV: scope and timingARGbefore the firstFROMENTRYPOINTversusCMD: the four combinations- The
docker-entrypoint.shpattern and whyexec "$@"matters USER: stop running as rootLABELand the standard OCI labelsHEALTHCHECK: let Docker watch your serviceVOLUMEand why declaring it is usually a bad ideaSTOPSIGNALand signal handlingONBUILDandSHELL- The professional
aurora-apiDockerfile
ARG versus ENV: scope and timing
ARG versus ENV: scope and timingBoth 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]\""]During the build, both are available. Now run it:
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"The bridge between the two
The most useful pattern is combining them: an ARG that feeds an ENV.
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:
docker build --build-arg DB_PASSWORD=superSecret2026 -t leaked .
docker image history leaked --no-trunc | head -5CREATED BY
|1 DB_PASSWORD=superSecret2026 /bin/sh -c echo "connecting with $DB_PASSWORD" > /app/config.txtThere 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.
ARG before the first FROM
ARG before the first FROMARG 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.
ENTRYPOINT versus CMD: the four combinations
ENTRYPOINT versus CMD: the four combinationsCMD, 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:
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 --versionHere --version has replaced server.js, but node is still there: node --version was executed. The executable is untouchable.
The recommended pattern
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 --helpjust 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:
--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:
Shell and exec forms, again
Everything you learned in lesson 02-03 about CMD applies equally to ENTRYPOINT, only worse:
With ENTRYPOINT in shell form two bad things happen, not one:
- PID 1 is
shand it does not forward SIGTERM: the 10 seconds of waiting and the SIGKILL from the previous lesson. CMDis ignored entirely, and so are thedocker runarguments. AnENTRYPOINTin shell form swallows the whole argument mechanism.
With ENTRYPOINT, the exec form is not a recommendation: it is mandatory.
- The
docker-entrypoint.sh pattern and why exec "$@" matters
docker-entrypoint.sh pattern and why exec "$@" mattersWhen 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 theENTRYPOINTis the script and theCMDis["node","server.js"],"$@"isnode server.js. The quotes are essential: without them, an argument containing spaces would be split in two.execis the crucial part. Withoutexec, 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. Withexec, the shell replaces itself withnode: same PID, same file descriptors, the script disappears from the process tree andnodebecomes PID 1.
Check it with the script in place:
PID 1 is node, not sh. The script ran, did its job and stepped aside. And therefore:
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.
USER: stop running as root
USER: stop running as rootRight now your API runs as root inside the container. Check it:
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:
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:
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:
The immediate consequence: privileged ports
An unprivileged user cannot listen on ports below 1024. If your application used port 80, it would stop starting:
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.
LABEL and the standard OCI labels
LABEL and the standard OCI labelsLABEL 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?
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.0The 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:
HEALTHCHECK: let Docker watch your service
HEALTHCHECK: let Docker watch your serviceA 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.
| 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 1The details behind the choice of command:
wgetand notcurl:node:22-alpineshipswget(from BusyBox) but notcurl. Usingcurlwould force anapk add curlthat adds around 4 MB. If you prefercurl, the option iscurl -f http://localhost:3000/health || exit 1, where-fmakes 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, solocalhostis 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:
health: starting is the grace period. Wait and look again:
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:
{
"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
healthybefore starting another withdepends_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
unhealthyreplicas (lesson 06-03). - Kubernetes has its own equivalent mechanism, probes (lesson 06-05).
Clean up:
HEALTHCHECK NONE
If your base image defines a healthcheck that is no use to you, you can disable it:
It is rare, but it comes up when you inherit from corporate images with checks that do not apply to your case.
VOLUME and why declaring it is usually a bad idea
VOLUME and why declaring it is usually a bad ideaVOLUME declares that a directory in the image should be mounted as a volume:
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:
- It generates orphaned anonymous volumes. Every
docker runwithout an explicit-vcreates a volume with a random name that is not deleted when you remove the container (unless you usedocker rm -v). On a development machine you accumulate tens of gigabytes of volumes with names likef3a9c2e1b8...that nobody knows whether they matter. You will see them indocker system dfin lesson 02-05. - 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. - It breaks any later
COPY. Anything you write to that path after theVOLUMEin the Dockerfile is silently lost, with no warning at all. It is a maddening error to diagnose. - It is a runtime decision, not an image decision. Who mounts what and where is decided by whoever deploys, with
-vor with Compose'svolumes:section.
A demonstration of point 3:
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.
STOPSIGNAL and signal handling
STOPSIGNAL and signal handlingSTOPSIGNAL 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 tooYou 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.
ONBUILD and SHELL
ONBUILD and SHELLTwo 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:
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:
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.txtWithout 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.
- The professional
aurora-api Dockerfile
aurora-api DockerfileEverything 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.0sA 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())"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-proaurora-pro: Up 5 seconds (health: starting)
aurora-pro: Up 45 seconds (unhealthy)
aurora-pro
real 0m0.304sAll 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
ARGfor secrets. They stay indocker image historyin the clear. For build-time credentials there are BuildKit secrets (lesson 05-05); for runtime credentials,-eand environment files (lesson 04-05). - Expecting a global
ARGto be available inside the stage.ARGs before the firstFROMare only visible to theFROMs. They have to be declared again, with no value, inside the stage. ENTRYPOINTin shell form. It breaks two things: PID 1 becomessh(a 10 s stop) and thedocker runarguments and theCMDare 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 beexec "$@", with quotes. USERtoo far up. If you switch user beforenpm ci, the step fails withEACCES. Install as root, copy with--chownand putUSERright before theCMD.RUN chown -Rafter copying. It doubles that layer's size because of copy-on-write. UseCOPY --chown.- An unprivileged user listening on port 80.
EACCES: permission denied. Listen on a high port and publish with-p 80:3000. HEALTHCHECKwithout--start-period. The container is markedunhealthyduring its legitimate startup and the orchestrator enters a restart loop.- A healthcheck that queries external dependencies and does not distinguish. If
/healthreturns 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. VOLUMEin 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. EachLABELis a metadata layer. - Tip: test the healthcheck command by hand with
docker execbefore 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:
- Takes an
ARG BUILD_ENVIRONMENTwith the default valuelocal. - Defines an
ENV RUN_ENVIRONMENTfrom thatARG. - Prints both values during the build with a
RUN. - Prints both when the container runs.
Then:
- Build it without
--build-argand run it. What does each variable print? - Build it with
--build-arg BUILD_ENVIRONMENT=productionand run it. What changes? - Run the image with
-e RUN_ENVIRONMENT=overridden. Can you do the same with theARG? - Check with
docker image historywhether theARG'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:
- Fill in the table with what you observed and compare it with the theoretical one.
- Explain in one sentence the rule that governs all four rows.
- Use
--entrypointto runls /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:
- Explain why that is problematic in an orchestrator that restarts unhealthy containers.
- Propose two different endpoints and what each one should check.
- Write the
HEALTHCHECKyou would use in the Dockerfile and justify the choice. - 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:defaultWith --build-arg:
docker build --no-cache --progress=plain --build-arg BUILD_ENVIRONMENT=production -t ex:prod . 2>&1 | grep "\[build\]"
docker run --rm ex:prodThe analysis:
- At build time, both exist. The
ENVtakes theARG's value: that is the bridge pattern. - At runtime, the
ARGis always empty. It never reached the container. - The
ENVkeeps the value frozen at build time.
Overriding at runtime:
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:
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=localThe 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 bye1. 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:
--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:
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 1The 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}}"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}}"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.
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 starting → unhealthy 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
- 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
