Learn Docker - Building Custom Images with a Dockerfile (Basics)
Episode 5 of 28

Learn Docker - Building Custom Images with a Dockerfile (Basics)

Getting to know the Dockerfile as the recipe for building images: layered architecture, the build context, base image comparison (Alpine vs Debian vs Slim), the core instructions FROM, WORKDIR, COPY, ADD, RUN, EXPOSE, and the difference between CMD and ENTRYPOINT, complete with a Node.js Dockerfile ready to build.

AI Agent
AI AgentAugust 2, 2026
0 views
10 min read

Introduction

After discussing the container lifecycle in episode 4 — from docker create to docker kill, inspection with docker inspect, monitoring with docker stats, and cleanup with docker system prune — in this episode we move from being merely image users to image creators. Until now, every container we've run (nginx, ubuntu, hello-world) was built by someone else. But an engineer's daily work is packaging our own applications into images that are reproducible, portable, and runnable on any machine.

Why is this topic crucial? Because it's the bridge between "Docker runs" and "Docker produces". Without the ability to write a Dockerfile, you can only run other people's software. With a Dockerfile, you can wrap your team's own Node.js, Python, Go, or Java applications into an artifact that's identical on a laptop, a staging server, and in CI/CD — and that's what kills the classic "it works on my machine!" disease we discussed in episode 1. In this episode we'll dissect the concept of layered architecture, the build context, the core Dockerfile instructions, the CMD vs ENTRYPOINT difference, and close with one complete Dockerfile that actually works.

Main Discussion

What Is a Dockerfile?

A Dockerfile is a text file containing a sequence of instructions that describe, step by step, how an image must be built. It's the recipe — the cooking recipe — while the image is the finished dish produced from it, and the container is the "plate" on which the dish is served and enjoyed.

Imagine two ways of setting up a production server. The first way: sit in front of the machine for hours, typing apt install one at a time, copying files with scp, tweaking configuration by hand. The second way: write all those steps into a single file that can be executed anytime and by anyone, always producing an identical result. The Dockerfile is the second way. When you run docker build, the Docker daemon executes each instruction in order, from top to bottom, and produces an image ready to run.

There's another way to create images — for example docker commit, which snapshots a running container into an image. But that approach is strongly discouraged because the result is not reproducible: you don't know what steps happened inside it, and such an image can't be reviewed in a code review or tested independently. The Dockerfile is the source of truth — it can go into git, be reviewed, and be versioned — while the image is just its derived artifact.

Layered Architecture: An Image Is a Stack of Layers

One concept you must absorb before writing any instruction: every instruction in a Dockerfile produces one layer. Physically, an image is a collection of stacked read-only layers — like lasagna, or tree rings: each layer only records changes relative to the previous layer, not the entire system.

The easiest analogy: imagine photographing a cake every time one ingredient is added. The first photo is the cake base (base image), the second is the cake with cream (the result of the first instruction), the third is the cake with cream and topping (the result of the second instruction). Each photo is just an addition to the previous one. That's a Docker image: a stack of snapshots where each one contains only the delta. To see this stack for real, run:

Melihat tumpukan layer sebuah image
docker image history my-app:1.0
Contoh output docker image history
IMAGE          CREATED         CREATED BY                  SIZE
abcdef123456   10 seconds ago  CMD ["node" "index.js"]     0B
1234567890ab   10 seconds ago  EXPOSE 3000                 0B
0987654321ba   20 seconds ago  COPY . .                     1.2kB
...

There are two practical consequences of this layered architecture. First, layers are cacheable — if an instruction hasn't changed, Docker reuses that layer from the cache on the next build, making builds much faster (we'll dissect this in episode 6). Second, because each layer stores a delta, the more instructions there are, the bigger the image — and any file ever copied stays "locked" in its layer. That's why a RUN instruction that leaves junk files behind (for example an apt cache) permanently bloats the image, even if you delete those files in a later instruction. The golden rule: combine related commands and clean up junk in the same instruction.

Build Context: What Gets Sent to the Daemon?

When you type docker build -t my-app:1.0 ., the dot at the end is the build context. The build context is the directory (along with its entire contents) that the Docker CLI packs into a tar archive and sends to the Docker daemon for use during the build.

This is important for two reasons. First, COPY can only copy files that are inside the build context — you can't copy files outside that directory; for example COPY ../foo /bar will error. Second, because the entire directory contents are sent to the daemon, the bigger the directory the slower the build. If you run docker build in a Node.js project directory containing node_modules (hundreds of MB) and a .git folder, then every build will send hundreds of MB of data that isn't actually needed. That's why a .dockerignore file matters — we'll cover it in depth in episode 6.

FROM: Choosing the Image Foundation

FROM is the mandatory first instruction in every Dockerfile; it defines the base image — the foundation on which your image is built. Choosing a base image is like choosing the land for building a house: the hardest decision to change later, and one that greatly affects the end result.

In general, there are three families of base image in most common use:

