Skip to main content

Command Palette

Search for a command to run...

๐Ÿณ Mastering Dockerfile: Advanced Syntax, Base Images, Multi-Stage Builds & Production-Grade Optimizations

Published
โ€ข7 min read

Most tutorials teach Docker with:

FROM node
COPY . .
RUN npm install
CMD ["npm", "start"]

But real-world Dockerfiles used in production are far more nuanced.
This article goes deep into:

  • Advanced Dockerfile syntax (not just basics)

  • Environment-specific configs (dev, staging, prod)

  • Base image choices (Alpine vs Bullseye vs Distroless)

  • Multi-stage builds

  • Performance, security, and image-size optimizations

  • How professional DevOps engineers design Dockerfiles


1๏ธโƒฃ Understanding Dockerfile as a Build Recipe

A Dockerfile is not just a script; itโ€™s a declarative build recipe for constructing immutable, layered images.

Each line:

  • Creates a new layer

  • Is cached independently

  • Impacts final image size, security, and runtime behavior

Why Layers Matter (Real-World Example)

If you do:

COPY . .
RUN npm install

Every small code change invalidates the npm install layer โ†’ slow builds.

Correct layering:

COPY package*.json ./
RUN npm install
COPY . .

Now dependencies are cached unless package.json changes.


2๏ธโƒฃ Advanced Dockerfile Instructions (Deep Dive)

๐Ÿ”น FROM (Base Image Strategy)

FROM node:20-bullseye

This defines:

  • OS (Debian Bullseye)

  • Runtime (Node.js 20)

  • libc implementation (glibc)

Base ImageOSlibcSizeUse Case
node:bullseyeDebianglibcLargeNative deps, stability
node:alpineAlpinemuslSmallMicroservices, APIs
distrolessMinimalglibcTinySecurity-first prod
scratchNoneNoneTinyStatic Go/Rust binaries

๐Ÿง  Real-Life Choice

  • If your app uses native modules (better-sqlite3, sharp) โ†’ Bullseye

  • If you need smallest possible image โ†’ Alpine

  • If security is critical โ†’ Distroless


๐Ÿ”น WORKDIR (Context Isolation)

WORKDIR /app
  • Sets working directory for all future instructions

  • Prevents messy absolute paths

  • Creates directory if not exists

Best practice:

WORKDIR /usr/src/app

๐Ÿ”น ENV (Environment Configuration)

ENV NODE_ENV=production
ENV PORT=1337

This defines environment variables inside the image.

ENV Across Environments (Dev / Staging / Prod)

# default values
ENV NODE_ENV=production
ENV LOG_LEVEL=info

Override at runtime:

docker run -e NODE_ENV=staging -e LOG_LEVEL=debug my-app

Real-Life Example

ENV NODE_ENV=production

This:

  • Disables dev-only dependencies

  • Improves performance

  • Changes framework behavior (Node, Next.js, Strapi, etc.)


๐Ÿ”น ARG (Build-Time Variables)

ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}

Usage:

docker build --build-arg APP_VERSION=1.2.3 -t myapp .

Difference between ARG and ENV:

FeatureARGENV
Available at runtimeโŒ Noโœ… Yes
Used during buildโœ… YesโŒ No
SecretsโŒ UnsafeโŒ Unsafe

๐Ÿ”น RUN (Build-Time Commands)

RUN apt-get update && apt-get install -y curl

Best practice:

RUN apt-get update \
 && apt-get install -y curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

Why?

  • Reduces layer size

  • Removes cache


๐Ÿ”น COPY vs ADD

COPY . .

Use COPY for normal file transfer.
Avoid ADD unless you need:

  • Auto-extract tar files

  • Remote URL downloads (not recommended)

Professional rule:

Prefer COPY. ADD is rarely needed.


๐Ÿ”น USER (Security Hardening)

RUN useradd -m appuser
USER appuser

Never run production apps as root.


๐Ÿ”น HEALTHCHECK (Production Monitoring)

HEALTHCHECK --interval=30s --timeout=5s \
  CMD curl -f http://localhost:1337/health || exit 1

Used by:

  • Docker

  • Kubernetes

  • ECS


๐Ÿ”น ENTRYPOINT vs CMD

ENTRYPOINT ["node"]
CMD ["server.js"]
  • ENTRYPOINT = fixed executable

  • CMD = default arguments

Override CMD easily:

docker run myapp other.js

3๏ธโƒฃ Multi-Stage Builds (Professional Standard)

๐Ÿ”ฅ Why Multi-Stage Builds Exist

Build tools (compilers, npm, dev deps) are not needed at runtime.

Example (Node.js / Strapi / React)

FROM node:20-bullseye AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:20-bullseye
WORKDIR /app
COPY --from=builder /app .
ENV NODE_ENV=production
CMD ["npm", "run", "start"]

Benefits

  • Smaller images

  • Faster startup

  • No build tools in prod

  • Less attack surface

Real-Life Example

CI builds assets โ†’ prod image only contains runtime files.


