Docker Lesson 12 – Writing your first Dockerfile | Dataplexa
Section II · Lesson 12

Writing Your First Dockerfile

Reading a Dockerfile and writing one are two different skills. This lesson is hands-on — you'll write a Dockerfile from scratch, build an image from it, run a container, and verify that it works. By the end, you've built something real.

The application we're Dockerizing is a simple Node.js API — one that any backend developer would recognise immediately. The goal isn't the app itself. The goal is building the muscle memory of writing a Dockerfile the right way, in the right order, with the right reasoning behind each decision.

The Application We're Dockerizing

The scenario: You're a backend developer at a growing e-commerce startup. Your team has built a Node.js order-management API that runs perfectly on your laptop. Now it needs to be containerised so it runs the same way in staging and production. You're starting from the project root — the package.json is there, the source code is there, and you need to create a Dockerfile from scratch.

The project structure looks like this:

order-api/
├── package.json  # project dependencies and metadata
├── package-lock.json  # locked dependency versions
├── server.js  # entry point — starts the Express server
├── routes/  # API route handlers
└── .gitignore  # git ignores — node_modules, .env etc.

Step 1 — Create the .dockerignore File

Before writing the Dockerfile, create a .dockerignore file in the project root. This works exactly like .gitignore — it tells Docker which files and directories to exclude from the build context when you run docker build.

The most important thing to exclude is node_modules. If you don't, Docker will copy your entire local node_modules folder into the image — that's potentially hundreds of megabytes being transferred unnecessarily, and it overwrites the clean installation that RUN npm install does inside the image.

# .dockerignore — place this file in the same directory as your Dockerfile
node_modules        # never copy local node_modules — npm install runs inside the image
.env                # never bake secrets into the image
.git                # git history has no place in a production image
*.log               # log files bloat the image unnecessarily
.DS_Store           # macOS metadata files

Never Copy .env into an Image

A .env file typically contains database passwords, API keys, and secrets. If it gets copied into the image and that image is pushed to Docker Hub — even a private registry — those secrets are permanently exposed in the image layers. Always add .env to .dockerignore. Pass secrets at runtime using environment variables or Docker Secrets (covered in Lesson 33).

Step 2 — Write the Dockerfile

Now create a file named Dockerfile — no extension — in the project root. Here's the complete Dockerfile for the order-management API, with every decision explained.

FROM node:18-alpine
# node:18-alpine gives us Node 18 on Alpine Linux — lightweight and production-ready
# The full node:18 image is ~900 MB; node:18-alpine is ~127 MB
# Always pin the major version — never FROM node:latest in production

WORKDIR /app
# Set the working directory inside the container
# All subsequent COPY and RUN instructions are relative to /app
# This keeps the container filesystem clean — no files scattered in /

COPY package*.json ./
# Copy package.json AND package-lock.json in one step (the * handles both)
# We copy these BEFORE the source code — critical for layer caching
# If package.json hasn't changed, Docker reuses the cached npm install layer

RUN npm install --omit=dev
# Install only production dependencies — skip devDependencies
# --omit=dev keeps the image lean by excluding test frameworks, linters, etc.
# This runs at BUILD time — node_modules get baked into the image layer

COPY . .
# Now copy the rest of the source code into /app
# This happens AFTER npm install so code changes don't bust the dependency cache

EXPOSE 3000
# Document that this app listens on port 3000
# This is metadata — you still need -p 3000:3000 in docker run to access it

CMD ["node", "server.js"]
# Start the Express server when the container launches
# Exec form (array syntax) is preferred — runs node directly as PID 1
# Avoid shell form (CMD node server.js) — it runs through sh, which swallows signals

Step 3 — Build the Image

From the project root — the directory containing your Dockerfile — run the build command. The dot at the end is the build context — it tells Docker which directory to use as the source for COPY instructions.

