Creating a Docker Image

Creating a Docker Image

Bitnesia • Sep 8, 2026 138 •ID

After understanding how images are composed of layers and how the tag system works, it is time to practice creating our own images. This chapter covers the Dockerfile from scratch: how to write it, the most frequently used main instructions, multi-stage build techniques to reduce final image size, and best practices to maintain so that the Dockerfile remains efficient and secure for long-term use. Whether you are a Developer looking to containerize your first application or a Sysadmin/DevOps Engineer responsible for keeping images lean in production, both need the same foundation: thoroughly understanding what happens in every line of the Dockerfile they write.

5.1 Writing a Dockerfile

A Dockerfile is a plain text file containing a series of instructions read by Docker sequentially from top to bottom to build an image. Each instruction begins with an uppercase keyword (by convention, though not technically required) followed by its arguments, and Docker executes them one by one, generating a new layer for each instruction that modifies the filesystem.

By default, Docker looks for a file named exactly Dockerfile (without an extension) in the root of the build context, which is the directory provided as an argument to docker build. This build context is sent to the Docker daemon and serves as the source of files for instructions like COPY. Let us start with the simplest case: create a new working directory containing a small static application, then write our first Dockerfile.

mkdir dockerfile-demo && cd dockerfile-demo
echo "<h1>Halo dari container</h1>" > index.html

Create a new file named Dockerfile in the same directory, then populate it with the following instructions.

FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html

These two lines are sufficient to produce a runnable image. The first line specifies the base image, while the second line copies the index.html file from the build context to the location read by the built-in nginx web server of that image. Build this image using docker build, with the -t option to provide a name and tag, and a dot at the end of the command pointing the build context to the current directory.

docker build -t dockerfile-demo:1.0 .

Notice the build output: each Dockerfile instruction appears as a separate step, complete with its execution time. Since Docker Engine 23.0, BuildKit has been the default builder for the docker build command across all platforms, replacing the classic builder used in earlier versions. BuildKit brings performance improvements through parallel execution across independent stages and more efficient caching, benefits that become increasingly noticeable as the Dockerfile contains multiple separate stages, such as in multi-stage build patterns.

Run a container from the newly built image to verify the result.

docker run -d -p 8080:80 --name demo-nginx dockerfile-demo:1.0

Access http://localhost:8080 via browser or curl, ensuring the page content displays the text "Halo dari container" that we wrote in index.html.

curl http://localhost:8080

In addition to the standard Dockerfile filename, custom filenames (such as Dockerfile.prod) can be used via the -f option in docker build, which is useful when a single project requires multiple Dockerfile variants for different environments. The build context should also be kept as small as possible; every file in the context directory is sent to the Docker daemon when the build starts, so including large, irrelevant folders (such as node_modules or .git) only slows down the build process without benefit. Create a .dockerignore file in the same directory as the Dockerfile to exclude those files, using a pattern format similar to .gitignore.

node_modules
.git
*.log
Dockerfile
.dockerignore

If the build context directory specified at the end of the docker build command is incorrect or missing, Docker displays the error message unable to prepare context: path "..." not found; ensure the provided path argument points to a valid, existing directory. If the Dockerfile exists in the context location but is empty, Docker displays the message the Dockerfile cannot be empty; ensure the file actually contains instructions and is saved as plain text. Clean up test containers and images when finished.

docker rm -f demo-nginx
docker rmi dockerfile-demo:1.0

5.2 Main Dockerfile Instructions

After understanding the basic workflow of writing a Dockerfile, we need to familiarize ourselves with the most frequently used instructions one by one. This section discusses the primary function and default behavior of each instruction, along with practical hands-on examples.

5.2.1 FROM and Base Image

FROM is always the first meaningful instruction in a Dockerfile (unless preceded by an ARG instruction used within the FROM line itself), defining the base image that serves as the starting point of the build. The syntax is simple: FROM <image>[:<tag>]. If no tag is specified, Docker defaults to the latest tag, although, as discussed in the chapter on images, this practice should be avoided in production environments due to unpredictable build outcomes.