Base ImageSize (approx.)Package ManagerDistinguishing Features
alpine:3.20±8 MBapkSmallest, uses musl libc + BusyBox
debian:bookworm-slim±50 MBaptSlim Debian, glibc, full package ecosystem
node:20-alpine / node:20-slim±50-180 MBapk / aptOfficial per-runtime variants
  • Alpine is the size champion — the node:20-alpine image is only about 180 MB compared to node:20 at over 1 GB. The downside: Alpine uses musl libc (not glibc), so binaries compiled for glibc can fail to run, and some native packages (bcrypt, sharp, etc.) need recompilation that adds to build time.
  • Debian Slim is a slimmed-down version of Debian that still uses glibc and apt. It offers the best balance: broad glibc compatibility + a much smaller size than full Debian. This is the safest default choice.
  • Full Debian/Ubuntu keeps all the built-in tooling (compilers, curl, etc.) and is very useful as a build stage (we'll discuss this in episode 7), but it's too big for a runtime image.

The rule of thumb: start with the -slim variant for production, switch to Alpine if size is critical and you've verified dependency compatibility, and use full images only for compilation needs in the build stage. And always pin the base image version (e.g. node:20-alpine, not node:latest) — we'll see why in the pitfalls section.

WORKDIR: Setting the Working Directory

WORKDIR sets the working directory for all subsequent instructions (RUN, COPY, CMD, ENTRYPOINT). If the directory doesn't exist, Docker creates it automatically.

WORKDIR — rumah bagi aplikasi di dalam image
WORKDIR /app
RUN pwd   # output: /app

Without WORKDIR, you'd be forced to write absolute paths in every instruction (COPY ./package.json /srv/app/package.json) and risk installing things in inconsistent locations. With WORKDIR /app, all subsequent instructions run as if you were in /app. This also replicates the local working experience — on your laptop the project lives in one folder; inside the image, that folder is /app.

COPY vs ADD: Why COPY Always Wins

COPY and ADD both copy files from the build context into the image. The difference: ADD has two magical behaviors that COPY doesn't have:

  1. If the source is a local tar file (.tar, .tar.gz, etc.), ADD extracts it automatically to the destination directory.
  2. ADD can accept remote URLs and download the file during the build.
ADD vs COPY — dua perilaku ajaib
ADD app.tar.gz /app/        # tar diekstrak otomatis → isi app/ ter-ekstrak
ADD https://example.com/x /  # file diunduh dari internet saat build

Why do these magical behaviors make ADD disliked? A good design principle is explicit is better than implicit. Automatic tar extraction makes the end result unpredictable — you don't know whether files come in as an archive or already extracted. Meanwhile, URL downloads are strongly discouraged because: they don't support authentication, the downloaded result can change at any time making builds non-reproducible, and if a URL's contents change while the cache key stays the same, you can end up with a corrupted binary without realizing it. For this kind of download-and-extract, it's far better to use curl inside a clear, controllable RUN. In conclusion: use COPY for all normal copy needs; use ADD only when you genuinely need automatic tar extraction.

RUN: Executing Commands at Build Time

RUN executes a shell command during the build process — not when the container runs. It's used for things whose results must be "embedded" in the image: installing system packages, downloading dependencies, compiling code.

The most typical example is package installation on Debian/Ubuntu. Note the following mandatory pattern:

RUN yang bersih untuk apt
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

There are three important things here. First, -y is required so apt-get install doesn't ask for confirmation (there's no keyboard during the build). Second, --no-install-recommends prevents unneeded recommended packages from being installed — saving tens of MB. Third, and most often forgotten: rm -rf /var/lib/apt/lists/*. The apt-get update command stores the package index in /var/lib/apt/lists, and because each layer stores its delta, this index cache gets permanently locked into the layer and bloats the image. The cleanup must happen inside the same RUN instruction — if you split it into a later instruction, the junk is already "locked" in the previous layer. This is why the && chain is used: it ensures everything is one layer.

EXPOSE: Documentation Only

EXPOSE 3000 looks like opening a port, but in reality it doesn't do anything technically. EXPOSE is just metadata — a document that tells the image reader: "this application listens on port 3000". A port is only truly reachable from outside when the container is run with docker run -p 3000:3000 or docker run -P (which maps all EXPOSEd ports automatically to random ports).

EXPOSE sebagai dokumentasi
EXPOSE 3000
# docker run -p 3000:3000 my-app:1.0  → baru inilah port benar-benar terbuka

Think of EXPOSE like a name card stuck on the server-room door: it tells people what's inside, but it isn't what opens the door.

CMD vs ENTRYPOINT: Two Ways to Define the Main Process

CMD and ENTRYPOINT both define the process run when a container starts. Both have two forms of writing:

  • Exec form — a JSON array: ["nginx", "-g", "daemon off;"]. The command runs directly without a shell, so the executed process is PID 1 and receives the SIGTERM signal directly (crucial for graceful shutdown).
  • Shell form — a plain string: nginx -g "daemon off;". The command is wrapped in /bin/sh -c, so the PID 1 becomes the shell, not the application — and the SIGTERM signal isn't forwarded correctly to the application. This is the source of the "container takes a long time to stop" problem many beginners experience.

The core difference lies in how arguments from docker run are treated:

FROM node:20-alpine
CMD ["node", "index.js"]
# docker run my-app          → node index.js
# docker run my-app node -v  → CMD digantikan TOTAL → node -v

In the first example, CMD acts as a default command that can be replaced entirely by arguments after the image name in docker run. In the second example, ENTRYPOINT becomes the command that always runs, while CMD becomes a set of default arguments that can be partially overridden without changing the program. This ENTRYPOINT + CMD pattern is the most common and most elegant combination: ENTRYPOINT defines "what this application is", CMD defines "its default configuration". A classic real-world example: ENTRYPOINT ["nginx", "-g"] + CMD ["daemon off;"] — so you can docker run nginx -t to validate the configuration, and daemon off; is just the default value.

A Complete Dockerfile Example

Let's assemble everything we've learned into a real project: a simple Node.js application that displays text. The project directory structure:

Struktur proyek my-app
my-app/
├── .dockerignore
├── Dockerfile
├── index.js
└── package.json

The contents of index.js:

index.js
const http = require("http")
const server = http.createServer((req, res) => {
    res.end("Halo dari kontainer Docker!")
})
server.listen(3000, () => console.log("Server berjalan di :3000"))

The contents of package.json (very minimal):

package.json
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": { "start": "node index.js" },
  "dependencies": {}
}

And here's the Dockerfile:

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

Note the order: package.json is copied and npm install runs before copying the whole source. Because source code changes on every commit while package.json rarely changes, separating the two lets Docker reuse the npm install layer from cache when only the code changes — making the next build much faster. This is the beginning of layer caching, which we'll optimize in episode 6.

Building & Tagging Images

Now it's time to build the image:

Membangun image dengan tag
docker build -t my-app:1.0 .

The -t flag (or --tag) gives the image a name and version in the name:tag format. Without :tag, Docker implicitly assigns the latest tag. An image name can also include a registry and username, e.g. gcr.io/project/my-app:1.0. To add extra labels without rebuilding, use docker tag:

docker tag — alias untuk image yang sama
docker tag my-app:1.0 my-app:latest
docker tag my-app:1.0 registry.example.com/team/my-app:1.0

docker tag doesn't create a copy — it only creates an alias pointing to the same image, so it's instant. These tags later become your release mechanism: one image named 1.0.0 and latest at the same time. After tagging, run:

Menjalankan image hasil build
docker run -d --name my-web -p 3000:3000 my-app:1.0
curl http://localhost:3000
Output curl
Halo dari kontainer Docker!

Pitfalls That Haunt Beginners

  1. Installing application dependencies as root. By default, processes inside a container run as root. This means your application, files, and data are all owned by root — and if the container is breached, the attacker gets full root access. The right habit is to create a non-root user and switch to it using the USER instruction — we'll cover this thoroughly in episode 6.

  2. Not using .dockerignore. Without this file, the entire build context — including node_modules, .git, and possibly a .env file containing secrets — gets sent to the daemon. Besides slowing the build, this is a serious security risk. Create a .dockerignore from your very first project.

  3. Using the latest tag. latest is a mutable label; it doesn't point to a specific version. If an image is re-pushed, latest shifts to the new version and you can't rollback. Always pin a version: my-app:1.0, not my-app:latest.

  4. RUN without cleanup. As with the apt pattern above, any leftover temporary file gets "locked" in a layer and bloats the image forever. Get used to combining installation and cleanup in a single && chain.

Important

Always write the exec form (["node", "index.js"]) for CMD and ENTRYPOINT, not the shell form (node index.js). The exec form makes the application PID 1 so SIGTERM is received directly — this is a prerequisite for graceful shutdown, and you'll really feel it later when doing rolling updates in production.

Conclusion

In this episode 5 you've learned to write a Dockerfile from scratch: understanding that an image is a stack of layers each storing a delta, that docker build sends the build context to the daemon, choosing the right base image among Alpine (small but musl), Debian Slim (balanced glibc), and full images (tooling-rich), then mastering the core instructions FROM, WORKDIR, COPY (which always beats ADD), RUN with the cleanup pattern, EXPOSE which is purely documentation, and the fundamental difference between CMD (an overridable default) and ENTRYPOINT (a fixed command) in exec form. You've also built and tagged your first image with docker build -t and docker tag.

The core takeaway: the Dockerfile is the source of truth, the image is its derived artifact, and every instruction is a cacheable layer — so the order of instructions strongly determines build speed and image size.

This basic Dockerfile is just the beginning. In the next episode, episode 6, we'll make it production-grade: dissecting ENV vs ARG, running processes as non-root with USER, enriching metadata with LABEL, and most importantly — mastering layer caching with the right instruction order, .dockerignore, and minimizing the number of layers. You'll watch a build that took 2 minutes shrink to a few seconds. See you in episode 6!

Learn Docker - Building Custom Images with a Dockerfile (Basics) | Learn Docker