docker build -t order-api:v1.0.0 .
# docker build    → tells the Daemon to build an image from a Dockerfile
# -t              → tag the resulting image with a name and version
# order-api       → the image name (use your project name)
# :v1.0.0         → the version tag — always version your own images explicitly
# .               → the build context: use the current directory
[+] Building 22.7s (9/9) FINISHED
 => [internal] load build definition from Dockerfile                    0.0s
 => [internal] load .dockerignore                                       0.0s
 => [internal] load metadata for docker.io/library/node:18-alpine       1.3s
 => [1/5] FROM node:18-alpine                                           4.1s
 => [2/5] WORKDIR /app                                                  0.0s
 => [3/5] COPY package*.json ./                                         0.1s
 => [4/5] RUN npm install --omit=dev                                   14.8s
 => [5/5] COPY . .                                                      0.3s
 => exporting to image                                                  2.1s
 => => naming to docker.io/library/order-api:v1.0.0                    0.0s

What just happened?

The Daemon executed each instruction in the Dockerfile in order. Five build steps correspond to the five meaningful instructions — FROM loads the base layer, then each subsequent instruction adds a new layer. Step 4 (RUN npm install) dominated the build time at 14.8 seconds — this is normal for the first build. On the second build, as long as package.json hasn't changed, Docker uses the cached layer for step 4 and the total build time drops to under 2 seconds. The final line names the image with the tag you specified: order-api:v1.0.0. Run docker images now and you'll see it in your local cache.

Step 4 — Run a Container from Your Image

The image is built. Now run a container from it — your own image, your own application.

docker run -d \
  --name order-api-container \
  -p 3000:3000 \
  -e NODE_ENV=production \
  order-api:v1.0.0
# -d                    → run in the background
# --name                → give it a recognisable name
# -p 3000:3000          → map host port 3000 to container port 3000 (matches EXPOSE)
# -e NODE_ENV=production → pass an environment variable into the running container
# order-api:v1.0.0      → the image we just built

# Verify it's running
docker ps
docker logs order-api-container
9c4d2e8f1a3b7c5e6d9f0a2b4c8e1f3a5b7d9e0f1a2b3c4d5e6f7a8b9c0d1e2

CONTAINER ID   IMAGE               COMMAND                  CREATED        STATUS        PORTS                    NAMES
9c4d2e8f1a3b   order-api:v1.0.0    "docker-entrypoint.s…"   3 seconds ago  Up 2 seconds  0.0.0.0:3000->3000/tcp   order-api-container

Order Management API running on port 3000
Environment: production
Connected to database successfully

What just happened?

Your image is now a running container. The docker ps output confirms it's up and the port mapping is active — 0.0.0.0:3000->3000/tcp. The docker logs output shows the app started successfully and picked up the NODE_ENV=production environment variable you passed with -e. Hit http://localhost:3000 in your browser and your containerised API responds. That's the full journey — Dockerfile written, image built, container running — in four steps.

The Image Layers You Just Created

Here's what the image you just built looks like as a layer stack — every instruction that created a layer, and roughly how much each one contributes to the final image size.

order-api:v1.0.0 — layer breakdown

FROM node:18-alpine~127 MB
WORKDIR /app~0 B
COPY package*.json~4 KB
RUN npm install --omit=dev~28 MB
COPY . .~12 MB

Total image size: ~167 MB. The base image dominates. Your app code and dependencies add roughly 40 MB on top. Compare this to a non-Alpine Node image which would start at ~900 MB.

Teacher's Note

Every Dockerfile you write from here on should follow this exact order: FROMWORKDIRCOPY package files → RUN install → COPY source → CMD. Deviating from this order is the single most common cause of slow builds.

Practice Questions

1. The file that tells Docker which files and directories to exclude from the build context — similar to .gitignore — is called what?



2. The dot at the end of docker build -t my-app:v1 . tells Docker which directory to use as the what?



3. To skip installing devDependencies during npm install in a production Dockerfile, which flag do you add?



Quiz

1. A Dockerfile copies package.json before copying the rest of the source code. Why is this ordering deliberate?


2. A developer forgets to add .env to .dockerignore and runs docker build. What is the risk?


3. A Dockerfile uses CMD ["node", "server.js"] instead of CMD node server.js. Why is the array (exec) form preferred?


Up Next · Lesson 13

Building Docker Images

You've run one build — now let's go deep on the build command itself, build arguments, tagging strategies, and what happens when a build fails halfway through.