Skip to main content

πŸ“¦ Creating Dockerfile for Different Languages

A Dockerfile is the recipe that turns your source code into a portable, reproducible image. In this lesson you'll learn the instructions that matter, the caching rules that make builds fast, and how to write a lean, secure Dockerfile for four of the languages this course touches β€” Node.js, Python, PHP, and Go.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a Dockerfile is and how each instruction becomes a cached image layer
  • Order instructions to maximize build-cache hits and use a .dockerignore file
  • Write multi-stage builds that ship a tiny runtime image without build tools
  • Harden an image by running as a non-root user and adding a HEALTHCHECK
  • Produce a working Dockerfile for Node.js, Python, PHP, and Go

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Containerize a small Express app and shrink it with a multi-stage build.

In This Lesson

What Is a Dockerfile?

A Dockerfile is a plain-text file containing an ordered list of instructions. When you run docker build, Docker reads those instructions top to bottom and produces an image β€” a frozen, layered snapshot of a filesystem plus the command to run. From that image you launch containers, which are the running instances.

πŸ’‘ A useful analogy: The Dockerfile is a recipe, the image is the meal prepped and sealed in a container ready to reheat, and a running container is the plate on the table. Because the recipe is written down and versioned in Git, anyone on your team reheats the exact same meal β€” no "works on my machine".

That reproducibility is the whole point. A Dockerfile turns "install Node 20, then run npm ci, then set these env vars…" from a page of README instructions into infrastructure as code that builds identically on your laptop, a teammate's machine, and a CI runner.

flowchart LR A[Dockerfile] -->|docker build| B[Image] B -->|docker run| C[Running Container] A -->|committed to| D[Git] B -->|docker push| E[Registry]

The Instructions That Matter

A Dockerfile has fewer than a dozen instructions you use daily. Here is the shape of a typical one, followed by what each line does:

# syntax=docker/dockerfile:1
FROM node:20-alpine              # base image to build on

LABEL org.opencontainers.image.source="https://github.com/me/app"

ENV NODE_ENV=production          # env vars baked into the image

WORKDIR /app                     # cd into (and create) this dir

COPY package*.json ./            # copy manifests first (see caching)
RUN npm ci --omit=dev            # run a build-time command -> new layer

COPY . .                         # copy the rest of the source

EXPOSE 3000                      # document the listening port

USER node                        # drop root before running

CMD ["node", "server.js"]        # default process when the container starts

πŸ“– Key Instructions

FROM β€” the starting image. Always the first real instruction.

WORKDIR β€” sets (and creates) the current directory for later instructions.

COPY β€” copies files from your build context into the image. Prefer it over ADD unless you specifically need ADD's URL-fetch or auto-extract behavior.

RUN β€” executes a command at build time and freezes the result as a new layer.

CMD vs ENTRYPOINT β€” CMD is the default command (easily overridden at docker run); ENTRYPOINT makes the container behave like a fixed executable. They are often combined.

⚠️ CMD runs at start-up, RUN runs at build time

A common beginner mistake is putting a start command in RUN. RUN npm start would try to launch your server during the build and hang forever. The command that launches your app belongs in CMD (or ENTRYPOINT).

Layers, Caching & .dockerignore

Every FROM, COPY, and RUN creates a new layer. Docker caches each layer and reuses it on the next build as long as that instruction and everything it depends on are unchanged. The moment one layer's inputs change, that layer and every layer after it must rebuild.

This single rule drives the most important optimization in all of Dockerfile writing: copy the things that rarely change before the things that change constantly. Your dependency manifest changes far less often than your source code, so copy and install dependencies first.

Why dependency layers should come before source code Two Dockerfile layer stacks. On the left, source is copied before install, so editing code busts the install cache. On the right, manifests are copied and installed first, so editing code only rebuilds the top layer. COPY . . then RUN install FROM node COPY . . (busts on edit) RUN install (re-runs!) One code edit = full reinstall manifests first, source last FROM node COPY package*.json + install COPY . . (only this rebuilds) One code edit = cached install
Figure 1 β€” Order instructions from least-likely to most-likely to change. Installing dependencies before copying source keeps the slow install step cached across code edits.

Squash related commands into one RUN

Each RUN is a layer, and a layer can only add data β€” deleting a file in a later layer doesn't shrink the image. So chain related steps (and their cleanup) into a single RUN:

# Three layers, and the apt cache is stuck in the image forever
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# One layer, cleanup actually reduces size
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

The .dockerignore file

Before building, Docker sends the whole build context to the daemon. A .dockerignore keeps junk out of that context β€” speeding the build and preventing secrets or bloat from sneaking into COPY . .:

node_modules
npm-debug.log
.git
.gitignore
.env
Dockerfile
.dockerignore
coverage
dist
*.md

βœ… Pin your base image tag

