A container that runs locally is not a container ready for production. The difference isn't whether it "works" — it's the size of the image, the attack surface, the build time, and whether secrets are sitting where they shouldn't be.
These are the most common problems and how to fix them.
Use lightweight base images
The base image sets the starting point for everything you build on top of it. A full Ubuntu or Debian image ships hundreds of packages your application will never use — and each one is potential attack surface on top of unnecessary weight.
The Alpine variants solve this. node:20-alpine instead of node:20 can be the difference between a 1GB and a 170MB final image.
# Avoid
FROM node:20
# Prefer
FROM node:20-alpine
If your application compiles static binaries (Go, Rust), you can go further still with FROM scratch — a completely empty image where only your binary exists.
Fewer layers, better cache
Every RUN, COPY or ADD instruction creates a new layer in the image. More layers mean more overhead and worse use of Docker's cache.
# Bad — three layers for three related commands
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good — one layer, same result
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
Order matters for the cache too: instructions that rarely change go on top, the ones that change often go at the bottom. Docker invalidates the cache from the first changed instruction onward — if you copy the source code before installing dependencies, any code change invalidates the dependency cache.
# Good — dependencies cached separately from the code
COPY package*.json ./
RUN npm ci
COPY . .
Multi-stage builds
The compiler, the build tools, and the development dependencies have no business being in the image that runs in production. Multi-stage builds separate the build environment from the runtime environment.
# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o servidor .
# Stage 2: final image
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/servidor .
CMD ["./servidor"]
The final image contains only the compiled binary and Alpine. The entire Go toolchain stays out. The result can be ten times lighter than an image that builds and runs in the same stage.
The same pattern applies to Node.js: one stage installs all the dependencies and builds, the final stage copies only the compiled files and the production dependencies.
Secrets don't belong in the Dockerfile
A secret in an ARG or ENV of the Dockerfile stays in the image history. Even if you overwrite it in a later layer, it's there — anyone with access to the image can recover it with docker history.
# Never do this
ENV DATABASE_PASSWORD=mipassword123
ARG API_KEY=abc123
Secrets get injected at runtime, not at build time. Docker supports this natively:
# Runtime injection
docker run -e DATABASE_URL=$DATABASE_URL mi-imagen
# Or with an environment variable file
docker run --env-file .env mi-imagen
For secrets needed during builds (tokens for private registries, for example), Docker BuildKit supports --secret, which mounts the secret without leaving it in the image history.
Don't run as root
By default, processes inside a container run as root. If a vulnerability lets someone escape the container, the attacker comes out with root permissions on the host.
Creating an unprivileged user is one line of Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["node", "server.js"]
node:alpine already ships an unprivileged node user. On other base images you can create one explicitly:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Always use .dockerignore
The build context is everything Docker sends to the daemon to build the image. Without a .dockerignore, that includes node_modules, .git, .env files, logs — everything.
# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
An unnecessarily large build context slows down every build and can leak sensitive information into the image if there's a generic COPY . ..
Monitor what it consumes
docker stats gives you a real-time view of the CPU, memory and network usage of every running container:
docker stats
# CONTAINER ID CPU % MEM USAGE / LIMIT MEM % NET I/O
# a1b2c3d4e5f6 0.5% 128MiB / 2GiB 6.25% 1.2MB / 800kB
If a container that should use 100MB is using 1GB, there's a problem — and docker stats is the fastest way to see it. For more complex environments, Prometheus with cAdvisor gives you historical metrics and alerts.
The Dockerfile as an architecture document
A well-written Dockerfile communicates decisions: which base image and why, which user runs the process, what doesn't belong in the final image. The same principles that keep application code maintainable apply here.
A container someone can understand, audit and modify without fear is worth as much as one that simply works.