A single Dockerfile can contain multiple FROM instructions, each marking the start of a new build stage. Each stage can use a different base image and be referenced by a name assigned using the AS keyword, a pattern that forms the foundation of multi-stage build techniques to separate the build process from the final runtime image.

5.2.2 RUN, COPY, and ADD

RUN executes commands within the current build stage and commits the results as a new layer, typically used for package installation or code compilation. This instruction has two forms: shell form (RUN <command>), which runs via /bin/sh -c on Linux, and exec form (RUN ["executable", "param1", "param2"]), which runs directly without a shell.

FROM debian:12-slim
RUN apt-get update && apt-get install -y curl

Combining apt-get update and apt-get install into a single RUN instruction using the && operator is an essential best practice, not merely a stylistic choice. If split into two separate RUN instructions, the cached layer from apt-get update might be reused in subsequent builds even if the package list in the remote repository has changed, potentially causing apt-get install to install outdated package versions.

COPY copies files or directories from the build context into the image filesystem, using the syntax COPY <src>... <dest>. ADD shares this core functionality but adds two extra capabilities: automatically extracting compressed archive files (such as .tar.gz) into the target directory, and fetching files directly from remote URLs. Official Docker documentation recommends using COPY for standard file copying needs and reserving ADD specifically for those two special cases, as the more implicit behavior of ADD can make image contents harder to predict solely from reading the instructions.

FROM debian:12-slim
COPY app.tar.gz /tmp/
ADD app.tar.gz /opt/app/

In the example above, ADD automatically extracts the contents of app.tar.gz into /opt/app/, whereas COPY copies the archive file as-is without extraction.

5.2.3 CMD and ENTRYPOINT

CMD sets the default command to be executed when a container instantiated from this image is started without additional command arguments. Only one CMD instruction takes effect in a single Dockerfile; if declared multiple times, only the last CMD instruction is applied. ENTRYPOINT serves a similar purpose but is harder to override than CMD: arguments passed via docker run are appended to the ENTRYPOINT command rather than replacing it entirely, as happens with CMD.

These two instructions are most commonly used together: ENTRYPOINT defines the primary executable that always runs, while CMD provides default arguments that image consumers can easily override.

FROM alpine:3.20
ENTRYPOINT ["echo"]
CMD ["Halo dari entrypoint"]
docker build -t entrypoint-demo .
docker run entrypoint-demo
docker run entrypoint-demo "Pesan custom"

The first run command displays "Halo dari entrypoint" using the default CMD, while the second run command displays "Pesan custom" because the provided argument replaces the contents of CMD, while ENTRYPOINT remains the primary command execution handler. Like RUN, both instructions support exec form (written with square brackets as shown above) and shell form; exec form is strongly recommended because it does not wrap the main process in an extra shell, allowing signals like SIGTERM from docker stop to pass directly to the main application process rather than being trapped inside a shell process.

A common pitfall for new developers: exec form must be formatted as a valid JSON array using double quotes for every string item, not single quotes. Writing CMD ['node', 'server.js'] with single quotes causes the Dockerfile parser to fail to recognize it as a valid JSON array, resulting in the line being parsed entirely as shell form rather than raising an immediate syntax error. Always use double quotes, such as CMD ["node", "server.js"], to guarantee consistent execution behavior.

5.2.4 WORKDIR, ENV, and ARG

WORKDIR sets the working directory for subsequent RUN, CMD, ENTRYPOINT, COPY, and ADD instructions, automatically creating the directory if it does not already exist. Using WORKDIR is preferred over RUN cd because directory changes performed via RUN cd do not persist to subsequent instructions, whereas WORKDIR is specifically designed to persist directory state across layers.

ENV defines environment variables that remain available throughout the rest of the build process and persist inside the container when the image is executed. ARG is similar, but its scope is strictly limited to the duration of the docker build execution and does not persist inside the resulting container unless its value is explicitly assigned to an ENV variable.

FROM node:20-alpine
ARG APP_ENV=production
ENV NODE_ENV=$APP_ENV
WORKDIR /app
COPY . .
RUN npm install --omit=dev

