Going deeper into advanced Dockerfile concepts: dissecting ENV vs ARG and their scope, the USER, LABEL, and SHELL instructions for secure images, then mastering .dockerignore, layer minimization, instruction ordering, and layer caching for faster builds and leaner images.

After building our first Dockerfile in episode 5 — understanding layered architecture, the build context, the CMD vs ENTRYPOINT difference, and closing with a working Node.js Dockerfile — in this episode we'll remodel that Dockerfile into production-grade. We'll answer the questions that come up as soon as you start getting serious: how do you pass different configuration across environments without changing code? Why should your application run as non-root? And why does your team's build take 2 minutes when the image only differs by one line?
This topic matters because this is where the difference between a "Dockerfile that works" and a "Dockerfile that deserves a code review" shows up. In the working world, a Dockerfile isn't just a technical file — it's an artifact that determines security, development speed, and infrastructure cost. A bad Dockerfile means vulnerable containers (root), bloated images (junk layers), and slow builds (cache not leveraged) — three problems that haunt DevOps teams every day. This episode gives you the tools to fix all three at once.
The most common problem after writing your first Dockerfile: your application needs different configuration (e.g. database URL, NODE_ENV, port) between development and production environments. Docker provides two mechanisms, and distinguishing them correctly is the foundation of everything.
ENV declares a runtime environment variable that is permanently embedded in the image. Its value exists in every layer after the declaration, can be read by the application while the container runs, and can be overridden at docker run -e KEY=value.ARG is a variable that only lives during the build process. Its value can be injected via docker build --build-arg KEY=value, and it won't exist in the resulting image (with an important caveat we'll discuss).ARG NODE_VERSION=20
ENV NODE_ENV=production \
APP_PORT=3000
FROM node:${NODE_VERSION}-alpineNote the example above: ARG NODE_VERSION=20 is declared before FROM, so it can be used to select the base image version via the ${NODE_VERSION} interpolation. This is the only use of ARG that can only happen before FROM. ARG can also be declared after FROM, but the two declarations are different variables — an ARG before FROM isn't available in instructions after FROM, and vice versa.
This scope difference can be summarized in a table:
| Aspect | ENV | ARG |
|---|---|---|
| Lifetime | Baked into the image, exists at runtime | Only during the build process |
| Value injection | docker run -e | docker build --build-arg |
| Readable by the application | Yes | No |
Visible in docker inspect | Yes (embedded) | No |
| Default value | Mandatory or via declaration | Can be set in the Dockerfile |
| When to use | Application configuration | Tool versions, build variants, build-time parameters |
The rule for choosing is simple: if the application needs the value at runtime → ENV. If only the build needs it → ARG. A real example: NODE_ENV=production and DATABASE_URL are ENV. Meanwhile NODE_VERSION (for choosing the base image), VERSION (for release labels), or GOPROXY (a Go mirror during build) are ARG.
Warning
Never put secrets (passwords, API keys, tokens) in ENV without a default value, nor as an ARG that's then copied into ENV — for example ARG DB_PASSWORD that's subsequently ENV-ed will be permanently embedded in an image layer and readable by anyone who runs docker history. An ARG value indeed doesn't travel into the image, but once it's put into ENV or used by a RUN command, it becomes part of a layer that can be extracted. Secrets must go through a buildkit secret mount or docker run -e at runtime — we'll discuss this in episodes 13 and 18.
As warned in episode 5, processes inside a container run as root by default. That's convenient, but dangerous: if the application gets hacked, the attacker instantly has full control of that container. In the working world, a good production image almost always runs its process as a non-root user.
Here's how: create a dedicated user inside the image, then switch to it with USER. An example on a Debian/Ubuntu base image:
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser
COPY . .
RUN chown -R appuser:appuser /app
USER appuserFROM node:20-alpine
WORKDIR /app
COPY --chown=node:node package.json ./
USER nodeNote the COPY --chown=node:node line — the --chown flag sets ownership of the copied files so a non-root user can access them. This is more efficient than a separate chown because it doesn't add a layer. Why does the order matter? USER must be placed after all instructions that need root rights (installing packages, copying files), because once USER appuser is used, all subsequent instructions run as that user — and you can't apt-get install anymore without rights. The common pattern: install first as root → create the user → chown the application files → then USER near the end.
Official base images like node, python, and nginx already provide built-in users (node, www-data). Always check the image's documentation and use the existing user rather than creating a new one — it saves an instruction.
LABEL adds key-value metadata to an image. It doesn't change application behavior, but it's very useful for organization, automation, and auditing. Industry practice uses the label standard from the Open Container Initiative (OCI) with the org.opencontainers.image.* prefix:
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.description="API service utama" \
org.opencontainers.image.source="https://github.com/team/my-app"This metadata can be read with docker inspect, which is very helpful when your team already has dozens of images and wants to know which repo an image came from, what version it is, and who maintains it — without having to guess. In large organizations, labels are often used for automation: for example gitlab-ci labels a commit version, or a policy scanner checks license labels.
The SHELL instruction changes the shell used by the shell form of RUN, CMD, and ENTRYPOINT. It's most useful when you want to execute commands with a shell other than sh — for example bash with the -c option, or powershell for Windows images. The most common example: running commands with bash and set -euxo pipefail for stricter error handling:
SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
RUN echo "perintah ini gagal" | grep sesuatu_yang_tidak_adaWithout pipefail, a pipeline that fails in the middle is still considered successful by Docker, so docker build doesn't detect the error — one of the sneakiest bugs. With pipefail, this kind of error fails the build immediately.
In episode 5 we touched on the build context — the entire directory gets sent to the daemon on docker build. The .dockerignore file works exactly like .gitignore: it tells Docker which files/directories must be excluded from the build context.
node_modules
.git
.gitignore
*.log
.env
.env.*
Dockerfile
docker-compose.ymlWhy is this so important? Three reasons. Speed: node_modules is hundreds of MB and .git can grow large with history — both get sent to the daemon on every build if not ignored. Correctness: imagine a .env containing secrets being sent along and then COPY-ed into the image — that's a direct leak. Repository security: keeping .env in a build context that's sent to a remote build server (e.g. CI) means your secrets travel between machines unnecessarily.
Tip
A pattern that often fools beginners: the Dockerfile itself can also be included in .dockerignore. The COPY . . command will copy the Dockerfile into the image if it isn't excluded — rarely desired, and it leaks build details to anyone who pulls that image.
Every instruction is one layer, and every layer adds size and slows the build (because it must be hashed and compared). The simple rule: don't split related commands into many RUNs. Compare:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*The "BEFORE" version produces three layers: the first layer contains the updated apt index, the second contains curl plus the apt index, and the third contains an rm that deletes the index — but because layers store deltas, the deletion in the third layer doesn't remove the index size in the first and second layers. The image still carries all the junk. The "AFTER" version combines everything into a single layer, so it's completely clean. That's why the && chain isn't just a writing style — it's image-size policy.
It's also worth remembering: don't overdo it. Sometimes separating RUNs is actually correct — for example when you want to leverage the cache for a step that rarely changes (like npm install separated from COPY . .). Minimizing layers doesn't mean forcing one giant command; it means don't create junk layers.
Now we arrive at the most valuable part of this episode: how Docker decides to use cache or rebuild. The process: for each instruction, Docker computes a hash of that instruction (and its context for COPY/ADD), then compares it with existing layers. If it matches → cache hit, the layer is taken from cache without execution. If it doesn't match → cache miss, the instruction is executed, and all subsequent instructions are forced to rebuild — even those whose contents didn't change.
Here's the golden rule: put the instructions that change least often at the top, and those that change most often at the bottom. "Stable" instructions — base image declaration, WORKDIR, system dependency installation, COPY package.json + npm install — are processed first. "Volatile" instructions — COPY . . (source code changes every commit), configuration files — are processed last.
FROM node:20-alpine # jarang berubah
WORKDIR /app # jarang berubah
COPY package.json ./ # jarang berubah (hanya saat dependensi berubah)
RUN npm install --omit=dev # LAYER INI DI-CACHE selama package.json sama
COPY . . # sering berubah → diletakkan paling bawahWith this order, when you change one line of code in index.js, Docker only re-executes COPY . . and the layers after it. Dependency installation (usually the slowest) stays cached. A build that took 2 minutes becomes 10 seconds.
Now compare with the opposite — the most common beginner mistake:
FROM node:20-alpine
WORKDIR /app
COPY . . # berubah tiap commit → cache bust di sini
RUN npm install --omit=dev # dipaksa jalan ulang SETIAP build
CMD ["node", "index.js"]In the "BEFORE" version, every code change — even a single letter — invalidates the npm install cache, which can eat precious minutes on every CI build. In the "AFTER" version, npm install is re-executed only when package.json changes.
A few advanced cache details you must know:
COPY invalidation is based on content, not timestamps. The hash is computed from file contents, so changing a file's mode (e.g. chmod +x) also triggers a cache miss.ENV and RUN instructions interact. If ENV changes, subsequent layers are invalidated too. This is one reason rarely-changing ARG/ENV should be placed at the top.docker build --no-cache forces a from-scratch build — useful when you suspect the cache holds something stale, or you want to verify from a clean state.docker build -t my-app:2.0 .
# Step 3/7 : RUN npm install --omit=dev
# ---> Using cache
# ---> a1b2c3d4e5f6The Using cache line is the marker that Docker used an existing layer. When you see it in build output, your instruction ordering is correct.
Having mastered all the instructions, let's compose one Dockerfile that combines everything — secure (non-root), lean (minimal layers), and cache-friendly:
FROM node:20-alpine
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.version="1.0.0"
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "index.js"]Let's dissect the decisions inside it. ARG NODE_ENV + ENV NODE_ENV=${NODE_ENV} allows override at build time (--build-arg NODE_ENV=development) while defaulting to production. npm ci is used instead of npm install because it's reproducible — it follows package-lock.json strictly (a concept we'll meet again in episode 7). --omit=dev eliminates development dependencies. COPY package.json package-lock.json ./ before RUN maximizes caching. COPY --chown=node:node . . ensures correct ownership without an extra chown. USER node makes the process run as the image's built-in non-root user. All volatile instructions are at the bottom.
Note
Note that npm ci requires package-lock.json to exist — if your project doesn't have one yet, run npm install once to generate it, then commit that file. Without a lockfile, npm ci will fail, and you'll have to fall back to npm install.
In this episode 6 you've upgraded your Dockerfile to production level: understanding the fundamental difference between ENV (embedded in the image, overridable at docker run -e) vs ARG (build-only, via --build-arg), including using ARG before FROM to choose the base image. You also learned to strengthen security with the non-root USER, add metadata with OCI-standard LABEL, change the shell with SHELL, and — most impactful of all — mastered layer caching: why COPY package.json + npm install must come before COPY . ., why an && chain in a single RUN saves image size, and why .dockerignore protects both your speed and your secrets.
Core takeaway: instruction order is caching policy, and one cache miss in the middle ruins every layer below it. A good Dockerfile isn't the shortest one — it's the most deliberate one.
But there's one problem not yet solved: for applications that need a compiler — Go, apps with native dependencies, projects built with heavy build tooling — the runtime base image will carry all that build tooling if we use it directly. In the next episode, episode 7, we'll dissect multi-stage builds: how to build the application in a full stage with SDK and compiler, then copy the result into a tiny runtime image (even scratch), shrinking image size from gigabytes to tens of megabytes. See you in episode 7!