A Dockerfile is a recipe for an image. It is read top to bottom, each instruction produces a layer, and the result is something anyone can run without knowing anything about your language, your runtime, or your machine.
For a Node service:
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Build and run it:
This one is a recording — the commands are real, but nothing is running here. Type them into your own shell to follow along.
The trailing . in docker build -t myapp:1.0 . is the build context — the directory sent to the builder. It is not the Dockerfile's location (that is -f). Passing / or your home directory as a context is a classic way to make a build take ten minutes and produce a two-gigabyte image.
# syntax=docker/dockerfile:1 — the first line, and the one most tutorials omit. It pins the Dockerfile frontend: the parser BuildKit downloads to interpret this file. With it, you get current syntax regardless of how old the installed Docker is, and new features arrive without upgrading the engine. Without it, you are stuck on whatever the engine shipped with. Always include it.
FROM — the base image, and the start of a stage. Every Dockerfile has at least one.
WORKDIR — sets the directory for everything after it, creating it if needed. Use it rather than RUN cd /app, which does nothing useful because each RUN is a separate shell.
COPY — copies from the build context into the image. COPY . . means "context root into the current WORKDIR".
RUN — executes a command at build time and commits the result as a layer.
EXPOSE — documentation. It does not publish anything; it records which port the image expects to serve on, which -P and some tooling read. Publishing is -p at run time.
CMD — the default command. Overridden by anything you type after the image name in docker run.
Your Dockerfile has EXPOSE 3000. You run docker run -d myapp and cannot reach port 3000 from the host. Why?
CMD versus ENTRYPOINTBoth say what runs. The difference is what happens when someone passes arguments.
CMD ["node", "server.js"]
docker run myapp runs node server.js. docker run myapp sh runs sh instead — CMD is entirely replaced.
ENTRYPOINT ["node"]
CMD ["server.js"]
docker run myapp still runs node server.js. But docker run myapp worker.js runs node worker.js — the argument replaces CMD and is appended to ENTRYPOINT.
The rule of thumb: ENTRYPOINT when the image is one tool and the arguments are its arguments; CMD alone when the image is an environment someone might want to poke at with a shell.
CMD ["node", "server.js"] # exec form — a JSON array
CMD node server.js # shell form — wrapped in /bin/sh -c
Use the array. Shell form runs your process as a child of /bin/sh, so the shell is PID 1 and your process is not — and most shells do not forward signals to children. docker stop sends SIGTERM to PID 1, the shell ignores it, and ten seconds later everything is SIGKILLed. That is the usual explanation for a container that always takes exactly ten seconds to stop.
Why does CMD ["python", "app.py"] shut down more cleanly than CMD python app.py?
.dockerignoreThe build context is uploaded to the builder before anything runs. Without a .dockerignore, that includes node_modules, .git, build output, local .env files, and everything else in the directory.
.git
node_modules
dist
*.log
.env
.env.*
Two reasons this matters, and the second is the serious one. It makes builds faster and images smaller. And it stops COPY . . from baking your local secrets into a layer that anyone who pulls the image can read — docker history and a tar extraction will find them, and rebuilding without the file does not remove it from a published image.
Build the Dockerfile in the current directory, tagging the result api:2.1.
docker init writes this for youDocker ships a scaffolding command that generates a sensible starting point rather than making you remember the shape:
docker init
It asks what your project is, then writes four files: Dockerfile, compose.yaml, .dockerignore, and README.Docker.md.
Templates exist for ASP.NET Core, Go, Java (Maven, uber-jar), Node, PHP with Apache, Python, Rust, and a general-purpose "Other".
What it produces is genuinely good — multi-stage where the language benefits, a non-root user, a pinned base version, a real .dockerignore. It is a better starting point than most tutorials, this lesson's deliberately minimal example included.
Two caveats. It overwrites existing files after prompting, so run it in a clean directory or read the diff. And treat the output as a draft: it cannot know that your build needs a native toolchain, or which of your directories holds state. Generating it and then understanding every line is the useful workflow — which is what the rest of this course is for.
Next up: layers and the build cache — why the order of those instructions is not arbitrary.