The value of an ARG can be overridden during build time using the --build-arg option, enabling configuration differences between build environments without modifying or duplicating the Dockerfile.

docker build --build-arg APP_ENV=staging -t node-demo:staging .

Because ARG values are preserved in image metadata and visible via docker image history, never use ARG to store sensitive information such as passwords, tokens, or API keys. Such secrets should be handled using dedicated build secret mechanisms rather than standard ARG or ENV statements.

5.2.5 EXPOSE, LABEL, and USER

EXPOSE documents the network ports on which the containerized application listens, using the syntax EXPOSE <port>[/<protocol>]. It should be emphasized that EXPOSE does not automatically publish or expose ports to the host system; it is purely informational metadata serving as documentation for anyone reading the Dockerfile. Actual port mapping must still be specified via the -p flag during docker run execution.

LABEL attaches custom key-value metadata to the image, such as maintainer details, version information, or short descriptions. This metadata can be inspected via docker image inspect and is useful for image categorization, filtering, and CI/CD automation.

FROM alpine:3.20
LABEL maintainer="[email protected]"
LABEL version="1.0" description="Contoh image demo LABEL dan EXPOSE"
EXPOSE 8080/tcp

USER specifies the effective user (and optionally group) applied to all subsequent instructions, as well as the main process execution context when the container runs. If omitted, the container runs as root by default, presenting a security vulnerability because processes within the container acquire unrestricted access to the container filesystem. The recommended approach is to create a dedicated non-root user and switch to that user prior to executing CMD or ENTRYPOINT instructions.

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]

In enterprise settings, Sysadmins and DevOps Engineers enforcing production container standards frequently mandate non-root execution policies across cluster runtime platforms, rejecting images that default to root. In the event of a container escape, an attacker escaping a root-owned container process immediately gains root privileges on the host system rather than restricted permissions.

5.3 Multi-Stage Build

Many applications require a build or compilation step prior to execution, such as compiling Go code, transpiling TypeScript, or bundling frontend assets. If this entire pipeline runs within a single image layer alongside the runtime, build tools such as compilers, package managers, and development dependencies remain embedded in the final image, significantly increasing image size beyond runtime requirements. Furthermore, an attacker exploiting an application vulnerability gains access to build toolchains inside the container environment.

Multi-stage builds solve this issue by introducing multiple FROM instructions within a single Dockerfile, where each FROM statement defines a standalone stage with its own base image. Individual stages can be tailored for specialized tasks, such as dedicated build and execution runtime stages. Artifacts generated in early stages are selectively copied into the lean runtime stage using COPY --from, while heavy build toolchains remain in discarded stages and are excluded from the final image.

Let us implement this pattern using a basic Go application. Create a new directory and a main.go file.

mkdir multistage-demo && cd multistage-demo
package main

import "fmt"

func main() {
	fmt.Println("Halo dari binary hasil multi-stage build")
}

Save this file as main.go. Next, write a Dockerfile utilizing two stages: a stage named build that uses the full golang image to compile the binary, and a second stage using a lightweight alpine image for the runtime context.

FROM golang:1.23-alpine AS build
WORKDIR /src
COPY main.go .
RUN go build -o /bin/hello main.go

FROM alpine:3.20
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

Note the AS build alias in the initial FROM line, which assigns an identifier to that stage for subsequent references. The COPY --from=build command in the second stage extracts the compiled binary file /bin/hello directly from the build stage, ignoring raw source files, Go compiler toolchains, and build dependencies. Build this image and compare its size to single-stage alternatives.

docker build -t multistage-demo:1.0 .
docker image ls multistage-demo:1.0

The resulting image size typically measures only a few megabytes, vastly smaller than the golang:1.23-alpine build image which contains full Go language SDK environments. Run the container to verify the compiled binary functions properly.

docker run --rm multistage-demo:1.0

Beyond distribution images, the final runtime stage base image can be replaced with scratch, an empty base image with zero filesystem footprint, provided the binary is statically compiled without runtime dependencies on system C libraries like libc or shell binaries.

