KeyboardWarrior.co.ukclient-side utility arcade

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.

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

JSON
{
  "name": "shop-api",
  "private": true,
  "version": "1.2.0"
}
Dockerfile
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 ..

text
node_modules
.git
.env
*.log

Keep package-lock.json. Build with a version tag, not with an implicit name:

shell
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:

shell
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

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

YAML
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:

YAML
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:

YAML
volumes:
  - ./:/app

That 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:

YAML
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

  • EXPOSE does not publish. ports and -p do, and 127.0.0.1: on the host side keeps the port off the LAN.
  • Listen on 0.0.0.0 in the container. Loopback never sees a published connection.
  • localhost is the container itself. Another service is its Compose name.
  • depends_on without a health check does not wait for Postgres. A passing check does not load your schema.
  • .env interpolates ${VAR} only. env_file injects every key into the process.
  • POSTGRES_PASSWORD is 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/postgresql and stores files in /var/lib/postgresql/18/docker. Do not treat a retag as an upgrade.
  • docker compose down keeps named volumes. down -v deletes them.
  • A bind mount of the repo onto /app hides node_modules.
  • up without --build reuses the existing tag. latest is not a version.
  • arm64 does not run on amd64. The message is an exec format error.
  • Shell-form CMD makes docker stop wait ten seconds. Use the JSON form.
  • ENV and COPY keep a secret in an earlier layer after rm.
  • 5432 published on 0.0.0.0 with password shop is 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.