Writing a Dockerfile
The Dockerfile
A Dockerfile is a text file with instructions for building an image. Each instruction creates a layer. The file lives in your project root.
A simple Dockerfile
For a Hectoday HTTP app:
# Dockerfile
FROM node:22-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy dependency files first (for caching)
COPY package.json package-lock.json ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy the rest of the application code
COPY . .
# The port the app listens on
EXPOSE 3000
# Start the app
CMD ["node", "src/server.js"] What each instruction does
FROM node:22-alpine — Start from the Node.js 22 Alpine image. This gives us Node.js and a minimal Linux system.
WORKDIR /app — All subsequent commands run inside /app. Like cd /app but it creates the directory if it does not exist.
COPY package.json package-lock.json ./ — Copy only the dependency files first. This is a caching optimization — dependencies change less often than code.
RUN npm ci --omit=dev — Install dependencies. npm ci is faster and more deterministic than npm install (it uses the lockfile exactly). --omit=dev skips devDependencies (TypeScript, @types packages) because they are not needed in production.
COPY . . — Copy everything else (your source code). This layer changes every time you change your code, but the dependency layer above is cached.
EXPOSE 3000 — Documents which port the app uses. This is informational — it does not actually publish the port. You do that with docker run -p.
CMD ["node", "src/server.js"] — The command that runs when the container starts. Use the exec form (JSON array) not the shell form (string). The exec form runs Node.js directly as PID 1 (the main process in the container), which means it receives shutdown signals (like SIGTERM when Docker stops the container) directly. If you use the shell form (CMD node src/server.js), a shell process becomes PID 1 and Node.js does not receive the signal — your app does not shut down gracefully.
Building the image
docker build -t myapp . -t myapp names (tags) the image. . is the build context (the current directory — Docker sends these files to the build engine).
Step 1/7 : FROM node:22-alpine
---> Using cache
Step 2/7 : WORKDIR /app
---> Using cache
Step 3/7 : COPY package.json package-lock.json ./
---> Using cache
Step 4/7 : RUN npm ci --omit=dev
---> Using cache ← dependencies cached!
Step 5/7 : COPY . .
---> abc123def456 ← code changed, this layer rebuilds
Step 6/7 : EXPOSE 3000
Step 7/7 : CMD ["node", "src/server.js"]
Successfully built abc123def456
Successfully tagged myapp:latest Notice “Using cache” on the dependency steps. Only the COPY . . step rebuilds because only the code changed. This is why we copy package.json separately.
Running the image
docker run -p 3000:3000 myapp -p 3000:3000 maps port 3000 on your machine to port 3000 in the container. Open http://localhost:3000/health — you should see {"status":"ok"}.
The TypeScript problem
The Dockerfile above runs node src/server.js, but your source code is TypeScript (.ts). You have two options:
Option A: Use tsx in the container. Install tsx and run with it. Simple but adds overhead and dev dependencies to the image.
Option B: Compile TypeScript, copy only JavaScript. Build in a separate stage, copy the compiled output. This is the multi-stage build approach — the next lesson.
Exercises
Exercise 1: Create the Dockerfile. Build the image with docker build -t myapp .. Verify it builds successfully.
Exercise 2: Run the container with docker run -p 3000:3000 myapp. Open http://localhost:3000/health in your browser.
Exercise 3: Change your application code (add a console.log). Rebuild. Notice that the dependency layer says “Using cache” while the code layer rebuilds.
Why do we COPY package.json before COPY . .?