FROM golang:1.23-alpine AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /bin/hello main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

The environment variable CGO_ENABLED=0 forces the Go compiler to generate a fully static binary without dynamic linkages to host libc libraries, a mandatory requirement for execution within a blank scratch image. Note that scratch-based images lack shell environments, preventing standard interactive debugging tools like docker exec from launching shell sessions, an operational trade-off DevOps Engineers must evaluate when local container debugging is required.

Multi-stage builds also support copying artifacts directly from external registry images rather than internal stages using COPY --from=<image_name> syntax. Additionally, specific build stages can be targeted using the --target flag during docker build, enabling developer-centric build workflows that stop execution at intermediate stages.

docker build --target build -t multistage-demo:build-only .

Clean up test images upon completion.

docker rmi multistage-demo:1.0 multistage-demo:build-only

5.4 Dockerfile Best Practices

Beyond mastering core syntax and multi-stage builds, adhering to established engineering best practices ensures container images remain efficient, secure, and maintainable across deployment pipelines.

Use minimal base images aligned with operational requirements. Minimal variants such as alpine or slim significantly reduce image footprint, lowering network transfer overhead and minimizing security attack surface area. However, compatibility testing remains essential prior to adopting minimal base images, particularly for applications relying on native C extensions bound to glibc.

Order instructions from least frequently changed to most frequently changed. System dependency installations should appear early in the file structure, while application source code copies should be placed near the bottom. This structure maximizes layer cache reuse when application code changes without requiring dependency re-installation.

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

In this pattern, package.json and package-lock.json are copied and installed independently before running COPY . .. So long as manifest files remain unchanged, Docker reuses cached npm ci layers during build execution, bypassing dependency downloads when application code is modified.

Combine related RUN execution commands. Every RUN statement creates a distinct image layer. Chaining related installation steps into single RUN instructions using && operators reduces layer count while allowing temporary package manager caches to be cleared within the same execution step before the layer state is committed.

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

Attempting to clean temporary package caches in a separate RUN statement does not reduce total image size, as cached files remain permanently recorded in preceding image layers; cache cleanup must occur within the same layer execution statement as package installation.

Consistently employ .dockerignore files. In addition to improving build execution speed by restricting build context transfer payloads, .dockerignore files prevent sensitive files such as local .env configurations, API secrets, or local repository files from inadvertently leaking into image layers via wildcard COPY . commands.

Never persist secrets within image layers. Passwords, private keys, and API tokens declared via ENV, ARG, or copied via COPY remain recorded in image layer history even if deleted in subsequent RUN statements, exposing sensitive data to anyone with image access. BuildKit provides dedicated RUN --mount=type=secret options that mount credentials temporarily during command execution without persisting data inside image layer history.

Implement multi-stage builds to separate build toolchains from runtime environments. As demonstrated, multi-stage structures produce smaller, more secure production images compared to monolithic, single-stage Dockerfile configurations.

Execute containers under non-root user contexts. Declare a non-root USER instruction towards the end of the Dockerfile for applications that do not require elevated system privileges, mitigating blast radius in the event of security vulnerabilities.

Specify explicit image version tags instead of latest. Unanchored base image tags introduce unpredictable build behaviors across pipeline executions, causing identical Dockerfile configurations to yield different image outputs over time. For strict build reproducibility in automated CI/CD pipelines, pin base images directly to immutable SHA256 image digests.

PracticeBenefit
Minimal base images (alpine/slim)Smaller footprint, reduced attack surface
Dependency instruction ordering prior to source codeMaximized cache hit ratio during rebuilds
Consolidated RUN statements + inline cache removalFewer layers, reduced layer storage consumption
.dockerignore usageReduced context sizes, prevents accidental secret inclusion
RUN --mount=type=secret for sensitive assetsPrevents secret persistence inside image layers
Multi-stage buildsIsolates build toolchains from runtime images
Non-root USER executionLimits damage potential during container escape events
Specific version tags (avoiding latest)Ensures consistent and predictable build output