Common Docker Mistakes
Docker mistakes usually come from misunderstanding what Docker actually stores and runs. Images are layered templates, containers are disposable runtime instances, and configuration such as ports, users, data, and secrets must be handled intentionally. This lesson shows the mistakes that most often cause slow builds, surprising production changes, leaked credentials, lost data, and insecure containers.
Overview: How it works
The most important Docker distinction is image versus container. An image is a read-only stack of layers plus metadata such as CMD, ENV, USER, and exposed ports. A container is an instance of that image with a thin writable layer and, when running, a process. Deleting a container removes that writable layer unless data was stored in a volume or bind mount. Deleting an image is separate, and Docker will not remove an image that is still referenced by an existing container.
Docker builds also depend on layers. During docker build or docker buildx build, each Dockerfile instruction is evaluated in order. If Docker can prove a layer and its inputs have not changed, it reuses the cached result. If an early layer changes, every later layer must be rebuilt. Many slow Docker projects are slow because the Dockerfile copies the whole source tree before installing dependencies, causing normal code edits to invalidate expensive package installation layers.
Networking has a similar gotcha. EXPOSE in a Dockerfile is metadata and documentation only. It does not publish a port to your laptop or server. Publishing happens at runtime with docker run -p host_port:container_port or Compose ports:. Inside a Compose project, services usually talk to each other by service name on the private project network, not by localhost.
Registries add one more source of trouble: tags are mutable names. The registry stores compressed layer blobs and a manifest describing which layers and config make up an image. A tag such as latest or main can be moved to another manifest. A digest such as sha256:... identifies exact image content. Production systems should record and deploy the digest of the image that passed CI.
Syntax
docker build -t IMAGE:TAG PATH
docker run [OPTIONS] IMAGE[:TAG]
docker compose -f FILE up [OPTIONS]
docker volume create NAME
docker image inspect IMAGE[:TAG]
| Form | What people often misunderstand | Safer habit |
|---|---|---|
docker build -t app:latest . |
latest is not special proof of freshness or safety. |
Use version, commit, or build-number tags and record the digest. |
FROM node:latest |
A rebuild can silently use a different base image. | Pin a specific tag such as node:20-alpine. |
EXPOSE 3000 |
It does not publish port 3000 to the host. |
Use -p 8080:3000 or Compose ports:. |
COPY . . |
It may copy secrets, dependencies, logs, and huge files into the build context. | Use a focused .dockerignore and copy dependency manifests first. |
ENV PASSWORD=... |
Image metadata and layers are not a secret store. | Inject secrets at runtime or with BuildKit secret mounts for build-only needs. |
docker run postgres |
Database files inside the container layer disappear when the container is removed. | Use a named volume for persistent data. |
Examples
Example 1: Fix a slow Node build cache
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Output:
#1 [internal] load build definition from Dockerfile
#2 [2/6] WORKDIR /app
#3 [3/6] COPY package.json package-lock.json ./
#4 [4/6] RUN npm ci --omit=dev
#5 [5/6] COPY . .
#6 exporting to image
#6 naming to docker.io/library/web-api:1.0 done
This Dockerfile avoids the common cache mistake by copying package manifests before application source. When server.js changes but package-lock.json does not, Docker can reuse the dependency layer. It also uses a pinned base image tag instead of latest, runs as the built-in node user, and documents port 3000. Remember that EXPOSE still does not publish the port.
Example 2: Publish the port explicitly
docker build -t web-api:1.0 .
docker run --rm --name web-api -p 8080:3000 web-api:1.0
Output:
Server listening on 0.0.0.0:3000
Open http://localhost:8080 on the host
The image listens on container port 3000, while the host receives traffic on port 8080. The mapping is always host:container. A frequent beginner mistake is opening localhost:3000 on the host after only writing EXPOSE 3000 in the Dockerfile.
Example 3: Persist database data with a named volume
docker volume create app-postgres-data
docker run --rm --name app-db -e "POSTGRES_PASSWORD=changeme" -v app-postgres-data:/var/lib/postgresql/data postgres:16-alpine
Output:
app-postgres-data
PostgreSQL init process complete; ready for start up.
The named volume is managed by Docker and mounted at PostgreSQL’s data directory. If the container is stopped and removed, the volume still exists. This is different from storing data in the container’s writable layer, which is tied to that one container and is easy to delete accidentally.
Example 4: Use Compose service names, not localhost
services:
api:
image: web-api:1.0
environment:
DATABASE_URL: postgres://app:changeme@db:5432/app
ports:
- "8080:3000"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: changeme
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Output:
[+] Running 3/3
✔ Network mistakes_default Created
✔ Container mistakes-db-1 Started
✔ Container mistakes-api-1 Started
Inside this Compose project, the API reaches PostgreSQL at hostname db because Compose provides DNS for service names on the project network. If the API used localhost, it would point back to the API container itself, not the database container.
How it works step by step
- The Docker client sends the build context to the builder. Files excluded by
.dockerignoreare not sent and cannot be copied by the Dockerfile. - The builder resolves the
FROMimage and pulls missing layers. A pinned tag makes this more predictable than a floatinglatesttag. - Docker evaluates instructions in order. A changed instruction or changed copied file invalidates that layer and all later layers.
- The final image records filesystem layers and metadata. Metadata such as
EXPOSEis useful, but it does not open a host port. - When a container starts, Docker mounts read-only image layers plus a writable layer. Named volumes and bind mounts are mounted separately over paths inside the container.
- In Compose, services join a private network. Service names become DNS names, so containers use
db,redis, orapiinstead of host-oriented addresses. - When an image is pushed, Docker uploads missing layer blobs and writes a manifest. A tag points to that manifest, while a digest identifies it exactly.
Common Mistakes
Using latest in production
FROM node:latest
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
This is wrong because latest is a moving target. Tomorrow it may contain a different Node version or operating system package set. Fix it by pinning a specific base tag and using explicit release tags for your own image.
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
Copying source before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
Any source edit changes the broad COPY . . layer, so npm ci runs again. Fix the order by copying package.json and the lockfile first, installing dependencies, and then copying the rest of the app.
Assuming EXPOSE publishes a port
FROM nginx:1.27-alpine
EXPOSE 80
This only records metadata. The container port is published to the host only when you run docker run -p 8080:80 nginx:1.27-alpine or define Compose ports:.
Putting secrets in image layers
FROM alpine:3.20
COPY production.env /app/production.env
RUN rm /app/production.env
Deleting the file later does not remove it from the earlier image layer. Avoid COPY, ENV, and ARG for real secrets. Use runtime secret injection, Docker secrets, orchestrator secret stores, or BuildKit secret mounts for build-only credentials.
Losing data with disposable containers
docker run --rm --name temporary-db -e "POSTGRES_PASSWORD=changeme" postgres:16-alpine
This starts PostgreSQL without a persistent mount. When the container is removed, data in its writable layer is gone. Use a named volume for database files unless you intentionally want disposable test data.
Best Practices
- Pin base image tags and avoid bare image names or
latestin production Dockerfiles. - Use immutable application tags such as commit SHAs, versions, or build numbers, and record registry digests for deployments.
- Copy dependency manifests before broad source copies to preserve the build cache.
- Add a
.dockerignorethat excludes.git, local dependencies, logs, coverage reports, build output, and env files. - Keep secrets out of image layers, build arguments, Dockerfile
ENV, and command history. - Run the final container as a non-root user whenever the application does not need root.
- Publish ports explicitly with
-por Composeports:; treatEXPOSEas documentation. - Use named volumes for persistent application and database data. Use bind mounts mainly for local development source code.
- Use Compose service names for container-to-container networking instead of
localhost. - Clean up stopped containers, unused images, and old volumes deliberately, but inspect before pruning shared development machines.
Practice Exercises
- You have a Node Dockerfile that starts with
FROM node:latestand runsCOPY . .beforenpm ci. Rewrite it so the base image is pinned and dependency caching survives source edits. Hint: copy lockfiles first. - A PostgreSQL container was started for a small internal app without
-v. Design the replacementdocker runcommand so data survives container replacement. Expected end state: a named volume is mounted at/var/lib/postgresql/data. - A Compose API service tries to connect to
localhost:5432even though PostgreSQL is another service nameddb. Change the connection string and explain why the old address was wrong.
Summary
- Most Docker mistakes come from confusing images, containers, layers, metadata, and runtime configuration.
latestand other mutable tags are convenient names, not reliable release identifiers.- Docker build cache works best when slow dependency layers come before frequently changing source layers.
EXPOSEdocuments a port;-pand Composeports:publish it.- Persistent data belongs in volumes or deliberate bind mounts, not a container’s writable layer.
- Secrets should be injected at runtime or through secret-specific build features, never baked into image layers.
