Docker: images, containers, and Compose
Learn how a Dockerfile becomes layered image content and how Compose runs connected, bounded containers.
Timeline
Understanding: Docker: images, containers, and Compose
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
A Docker image resembles a sealed apartment blueprint: files and a startup command are defined in advance. A container is one occupied apartment created from that blueprint. The same image can run many times with different ports and configuration.
Docker builds an image from Dockerfile layers and starts an isolated process group from it. The container uses the host kernel; namespaces isolate process, network, and filesystem views while cgroups account for and limit resources. Compose describes multiple related containers, networks, volumes, and runtime settings.
Terms used in this experiment
Understand the words first, then the execution order.
Image
An immutable template made of read-only layers and metadata such as its filesystem, default command, and environment.
Container
A running image instance with processes, a writable layer, a network namespace, and runtime configuration.
Dockerfile
A text recipe whose instructions define build stages, filesystem layers, and image metadata.
Build context
The files available to COPY or ADD; .dockerignore excludes unnecessary or sensitive files before context transfer.
Layer
A reusable filesystem or metadata change whose build cache depends on the instruction and its inputs.
Registry
Storage from which versioned images are pushed and pulled by repository, tag, or digest.
Volume
Data with a lifecycle outside a container writable layer, used for persistent state.
PID 1
The first process inside a container, responsible for receiving termination signals and reaping finished children.
What happens step by step
Each step maps to an observable runtime state.
- 01Prepare the build context
The client reads a directory while .dockerignore excludes node_modules, Git data, build output, and secrets.
- 02Execute the Dockerfile
The builder resolves base images and evaluates each required build stage.
- 03Reuse cache
An unchanged instruction with unchanged inputs can reuse a layer, so dependency manifests are copied before source.
- 04Produce the runtime image
Multi-stage COPY transfers only the standalone artifact without the compiler, caches, or development tools.
- 05Create a container
The runtime adds a writable layer, environment, limits, mounts, and a network namespace over the image.
- 06Start the main process
CMD or ENTRYPOINT selects the process, USER reduces privilege, and init helps signal forwarding.
- 07Connect services
Compose supplies a network and service DNS names, so the app connects to postgres rather than localhost.
- 08Observe lifecycle
Health status, restart policy, logs, and graceful shutdown expose process readiness and termination.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
An image is not a virtual machine
A container does not boot another kernel, so image platform and host architecture must be compatible.
EXPOSE does not publish a port
EXPOSE documents a container port; docker run -p or Compose ports creates a host mapping.
localhost is local to the current container
From the app container, localhost is not the Postgres container; use the postgres service name.
ENV becomes runtime metadata
Do not preserve build secrets through ARG, ENV, or COPY because image history and layers can expose them.
depends_on is not a migration system
A healthy dependency is only a probe result; connection retries and controlled schema migrations remain necessary.
Container filesystems are usually disposable
A writable layer disappears with the container; database state belongs in a volume or managed database.
A tag can move
latest and mutable version tags may resolve to different content; a digest identifies one exact image.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=build /app/.next/standalone ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
export const dockerfileExample = `# syntax=docker/dockerfile:1
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM dependencies AS build
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --chown=node:node --from=build /app/.next/standalone ./
COPY --chown=node:node --from=build /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => { if (!r.ok) process.exit(1) })"
CMD ["node", "server.js"]`;
export const composeExample = `services:
app:
build:
context: .
target: runtime
init: true
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://app:password@postgres:5432/app
depends_on:
postgres:
condition: service_healthy
mem_limit: 2g
pids_limit: 128
restart: unless-stopped
postgres:
image: postgres:18-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 12`;
export const kubernetesManifestExample = `apiVersion: apps/v1
kind: Deployment
metadata:
name: node-loop-lab
spec:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: node-loop-lab
template:
metadata:
labels:
app: node-loop-lab
spec:
containers:
- name: app
image: ghcr.io/example/node-loop-lab:1.0.0
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: node-loop-lab
spec:
selector:
app: node-loop-lab
ports:
- name: http
port: 80
targetPort: http`;
function dockerStages(source) {
const stages = [];
for (const line of source.split('\n')) {
const match = line.match(/^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
if (match) {
stages.push({
image: match[1],
name: match[2] ?? `stage-${stages.length + 1}`,
});
}
}
return stages;
}
export async function dockerBuildAndRun(emit) {
const stages = dockerStages(dockerfileExample);
emit(
'build-context',
'context',
'Docker client собирает build context; .dockerignore исключает node_modules, .git и секреты',
);
await pause(15);
emit(
'dockerfile',
'parse',
`Dockerfile описывает ${stages.length} стадии: ${stages.map((stage) => stage.name).join(' → ')}`,
);
for (const stage of stages) {
await pause(15);
emit(
'buildkit',
'stage',
`BuildKit строит stage ${stage.name} из immutable base image ${stage.image}`,
);
}
emit(
'cache',
'layer',
'COPY package*.json расположен до COPY исходников: изменение кода не инвалидирует слой npm ci',
);
await pause(15);
emit(
'image',
'artifact',
'Runtime image получает standalone build, но не исходный build toolchain',
);
await pause(15);
emit(
'container',
'process',
'Container запускает node server.js как USER node; init передаёт сигналы и убирает zombie processes',
);
await pause(15);
emit(
'network',
'publish',
'Port mapping 127.0.0.1:3000:3000 публикует container port только на loopback хоста',
);
await pause(15);
emit(
'health',
'probe',
'Healthcheck проверяет /api/health; healthy не означает, что все внешние зависимости доступны',
);
emit(
'result',
'summary',
'Image — неизменяемый шаблон; container — запущенный process с writable layer и runtime configuration',
);
}
function readyPods(pods) {
return pods.filter((pod) => pod.ready);
}
export async function kubernetesReconciliation(emit) {
const desiredReplicas = 3;
let generation = 1;
let pods = [
{ name: 'node-loop-lab-old-1', version: '1.0.0', ready: true },
];
emit(
'api-server',
'desired-state',
`Deployment принят: desired replicas=${desiredReplicas}, image=1.0.0`,
);
await pause(15);
while (pods.length < desiredReplicas) {
const pod = {
name: `node-loop-lab-old-${pods.length + 1}`,
version: '1.0.0',
ready: false,
};
pods.push(pod);
emit(
'deployment-controller',
'reconcile',
`Actual=${pods.length - 1}, desired=${desiredReplicas}: ReplicaSet создаёт ${pod.name}`,
);
await pause(15);
pod.ready = true;
emit(
'kubelet',
'readiness',
`${pod.name} прошёл readinessProbe и добавлен в endpoints Service`,
);
}
emit(
'service',
'routing',
`Service выбирает по label ${readyPods(pods).length} ready Pods из ${pods.length}`,
);
await pause(15);
pods[1].ready = false;
emit(
'readiness',
'traffic',
`${pods[1].name} стал NotReady: container продолжает работать, но Service исключил его из трафика`,
);
await pause(15);
pods[1].ready = true;
generation += 1;
const newPod = {
name: `node-loop-lab-new-${generation}`,
version: '1.1.0',
ready: false,
};
pods.push(newPod);
emit(
'rolling-update',
'surge',
`maxSurge=1: создан ${newPod.name}, старые ready Pods пока обслуживают трафик`,
);
await pause(15);
newPod.ready = true;
pods = pods.filter((pod) => pod.name !== 'node-loop-lab-old-1');
emit(
'rolling-update',
'replace',
'Новый Pod стал Ready; controller удалил один старый Pod без снижения ready replicas',
);
emit(
'scheduler',
'resources',
'Scheduler размещает Pod по requests; limits ограничивают runtime, но не резервируют дополнительный ресурс',
);
emit(
'result',
'summary',
'Kubernetes непрерывно сравнивает desired и actual state; controller исправляет расхождение, а Service маршрутизирует только Ready Pods',
);
}
The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
Build and run an image
Create a local image and a container with a published HTTP port.
docker build -t node-loop-lab:local .
docker run --rm --init \
-p 127.0.0.1:3000:3000 \
--memory=2g --pids-limit=128 \
node-loop-lab:local- -t assigns a local repository and tag.
- --rm removes the stopped container, not the image.
- --init adds a minimal init process as PID 1.
COPY order for cache
Avoid reinstalling dependencies after every source edit.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build- npm ci reproduces the lockfile without changing it.
- A source change invalidates only the later layers.
Multi-stage runtime
Keep the build toolchain out of the production image.
FROM node:24-alpine AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=build /app/.next/standalone ./
CMD ["node", "server.js"]- The final image begins at a new FROM.
- Only a selected artifact crosses from the build stage.
Compose service DNS
Connect the app and PostgreSQL inside a Compose network.
services:
app:
environment:
DATABASE_URL: postgresql://app:secret@postgres:5432/app
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:18-alpine- postgres is the hostname derived from the service name.
- A real deployment must not commit the password in the Compose file.
Inspect runtime state
Distinguish an image, a running container, and its logs.
docker image ls
docker compose ps
docker compose logs -f node-loop-lab
docker inspect node-loop-lab- ps exposes status and published ports.
- logs -f follows stdout and stderr.
- inspect returns low-level JSON configuration.
Graceful Node shutdown
Drain traffic and close resources before forced termination.
process.once('SIGTERM', async () => {
server.close();
await databasePool.end();
process.exitCode = 0;
});- docker stop sends SIGTERM before SIGKILL.
- The process must finish active requests within its grace period.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
The production image contains dev dependencies, a secret, and a root process
A team builds its Nest/Next application in one stage, copies the entire working directory, and passes a registry token through ENV. The same large image runs in production as root.
Any build-context file can enter a layer, the secret remains in image metadata or history, and compilers plus dev dependencies increase size and attack surface. A compromised root process has needless privilege.
FROM node:24
WORKDIR /app
COPY . .
ENV NPM_TOKEN=production-secret
RUN npm install
EXPOSE 3000
CMD npm run startOne stage mixes supply, build, and runtime concerns. COPY depends on the entire context, shell-form CMD adds an intermediate process, and the container uses the default root user.
# syntax=docker/dockerfile:1
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
FROM dependencies AS build
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node --from=build \
/app/.next/standalone ./
COPY --chown=node:node --from=build \
/app/.next/static ./.next/static
USER node
CMD ["node", "server.js"]The secret mount exists only during RUN, the lockfile gives reproducible installation, multi-stage COPY moves a minimal artifact, and exec-form CMD starts Node directly under an unprivileged user.
What the unfamiliar calls from both code samples actually do.
FROM ... AS stage- Starts a named build stage. The final image contains only the final ancestry and artifacts copied explicitly from other stages.
RUN --mount=type=secret- BuildKit temporarily mounts a secret for one RUN instruction without preserving it as ENV or a regular filesystem layer.
npm ci- Installs the exact package-lock.json tree and fails when package manifests are out of synchronization.
COPY --from=build- Transfers selected files from another stage instead of including the entire build environment in the runtime image.
COPY --chown=node:node- Assigns ownership while copying so the unprivileged runtime user can read the necessary artifacts.
CMD ["node", "server.js"]- Exec form starts Node without a shell wrapper, allowing signals to reach the application process directly.
Common misconceptions
The myth is on the left; the accurate model is on the right.
A container contains a separate operating system.
It contains user-space files but shares the host kernel.
COPY . . copies only the application.
Without .dockerignore, secrets, Git data, local dependencies, and large junk can enter the context.
A successfully built image is production-ready.
Production also needs a non-root user, lean runtime, signals, health, limits, logs, and base-image updates.
PostgreSQL data can live in the container.
Containers are replaceable; persistent state needs a volume or external storage.
HEALTHCHECK guarantees product availability.
It proves only the selected signal, which may be too weak or coupled to too many dependencies.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- How does an image differ from a container?
- Why does a source edit not have to rerun npm ci?
- Why does EXPOSE 3000 not open a host port?
- Why does DATABASE_URL use postgres rather than localhost?
- Which files belong in .dockerignore?
- Why use a production stage and USER node?
- What happens to writable-layer data when a container is removed?