Prefer FROM node:20-alpine over the moving target FROM node (which resolves to latest). Pinning a version makes builds reproducible; a bare tag can silently change under you and break a build weeks later.

Multi-Stage Builds

Your build tools β€” compilers, dev dependencies, bundlers β€” are needed to produce the app but not to run it. A multi-stage build uses one stage to build and a second, clean stage that copies only the finished artifact. The result is a dramatically smaller, more secure final image.

# ---- Stage 1: build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build          # produces /app/dist

# ---- Stage 2: runtime ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The key line is COPY --from=builder: it reaches back into the first stage and lifts out just the built files. Node, npm, and your source never make it into the shipped image. For a compiled language like Go, this can take an image from 800 MB down to a few megabytes.

Dockerfiles by Language

The concepts above stay the same; only the base image and install command change. Here is a production-minded Dockerfile for each language.

Node.js

Use npm ci (not npm install) for reproducible installs from the lockfile, and the official node user to avoid running as root:

FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000
USER node

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Python

The PYTHONUNBUFFERED and PYTHONDONTWRITEBYTECODE variables make logs appear immediately and skip .pyc clutter. Serve with a real WSGI server like Gunicorn, not the dev server:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

PHP

Pull Composer straight from its official image, install extensions with the docker-php-ext-install helper, and let Apache serve the app:

FROM php:8.3-apache

RUN docker-php-ext-install pdo_mysql

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader

COPY . .
RUN composer dump-autoload --optimize \
    && chown -R www-data:www-data /var/www/html

EXPOSE 80
CMD ["apache2-foreground"]

Go β€” the multi-stage showcase

Go compiles to a single static binary, so the runtime stage needs almost nothing. Building on scratch or a distroless base yields an image measured in single-digit megabytes:

# ---- build ----
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

# ---- runtime ----
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Rough final image sizes

node:20-alpine app     ~150 MB
python:3.12-slim app   ~130 MB
php:8.3-apache app     ~480 MB
Go (distroless)        ~8 MB

Best Practices (Do / Don't)

βœ… Do

  • Pin exact base tags (python:3.12-slim, not python).
  • Copy manifests before source so dependency installs stay cached.
  • Use multi-stage builds to keep build tools out of the runtime image.
  • Run as a non-root USER and add a HEALTHCHECK.
  • Add a .dockerignore to shrink the context and block secrets.

⚠️ Don't

  • Don't bake secrets into the image with ENV or COPY β€” inject them at runtime.
  • Don't run apt-get upgrade in a Dockerfile; rebuild on a fresh base instead.
  • Don't use one RUN per command β€” chain related steps and clean up in the same layer.
  • Don't ship the dev server (Flask's run, npm run dev) to production.

Hands-on Exercise

πŸ‹οΈ Containerize and shrink an Express app

Objective: Build a working Node.js image, then use caching and a lean base to keep it small and fast.

Instructions:

  1. Create a folder and a minimal server:
    // server.js
    const express = require('express');
    const app = express();
    app.get('/', (req, res) => res.send('Hello from Docker!'));
    app.get('/health', (req, res) => res.json({ status: 'ok' }));
    app.listen(3000, () => console.log('listening on 3000'));
  2. Run npm init -y && npm install express to create the manifest and lockfile.
  3. Write a .dockerignore that excludes node_modules.
  4. Write a Dockerfile that copies package*.json first, installs, then copies source.
  5. Build and run: docker build -t hello . then docker run -p 3000:3000 hello.
  6. Visit http://localhost:3000, edit the message, and rebuild β€” notice the install layer is cached.
πŸ’‘ Hint

If your rebuild reinstalls dependencies after a code-only edit, your COPY . . is probably above the npm ci line. Move the manifest copy and install up so they sit before the full source copy.

βœ… Solution
FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]

Because package*.json is copied and installed before the source, editing server.js reuses the cached install layer β€” the rebuild only re-runs the final COPY.

🎯 Quick Quiz

Question 1: Why do we copy package*.json and install dependencies before copying the rest of the source?

Question 2: What is the main benefit of a multi-stage build?

Question 3: Which of these belongs in a .dockerignore file?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A Dockerfile is versioned infrastructure-as-code that builds a reproducible image.
  • Every instruction is a cached layer; order from least- to most-changing and copy manifests before source.
  • Multi-stage builds keep compilers and dev dependencies out of the runtime image.
  • Harden images: pin tags, run as non-root, add a HEALTHCHECK, and use a .dockerignore.
  • The pattern is identical across Node, Python, PHP, and Go β€” only the base image and install command change.

πŸ“š Further Reading

πŸš€ What's Next?

One image is rarely a whole app. Next we'll wire several containers together β€” a web service, a database, a cache β€” with a single Docker Compose file.

πŸŽ‰ Nice work!

You can now write a lean, secure Dockerfile for any of the languages in this course. Let's connect them together.