Docker
Docker images, containers, and Compose
How images, containers, and Compose fit together: a Dockerfile, ports, volumes, health checks, and the mistakes that drop data or hide the service.
9 min read
An image, a container, and a Compose file are three different objects. Most Docker confusion comes from treating them as one. An image is a stack of read-only filesystem layers, plus the command to run, the user, and any environment variables baked in at build time. A container is a process started from that image, with a thin writable layer on top. Compose is a file that starts several containers, joins them to a private network, and names the volumes that should outlive them.
This note builds a small Node API beside PostgreSQL 16, then stops on the places the names lie: a port that looks published but is not, data that vanishes on the next up, and a database password that does not change when the file does. The commands assume Docker Engine and the Compose plugin (docker compose, with a space). The older docker-compose program is obsolete. Paths match the official node and postgres images.
The image and the writable layer
docker build reads a Dockerfile and a context directory, usually ., and produces an image. Each RUN, COPY, and ADD adds a layer. Layers are reused, so a later build that only changes server.js does not have to run npm ci again.
The image is not a process. docker image ls lists images. docker run adds a writable layer, a network namespace, and the CMD process, unless you replaced the command. Files written there survive docker stop and docker start. They disappear with docker rm, docker run --rm, or a Compose recreate. A database kept only in that layer is one you have agreed to delete. docker image rm will also refuse while any container, including a stopped one, still references the image.
A Dockerfile that caches
server.js listens on every IPv4 address in the container. Binding only 127.0.0.1 is a reliable way to make a published port look dead. Docker delivers published traffic to the container's network interface, not to its loopback, so a process bound to loopback never sees the request.
const http = require("node:http");
const port = Number(process.env.PORT ?? 3000);
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end("ok\n");
});
server.listen(port, "0.0.0.0");Nothing beyond Node is required yet. Copy the lockfile and run npm ci before the source anyway, so a later dependency stays cached when only server.js changes. npm ci refuses a missing or mismatched package-lock.json. Generate that file on the host with npm install, commit it, and do not resolve versions inside the image.
{
"name": "shop-api",
"private": true,
"version": "1.2.0"
}FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "server.js"]WORKDIR /app creates the directory and is the working directory below it. ENV is part of the image config, visible in docker image inspect. NODE_ENV belongs there. A password does not. A secret in an early layer survives a later RUN rm. If an install needs a token, a BuildKit secret mount (--mount=type=secret) exposes it to that one RUN and does not commit it.
USER node applies to later RUN, CMD, and ENTRYPOINT lines. The official image defines node as uid 1000. Skip USER and the process is root: less powerful than root on the host, because of namespaces and dropped capabilities, and still the wrong account to expose. COPY stays root, so /app is root-owned. node can read it and cannot rewrite it. chown a writable directory before USER, or mount a volume there. Going back to root to clear EACCES undoes the line.
EXPOSE 3000 is metadata. It does not publish a host port and it does not touch a firewall. Containers on the same user-defined network can connect to port 3000. A browser cannot, until -p or Compose ports.
CMD ["node", "server.js"] is the exec form, so node is PID 1. docker stop sends SIGTERM, waits ten seconds, then SIGKILL. Node handles SIGTERM and exits. CMD node server.js is /bin/sh -c "node server.js". The shell is PID 1, it does not forward SIGTERM, and an unhandled signal does not kill PID 1, so the stop burns the whole timeout. Arguments on docker run shop-api:1.2.0 node --version replace CMD, not an ENTRYPOINT. This image has no entrypoint, so that command replaces the server.
COPY does what it says. ADD also unpacks a local tar and can fetch a URL during the build.
The client uploads the whole context before the first instruction. Stray node_modules or .git makes that upload slow even when nothing copies them. .dockerignore at the context root leaves them out. A file next to a Dockerfile in a subdirectory does nothing when the context is ..
node_modules
.git
.env
*.logKeep package-lock.json. Build with a version tag, not with an implicit name:
docker build -t shop-api:1.2.0 .Omitting the version stores the tag latest. That word means "the tag I last pointed at this name", not "the newest build", and not "the same bytes on every machine". Check the architecture before you copy an image to a server:
docker image inspect shop-api:1.2.0 --format '{{.Architecture}}'A build on an Apple Silicon laptop is arm64 unless you say otherwise. An amd64 server rejects it with an exec format error. docker build --platform linux/amd64 -t shop-api:1.2.0 . produces the image that server can run. Emulation makes the build slower. It does not make the result optional.
One container, from the host
docker run --rm --name shop-api -p 127.0.0.1:3000:3000 shop-api:1.2.0-p 127.0.0.1:3000:3000 is host address, host port, container port. Curl the host port. server.js binds the container port, and 8080:3000 is the usual fix when the host port is taken. Omit the address and the port is on 0.0.0.0, every interface. --rm deletes the container on exit, which is right for a trial and wrong if you still need a file from inside it.
-d detaches. Logs are docker logs -f shop-api, and docker stop shop-api is the graceful stop. "Address already in use" fails immediately. A hung curl is the app bound to loopback, the wrong host port, or a curl from another container.
-e PORT=3000 and --env-file set variables for this process. --env-file injects KEY=value lines. It is not the .env file Compose reads for ${VAR} substitution.
Compose for the API and a database
Compose reads compose.yaml (docker-compose.yml still works) and creates a project. The project name defaults to the directory name. Containers, the default network, and named volumes are prefixed with it, so two checkouts both try to bind host port 3000 and the second up fails. Set name when the directory is a bad project name.
name: shop
services:
api:
build: .
image: shop-api:1.2.0
ports:
- "127.0.0.1:3000:3000"
environment:
PORT: "3000"
DATABASE_URL: postgres://shop:shop@db:5432/shop
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: shop
POSTGRES_PASSWORD: shop
POSTGRES_DB: shop
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "shop", "-d", "shop"]
interval: 5s
timeout: 5s
retries: 12
restart: unless-stopped
volumes:
db-data:shop is a laptop password, not one for a machine other people can reach, and a real secret does not belong in git. Port 5432 is unpublished, so the host cannot open Postgres. The API can: the service name db is DNS on the Compose network, and 5432 in the URL is that network port. Containers do not need a published host port to reach each other.
localhost inside the API is the API. postgres://shop:shop@localhost:5432/shop fails while db is healthy. From the host the API is http://127.0.0.1:3000. From another container it is http://api:3000. Docker Desktop resolves host.docker.internal to the laptop. Engine on Linux does not, unless you set extra_hosts: ["host.docker.internal:host-gateway"]. These two services should use db and api.
depends_on with condition: service_healthy waits for the check. Without the condition it waits only until the process has started, which is before Postgres accepts sessions. pg_isready is in the image. Healthy does not mean migrated: a missing table still crashes the API, and Compose does not retry that startup for you.
restart: unless-stopped returns after a crash and after a daemon reboot, and stays down if you stopped it. always comes back on reboot anyway.
docker compose up --build builds, creates the volume and network, and follows logs. Without --build, an existing shop-api:1.2.0 is reused after the Dockerfile changes. -d detaches. docker compose ps shows health, docker compose logs -f api follows one service, and docker compose exec db psql -U shop -d shop -c '\conninfo' uses the container already running. docker compose run starts a second container. It is not a shell on the process bound to port 3000.
docker compose config prints the interpolated file. An empty password is obvious there, before Postgres rejects it.
Two different environment files
.env beside compose.yaml replaces ${VAR} in the YAML. It is not the container environment. A database service that never mentions POSTGRES_PASSWORD will not see one that happens to sit in .env, and the official image will exit because that variable is required. Pass it through:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}The :? form makes Compose stop with your message when the variable is unset. env_file: .env is the other switch: it does inject the file, every key of it, into the process. Split the interpolation file from the runtime file when they are not the same list.
POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB are applied only when the data directory is empty. Changing the password in YAML later does not run ALTER ROLE. The API and psql then disagree, against a file that looks correct. Rotate in SQL, or delete the volume and accept an empty database.
Volumes, and which data is safe
On PostgreSQL 16 and older, data lives at /var/lib/postgresql/data. db-data is a named volume, created as shop_db-data because the project is shop. Mounting the parent /var/lib/postgresql does not replace that. The 16 image declares its volume at .../data. If that path is not mounted, the engine inserts an anonymous volume there, then throws it away on recreate. docker volume ls still shows your named volume. The server never wrote to it.
PostgreSQL 18 stores data in /var/lib/postgresql/18/docker and declares the volume at /var/lib/postgresql. Do not retag a 16 directory onto 18. Change majors with pg_dump and restore, or pg_upgrade. These examples stay on postgres:16.
docker compose down removes containers and the project network and keeps named volumes, so the next up still has the rows. docker compose down -v deletes the volumes. That deletes the database.
A bind mount is a host path, such as ./notes:/app/notes. Covering the whole application is a different result:
volumes:
- ./:/appThat hides the image's /app, including node_modules from npm ci. The process sees the host tree instead, which may have no modules, or modules for another operating system. Mount the file you edit. Leave the installed tree alone. New files in a bind mount are owned by uid 1000 (node), which often matches a laptop account and does not match a shared builder.
Unmounted writes stay in the writable layer: /tmp survives docker restart and goes away when the container is recreated. The json-file log driver grows without a cap. Limit a busy service:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"docker compose logs still works. It has less history, and the disk survives.
Pitfalls
EXPOSEdoes not publish.portsand-pdo, and127.0.0.1:on the host side keeps the port off the LAN.- Listen on
0.0.0.0in the container. Loopback never sees a published connection. localhostis the container itself. Another service is its Compose name.depends_onwithout a health check does not wait for Postgres. A passing check does not load your schema..envinterpolates${VAR}only.env_fileinjects every key into the process.POSTGRES_PASSWORDis used on an empty data directory. Editing it later does not rotate the role.- PostgreSQL 16 needs a mount at
/var/lib/postgresql/data. Mounting only the parent leaves an anonymous volume on the data path. PostgreSQL 18 uses/var/lib/postgresqland stores files in/var/lib/postgresql/18/docker. Do not treat a retag as an upgrade. docker compose downkeeps named volumes.down -vdeletes them.- A bind mount of the repo onto
/apphidesnode_modules. upwithout--buildreuses the existing tag.latestis not a version.arm64does not run onamd64. The message is an exec format error.- Shell-form
CMDmakesdocker stopwait ten seconds. Use the JSON form. ENVandCOPYkeep a secret in an earlier layer afterrm.5432published on0.0.0.0with passwordshopis a database on the network.
docker compose config, then ps, then logs, show what is running. The Dockerfile can rebuild the image. It cannot rebuild the volume.