↓Skip to main content
  1. Docker Tutorials/

How to Use If/Else Conditions With External Arguments in a Dockerfile

·4 mins

Dockerfile syntax has no if/else instruction. Every instruction runs unconditionally, in order. Conditional behavior comes from two different mechanisms, depending on what you’re trying to vary, and picking the wrong one leads to Dockerfiles that are harder to read than they need to be.

Mechanism 1: shell conditionals inside RUN, driven by ARG #

For conditional logic within a single build stage, such as installing a different package or running a different setup step, pass a build-time argument and branch on it inside a shell command:

FROM debian:bookworm-slim

ARG INSTALL_DEV_TOOLS=false

RUN if [ "$INSTALL_DEV_TOOLS" = "true" ]; then \
      apt-get update && apt-get install -y vim curl htop; \
    else \
      echo "skipping dev tools"; \
    fi
docker build --build-arg INSTALL_DEV_TOOLS=true -t myapp:dev .
docker build -t myapp:prod .   # uses the ARG's default, "false"

This is a real shell if/else, because the shell form of RUN hands its argument to /bin/sh -c, so the conditional syntax works as it would in any shell script. That is all the “Dockerfile if/else” people are usually looking for: a normal shell conditional inside a RUN instruction, driven by an ARG you control with docker build --build-arg.

One thing to watch: /bin/sh is often a minimal POSIX shell such as dash (it is on Debian and Ubuntu), so stick to POSIX syntax. [ ... ] works everywhere, [[ ... ]] does not. If you need bash features, switch the shell for the following instructions with SHELL ["/bin/bash", "-c"].

ARG before FROM vs. after #

Worth being precise about scope here, since it’s a common source of “my ARG isn’t available” confusion. An ARG declared before the first FROM can be used in FROM lines (for choosing the base image dynamically), but it is not available inside the build stage afterwards. To use it there, declare it again without a value, which picks up the original default:

ARG BASE_IMAGE=debian:bookworm-slim
FROM ${BASE_IMAGE}

# BASE_IMAGE is out of scope here, so this prints empty brackets
RUN echo "before re-declaring: [$BASE_IMAGE]"

# Declaring it again without a value brings back the default
ARG BASE_IMAGE
RUN echo "after re-declaring: [$BASE_IMAGE]"

The first echo prints empty brackets, the second prints the default base image name. (BuildKit hides the output of successful RUN steps by default, so you need to use docker build --progress=plain to see it.)

Mechanism 2: multi-stage builds with a chosen target #

For a bigger fork, an entirely different set of instructions for development and production rather than one conditional package install, a single RUN if/else gets unwieldy. Use separate build stages and choose which one you want as the final output:

FROM node:20-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS development
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS production
ENV NODE_ENV=production
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .

Both stages start from the shared base stage and then diverge: development installs all dependencies and starts a dev server, production installs only the runtime dependencies and starts the app directly. If stages are new to you, What Is a Dockerfile and How Do You Create One? explains how they work.

This is the cleaner mechanism once “conditional” means “meaningfully different instructions”, not just “one flag”. Each target is fully readable on its own, instead of a single stage full of interleaved if branches. With BuildKit, only the stages the chosen target depends on are built, and the shared base stage is built once and reused. With a conditional RUN, changing the argument invalidates the build cache from that instruction onwards.

Which to reach for #

If a single package or one setup step varies, use an ARG with a shell conditional inside RUN. If you need a genuinely different build, such as development tooling and hot reload versus a production bundle, use multi-stage builds with --target. Mixing both is fine and common: use --target for the big structural fork, and ARG-driven conditionals for small variations within a given target.