4๏ธโƒฃ Environment-Aware Dockerfiles (Dev, Staging, Prod)

Dev Dockerfile

CMD ["npm", "run", "develop"]

Prod Dockerfile

ENV NODE_ENV=production
CMD ["npm", "run", "start"]

Staging

ENV NODE_ENV=staging
CMD ["npm", "run", "start"]

5๏ธโƒฃ Advanced Production Optimizations

๐Ÿ”น Use .dockerignore

node_modules
.git
.cache
dist
.env

๐Ÿ”น Reduce Image Size

RUN npm install --omit=dev

๐Ÿ”น Non-root user

USER node

๐Ÿ”น Immutable Config

Pass secrets via env:

docker run -e JWT_SECRET=xxx myapp

6๏ธโƒฃ How a Professional DevOps Engineer Thinks

Before writing Dockerfile:

  • What OS does my app depend on?

  • Are there native binaries?

  • Do I need dev tools at runtime?

  • What env vars change per environment?

  • How will this run in Kubernetes/ECS?


7๏ธโƒฃ Production-Grade Dockerfile Example (Complete)

FROM node:20-bullseye AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:20-bullseye
WORKDIR /app
COPY --from=builder /app .
ENV NODE_ENV=production
EXPOSE 1337
USER node
HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:1337/_health || exit 1
CMD ["npm", "run", "start"]

๐Ÿ Final Thoughts

Dockerfiles are not just build scripts โ€” they encode:

  • Security decisions

  • Performance tradeoffs

  • OS compatibility

  • Environment behavior

A strong DevOps engineer treats Dockerfiles like infrastructure code, not just boilerplate.

Entry point vs CDM

๐Ÿ”น What do these two lines mean?

ENTRYPOINT ["node"]
CMD ["server.js"]

Together, they define how your container starts.

When Docker runs the container, it combines them like this:

node server.js

So effectively, your container runs:

๐ŸŸข Node.js with server.js as the default script


๐Ÿง  How Docker Combines ENTRYPOINT + CMD

Docker treats them like:

Final command = ENTRYPOINT + CMD

So:

ENTRYPOINT ["node"]
CMD ["server.js"]

Becomes:

node server.js

๐Ÿ”„ Why Not Just Use CMD Alone?

You could do:

CMD ["node", "server.js"]

This also runs node server.js.
But the behavior when you override commands is different ๐Ÿ‘‡


๐Ÿ†š Difference in Behavior (Real-Life Example)

Case A: Only CMD

CMD ["node", "server.js"]

Run normally:

docker run myapp
# runs: node server.js

Override command:

docker run myapp node other.js
# runs: node other.js

Here, CMD is fully replaced.


Case B: ENTRYPOINT + CMD (Your Example)

ENTRYPOINT ["node"]
CMD ["server.js"]

Run normally:

docker run myapp
# runs: node server.js

Override CMD only:

docker run myapp other.js
# runs: node other.js

ENTRYPOINT stays fixed (node), and you only change the script.

๐Ÿ‘‰ This is powerful when you want to lock the executable but allow flexible arguments.


๐ŸŽฏ Real-Life Use Cases

โœ… 1๏ธโƒฃ CLI Tool Containers

Example: A Node-based CLI tool

ENTRYPOINT ["node", "cli.js"]
CMD ["--help"]

Run default:

docker run my-cli
# node cli.js --help

Custom args:

docker run my-cli build --prod
# node cli.js build --prod

โœ… 2๏ธโƒฃ Scripting Containers

ENTRYPOINT ["node"]
CMD ["migrate.js"]

Now the container is a Node runtime wrapper:

docker run my-migrator cleanup.js
# node cleanup.js

โŒ When NOT to Use ENTRYPOINT + CMD

For typical web servers (Express, Strapi, Next.js), this pattern is not common. Youโ€™ll usually see:

CMD ["npm", "run", "start"]

Because:

  • Youโ€™re not building a CLI tool

  • You donโ€™t need to override script names at runtime


โš™๏ธ Exec Form vs Shell Form (Important Detail)

Your example uses exec form:

ENTRYPOINT ["node"]
CMD ["server.js"]

This is better than shell form:

ENTRYPOINT node
CMD server.js

Why exec form is better:

  • Proper signal handling (SIGTERM, SIGINT)

  • No extra shell process

  • Cleaner process tree

  • Works better with Docker stop, Kubernetes, ECS


๐Ÿง  Interview-Ready One-Liner

ENTRYPOINT defines the fixed executable for the container, while CMD provides default arguments that can be overridden at runtime. When combined, Docker runs ENTRYPOINT with CMD as parameters.


๐Ÿงช Quick Demo You Can Try Locally

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY server.js .
ENTRYPOINT ["node"]
CMD ["server.js"]

Run:

docker run myapp
# node server.js

docker run myapp other.js
# node other.js

๐Ÿ Final Takeaway

Think of it like this:

  • ENTRYPOINT = what program is this container?

  • CMD = what arguments should it run by default?