One image, one process. Multi-service, shared networks and volumes: skill docker-compose. Detect the runtime (package.json, Gemfile, mix.exs, go.mod, Cargo.toml, pyproject.toml) and adapt — don't copy a Node Dockerfile into an Elixir app.
Non-negotiable rules
- Tags pinned (
node:22-alpine, digest when the risk requires it). Avoidlatestin production. - Multi-stage: build tools stay in the build stage; minimal runtime at the end.
.dockerignorebefore the firstCOPY . .— otherwisenode_modules,.gitand secrets enter the context.- Secrets only at runtime (
ENVinjected, files, orchestrator). NeverCOPY .envnorENV API_KEY=...in the Dockerfile. - Process as non-root USER.
- One concern per container (app or db or worker).
HEALTHCHECKin images that start a long-running service.
.dockerignore
.git
.gitignore
**/.env
**/.env.*
**/node_modules
**/dist
**/target
**/_build
**/deps
**/.venv
**/coverage
**/*.md
Dockerfile*
compose*.yml
compose*.yaml
Adjust to the stack. Don't ignore what the build needs (package-lock.json, mix.lock, Gemfile.lock).
Multi-stage (default)
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN useradd --system --uid 1001 --create-home app
COPY --from=build --chown=app:app /app/package.json ./
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]
Adapt:
| Stack | Build | Runtime |
|---|---|---|
| Node | npm ci / pnpm |
node in the artifact |
| Rails | bundle deploy, assets |
rails server / Puma |
| Phoenix | mix deps.get + assets + mix release |
OTP release |
| Go | CGO_ENABLED=0 go build |
scratch/distroless + binary |
| Rust | cargo build --release |
slim + binary |
WORKDIRexplicit;COPYfrom the most stable (lockfile) to the most volatile (source) — layer cache.RUNcombined sensibly (fewer layers), but readable.- Alpine: great if native deps compile; glibc (Debian slim) when
muslbreaks gems/N-API.
Security and runtime
USERnumeric or named after installing packages (apt needs root).- Don't install a compiler in the final stage.
--chownin theCOPY --fromfor the final user.- Filesystem: assume read-only root in orchestrators; write only to
/tmpor a volume. - Drop capabilities and read-only stay in compose/k8s — document if the image requires it.
EXPOSEis documentation; the real bind is in run/compose.
Tags, build, and CI
docker build -t myapp:$(git rev-parse --short HEAD) .
docker build --target build . # validar stage
- Immutable tag (git sha) +
myapp:prodmovable if the flow requires it. --platformexplicit in CI (linux/amd64) when the host is ARM.- BuildKit (
DOCKER_BUILDKIT=1/ syntax directive) for conscious cache mounts:
RUN --mount=type=cache,target=/root/.npm npm ci
Don't cache secrets.
Anti-patterns
FROM ubuntu:latest+apt-getof toolchain in the same stage that runs prodCOPY . .without.dockerignore- Root as PID 1 without tini/dumb-init when the app doesn't respond to SIGTERM
latestin production- Two processes (nginx + app) in the same container without a requested supervisor
- Healthcheck that pings the internet
- Committing
.dockerignorewhich ignores the lockfile
Integration with other skills
- Multiple services, DB, networks:
docker-compose - Go-live:
production-readiness - Rails/Phoenix/Nuxt: use the language skill and this one for the image
Completion criteria
- Multi-stage Dockerfile aligned with the detected runtime
.dockerignorecovering git, deps, and env- Non-root user; secrets outside the image
- Pinned tag; HEALTHCHECK if it is a service
- Image starts and responds locally (
docker build+docker run)