Docker has become an essential tool for developers, DevOps engineers, and anyone deploying applications today. It solves the age-old problem of “it works on my machine” by packaging an application with everything it needs into a lightweight, isolated unit called a container.
This guide takes you from absolute beginner to confidently building and running real‑world applications with Docker.
1. Why Docker Exists (The Problem)
Before Docker:
- “It works on my machine” is the daily mantra 😵
- Different OS → different bugs, different dependency versions
- Onboarding a new developer takes hours (Node, DB, caches, environment variables…)
- Servers are hand‑configured snowflakes, impossible to reproduce exactly
Docker solves this:
👉 It packages your app plus everything it needs into a lightweight, isolated unit called a container.
That container runs identically everywhere:
- Your laptop
- A teammate’s machine
- A CI/CD pipeline
- A production server in the cloud
2. What is Docker?
Docker is a platform that lets you:
- Define application environments as code (Dockerfile)
- Build images from those definitions
- Run containers from those images
- Share images via a public registry (Docker Hub)
Simple analogy:
Docker = Lunch box 🍱
Your app + dependencies = the food inside
Container = the sealed box you can carry anywhere, and when you open it the meal is exactly the same
3. Key Concepts (Must Know First)
📦 Image
A blueprint of your application environment. It contains the OS files, dependencies, code, and configuration needed to run your app.
Example images:
-
node:18-alpine– Node.js on a tiny Alpine Linux -
postgres:15– PostgreSQL database server -
nginx– a fast web server
Think: “Class in OOP”
🚀 Container
A running instance of an image. You can have multiple containers from the same image, each isolated from the others.
Think: “Object created from a class”
🧱 Dockerfile
A text file that defines how to build an image. It lists step‑by‑step instructions, like a recipe.
Example:
FROM node:18
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["node", "index.js"]
📦 Docker Hub
A public registry for Docker images (like GitHub for containers). You pull official images from here and can push your own.
📁 Build Context
When you run docker build ., Docker sends all files in the current directory (the build context) to the daemon. If you have huge logs or a node_modules folder, builds become slow, and images can balloon. That’s why you need a .dockerignore file.
4. Installing Docker
Windows / Mac
Install Docker Desktop:
https://www.docker.com/products/docker-desktop/
Linux
sudo apt update
sudo apt install docker.io
Verify:
docker --version
docker compose version # new plugin, replaces docker-compose
5. Your First Docker Command
Run your first container:
docker run hello-world
What happens:
- Docker looks for the
hello-worldimage locally - Not found → pulls it from Docker Hub
- Creates a container from the image
- Runs it → prints a success message
- Container stops (its job is done)
6. Working with Images
List images
docker images
Pull image
docker pull node
Build an image from a Dockerfile
docker build -t my-app .
-
t my-appgives it a name (tag) -
.is the build context (current directory)
Tag an image for a registry
docker tag my-app username/my-app:v1.0
Push to Docker Hub
docker login
docker push username/my-app:v1.0
Remove image
docker rmi node
7. Working with Containers
Run a container in the foreground
docker run nginx
Press Ctrl+C to stop.
Run in the background (detached)
docker run -d nginx
List running containers
docker ps
List all containers (including stopped)
docker ps -a
Stop a container
docker stop <container_id>
Remove container
docker rm <container_id>
Add -f to force the removal of a running container.
8. Ports (VERY IMPORTANT)
Containers run in their own isolated network. To reach a service inside, you must map a host port to the container’s port.
docker run -p 8080:80 nginx
-
8080→ your machine’s port -
80→ the port inside the container where nginx listens
Now visit: http://localhost:8080
9. The Hard Way First (Why Dockerfiles Exist)
Before learning Dockerfiles, let’s build an image manually. This will make you feel why Dockerfiles are essential.
Step 1: Pull a bare OS
docker pull ubuntu
Step 2: Run It Interactively
docker run -it ubuntu bash
• -i interactive, -t allocate a terminal, bash gives you a shell inside the container.
Step 3: Install Node.js manually (inside the container)
apt update
apt install -y nodejs npm
node --version
Step 4: Write a tiny server (still inside the container)
apt install -y nano
nano server.js
Paste:
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200);
res.end('Hello from inside a container 🐳');
}).listen(3000);
console.log('Server running on port 3000');
Run it: node server.js
It works! 🎉 But notice what just happened to get here.
Step 5: Save this container as an image
Open a second terminal (leave the server running) and check the container ID:
docker ps # get container ID
Now save the current state of that container as a new image:
docker commit <container_id> my-manual-node-image
Next time, instead of repeating steps 1 – 4, you could just run:
docker run -it my-manual-node-image bash
…and Node + your server file would already be there.
Step 6: Notice the Problems 🚩
This works, but think about what you just did:
- 😩 Not repeatable — if you forget a step, or do something slightly different next time, you get a different image. There's no record of what you actually typed.
- 📦 Bloated image —
docker commitsnapshots everything: apt cache, shell history, stray files, thenanoeditor you installed just to write one file. None of that belongs in a production image. - 🙈 No audit trail — open
my-manual-node-imagesix months from now and you have no idea how it was built. You can peek withdocker history my-manual-node-image, but it only shows vague layer commands, not your actual reasoning. - 🤝 Hard to share — to hand this to a teammate, you'd have to push a multi-hundred-MB binary blob, instead of sharing a few lines of text they can read in 10 seconds.
- 🔁 Painful to rebuild — try recreating this image from scratch a second time using only
docker commitand no notes. You'll forget something. Maybe theapt update, maybe the exact install flags.
The Better Way: Write It Down as a Dockerfile
Everything you just did by hand, pulling Ubuntu, installing Node, copying in your code, can be written as a simple text file that Docker runs for you, identically, every single time:
FROM node:18
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
And rebuilt, identically, from scratch with:
docker build -t my-node-app .
No manual apt install, no nano, no remembering steps, no bloat from tools you only needed temporarily.
The Dockerfile is the documentation.
💡 This is actually how Docker images were built in the early days, manual changes +
docker commit. Dockerfiles were introduced specifically to fix the problems you just experienced.
10. Writing Your First Dockerfile (Node.js App)
Step 1: Create a simple Node.js app
mkdir docker-demo
cd docker-demo
npm init -y
Create index.js:
console.log("Hello from Docker 🚀");
Step 2: Write the Dockerfile
FROM node:18
WORKDIR /app # all subsequent commands run from /app
COPY . . # copy everything from build context into /app
CMD ["node", "index.js"]
Step 3: Build the image
docker build -t my-node-app .
Step 4: Run the container
docker run my-node-app
Critical addition: .dockerignore
Before you build, create a .dockerignore file next to your Dockerfile to exclude sensitive or huge files from the build context:
node_modules
.git
.env
*.log
Understanding CMD and ENTRYPOINT (the #1 confusion)
Both define what command runs when the container starts, but they behave differently.
| Instruction | Default behavior | What happens when you pass arguments after the image name |
|---|---|---|
CMD only |
Runs the CMD command | The entire CMD is replaced by the arguments you typed |
ENTRYPOINT only |
Runs the ENTRYPOINT command | The arguments you typed are appended to the ENTRYPOINT |
ENTRYPOINT + CMD
|
Runs ENTRYPOINT followed by CMD
|
The CMD part is replaced; ENTRYPOINT stays and the new arguments are appended to it |
Example: making a CLI tool
ENTRYPOINT ["curl"]
CMD ["--help"]
-
docker run my-curl→ runscurl --help -
docker run my-curl https://example.com→ runscurl https://example.com(CMD overridden)
Shell form vs Exec form (a separate issue)
-
Shell form – e.g.
CMD node server.jsDocker runs it inside
/bin/sh -c. This adds a shell process in between, which can interfere with Unix signals (likeSIGTERM). Not ideal for the main process. -
Exec form – e.g.
CMD ["node", "server.js"]Docker runs the executable directly, so signals are passed straight to your application. This is the recommended form for the main process.
The one‑line takeaway
CMDcan be completely replaced;ENTRYPOINTis the fixed executable, and whatever you type after the image name becomes its arguments, overridingCMD.Always use exec form (
["..."]) for the main process.
11. What Are Docker Layers?
Every instruction in a Dockerfile creates a new layer (except some metadata-only instructions like MAINTAINER, LABEL, CMD, etc., which still add a layer but with minimal impact). A layer is essentially a filesystem diff – the changes made by that instruction compared to the previous state.
For example:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
This creates layers like:
-
Layer 1 – Base image (
node:18) – contains OS, Node.js, npm, etc. -
Layer 2 –
WORKDIR /app– sets the working directory (metadata). -
Layer 3 –
COPY package*.json ./– adds only the package files. -
Layer 4 –
RUN npm install– installs dependencies (this often creates a huge layer withnode_modules). -
Layer 5 –
COPY . .– copies the rest of your source code.
Layers are stored separately and can be shared between images.
How Layer Caching Works
When you build an image, Docker checks each instruction to see if it can use a cached layer from a previous build.
- For
COPYandADD, Docker compares the checksum of the files being copied. If the files haven’t changed, the layer is reused. - For
RUN, Docker compares the command string and the checksum of all previous layers. If both are identical, it reuses the cached layer. - If an instruction changes, that layer and all subsequent layers are rebuilt (because they depend on the previous state).
This caching can dramatically speed up builds.
Why Order Matters: The “Dependency Manifest First” Pattern
Suppose you have a typical Node.js application:
project/
├── package.json
├── package-lock.json
├── src/
│ ├── index.js
│ ├── ...
└── ...
Your Dockerfile might naively look like:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "src/index.js"]
Now imagine you change one line in src/index.js and rebuild.
- The
COPY . .instruction sees that the source files changed, so its layer is invalidated. - Because that layer changed,
RUN npm installmust also be rebuilt, even thoughpackage.jsondid not change. -
npm installmight take minutes, and it repeats every time you change any source file.
The fix: Copy only the dependency manifests first, then run install, then copy the rest.
FROM node:18
WORKDIR /app
# Copy only files needed for dependency installation
COPY package*.json ./
RUN npm install
# Now copy the rest of the application
COPY . .
CMD ["node", "src/index.js"]
Now when you change src/index.js:
- The
COPY package*.json ./layer is unchanged → cache hit. - The
RUN npm installlayer is unchanged → cache hit. - Only the final
COPY . .layer is rebuilt (which is fast).
This simple reordering can reduce build time from minutes to seconds during development.
General Best Practices for Layer Ordering
-
Put instructions that change least often at the top.
- Base image (
FROM) - System package installation (
RUN apt-get update && apt-get install ...) - Dependency manifests (
COPY requirements.txt,package.json,go.mod, etc.) - Dependency installation (
RUN pip install,npm install,go mod download)
- Base image (
-
Put instructions that change most often at the bottom.
- Copying the application source code (
COPY . .) - Building the application (if not done earlier)
- The final
CMDorENTRYPOINT
- Copying the application source code (
-
Combine related
RUNcommands to reduce layers and avoid intermediate caches.
For example, instead of:
RUN apt-get update RUN apt-get install -y curl RUN apt-get cleanDo:
RUN apt-get update && \ apt-get install -y curl && \ apt-get cleanThis also helps avoid leaving cache files in the final image.
Use
.dockerignoreto exclude unnecessary files from the build context. This preventsCOPY . .from includingnode_modules,.git, logs, etc., which would invalidate the layer more often and bloat the image.
12. Volumes (Persist Data and Share Files)
Why Volumes Exist
Containers are designed to be ephemeral and immutable. When a container is deleted, any data written inside its writable layer is lost. Volumes (and other mount types) provide a way to persist data outside the container’s lifecycle, share data between containers, or inject host files.
Docker offers several mount types:
| Type | Managed by | Lives where | Use case |
|---|---|---|---|
| Named Volume | Docker | Docker’s storage area | Databases, persistent app data |
| Bind Mount | You | Any host directory | Local development, config injection |
| Anonymous Volume | Docker | Docker’s storage (random ID) | Protecting a directory from bind mountsThe Solution: Mounts |
1️⃣ Named Volumes
Docker creates and manages the storage location. You don’t need to know the path.
docker volume create my-data
docker run -v my-data:/var/lib/postgresql/data postgres
- ✅ Survives container removal
- ✅ Easiest to back up (
docker volumecommands manage it) - ❌ You can't casually browse the files from the host
Commands:
docker volume ls # list all volumes
docker volume inspect my-data # see where Docker stores it
docker volume rm my-data # delete it (data is gone)
2️⃣ Bind Mounts
You point a container at an exact folder on your host.
docker run -v $(pwd):/app node
- ✅ Perfect for development — edit code on your laptop, see it instantly inside the container
- ✅ You can inspect/edit files directly with your normal tools
- ❌ Breaks if the path doesn't exist on whatever machine runs it (not portable)
- ❌ Can accidentally overwrite container files
3️⃣ Anonymous Volumes (the /app/node_modules trick)
You saw this line in Docker Compose examples:
volumes:
- .:/app # bind mount
- /app/node_modules # anonymous volume
What’s happening?
When you bind‑mount your host’s source code into /app, the container’s original node_modules would be replaced by whatever (or nothing) exists on your host. By adding an anonymous volume at /app/node_modules, Docker takes the container’s original node_modules, stores it in a new volume, and mounts that volume back onto the same path, shielding it from the bind mount.
This prevents OS/architecture mismatches (e.g., native modules compiled on Mac vs. Linux inside the container). Anonymous volumes are tied to the container; remove the container with -v to clean them up:
docker rm -v <container_id>
Real‑world example: Node.js + PostgreSQL (with Compose)
Let's make this concrete with a setup you'll actually build: a Node API backed by Postgres, with live code reload in dev and durable database storage.
As raw docker run commands (so you can see what's happening)
# Named volume so Postgres data survives container restarts
docker volume create pg-data
docker network create app-net
docker run -d --name db \
--network app-net \
-e POSTGRES_PASSWORD=secret \
-v pg-data:/var/lib/postgresql/data \
postgres
docker run -d --name api \
--network app-net \
-p 3000:3000 \
-v $(pwd):/app \
-v /app/node_modules \
node-api-image
Notice two -v flags on the API container:
-
v $(pwd):/app— a bind mount, so your local code changes reflect instantly inside the container -
v /app/node_modules— an anonymous volume, used here as a trick to prevent the bind mount from overwriting the container's ownnode_moduleswith your host's (which might not match the container's OS/architecture)
This is already a lot to type and remember correctly, which is exactly why this normally lives in a Dockerfile + Compose file instead.
As a Dockerfile
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
As a Compose file (this replaces ALL the commands above
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
volumes:
- .:/app # bind mount → live code reload
- /app/node_modules # anonymous volume → protects container's own deps
environment:
- DATABASE_URL=postgres://postgres:secret@db:5432/postgres
depends_on:
- db
db:
image: postgres
environment:
- POSTGRES_PASSWORD=secret
volumes:
- pg-data:/var/lib/postgresql/data # named volume → durable DB storage
volumes:
pg-data: # must be declared here for named volumes used above
Run the whole stack:
docker-compose up
Now: edit a file in your project, and the API container picks it up immediately (bind mount). Stop and remove everything with docker-compose down, then bring it back up; your Postgres data is still there (named volume), because named volumes survive even when you tear down containers. Only docker-compose down -v would also delete the named volume and wipe the database.
13. Networking (How Containers Talk)
Back in the Volumes example, you saw this line and probably skipped past it:
docker network create app-net
Let's actually explain what that does; it's the reason api was able to reach db by just using the name db as a hostname.
The Problem
Containers are isolated. Two containers can’t see each other unless connected to a shared network.
docker run -d --name db postgres
docker run -d --name api node-api-image
If api's code tries to connect to db here, it fails. They're strangers — each living in its own private network bubble.
Docker's Network Types
| Type | What it does |
|---|---|
| bridge (default) | Private internal network; containers can reach each other by IP, but IPs change |
| user-defined bridge | Same as bridge, but with automatic DNS — containers can reach each other by name |
| host | No isolation; container uses host’s network directly (Linux only) |
| none | No networking |
The one you’ll use: user‑defined bridge
docker network create app-net
docker run -d --name db --network app-net postgres
docker run -d --name api --network app-net -p 3000:3000 node-api-image
Because both containers are on app-net, Docker gives them automatic DNS: inside the api container, the hostname db resolves to the database container's internal IP. Your app's connection string can just say:
postgres://postgres:secret@db:5432/postgres
Useful Commands
docker network ls # list all networks
docker network inspect app-net # see which containers are connected, their IPs
docker network connect app-net some-container # add a running container to a network
docker network disconnect app-net some-container
docker network rm app-net # delete the network (containers must be removed/disconnected first)
Ports vs networks clarified
-
p 8080:80maps a host port to a container port — this is for outside access (browser, curl). - A shared network gives container‑to‑container communication. You don’t need to publish a database port unless you need direct host access.
Compose Does This Automatically
This is the other half of why Compose is so much nicer than raw commands: every service in a docker-compose.yml file is automatically placed on the same user-defined network, with each service name usable as a hostname, with zero docker network create needed. That's exactly what lets the Compose file in the Volumes section just write depends_on: - db and db:5432 in the connection string with no extra networking setup at all.
You can still customize this if needed:
services:
api:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
backend:
This pattern, two custom networks, is a common security practice: db sits on backend only, so it's reachable from api, but a hypothetical public-facing service on frontend could never reach db directly even if compromised.
14. Environment Variables
Environment variables are the primary way to inject configuration into containers at runtime. They allow the same image to behave differently in development, staging, and production without rebuilding.
Setting Environment Variables
A. At runtime with docker run
docker run -e NODE_ENV=production -e API_KEY=123 my-app
-
eor-envsets a variable inside the container. - You can pass multiple
eflags. - To pass a variable from your host’s environment, just reference it:
docker run -e API_KEY=$API_KEY my-app
B. In a Dockerfile with ENV
ENV NODE_ENV=production
ENV API_KEY=123
-
ENVbakes the variable into the image. It becomes the default value, but can still be overridden at runtime withe. -
ENVvalues are also available during the build process to subsequent instructions (unlikeARG).
C. In Docker Compose
services:
app:
environment:
- NODE_ENV=production
- API_KEY=${API_KEY} # from host env or .env file
- The
environmentkey can be a list (as above) or a map:
environment:
NODE_ENV: production
API_KEY: ${API_KEY}
- Variables like
${API_KEY}are substituted from the shell environment or from a.envfile located in the same directory as the Compose file.
ARG vs ENV – Build-time vs Runtime
| Instruction | Purpose | Available at build time? | Available at runtime? | Overridable at runtime? |
|---|---|---|---|---|
ARG |
Build‑time variable | Yes (with docker build --build-arg) |
No (unless you copy it to ENV) |
No |
ENV |
Set environment variable | Yes | Yes | Yes (with docker run -e) |
Example:
ARG VERSION=latest
ENV APP_VERSION=$VERSION
-
ARGlets you pass a value only during build:docker build --build-arg VERSION=1.2.3 . -
ENVmakes that value available inside the running container.
Environment Variable Precedence
If the same variable is set in multiple places, the order of precedence (highest to lowest) is:
-
docker run -eor Composeenvironment(runtime) -
ENVin Dockerfile (image default) -
ARG(only during build, not present in final image unless promoted toENV)
Example:
- Dockerfile:
ENV NODE_ENV=development - Run:
docker run -e NODE_ENV=production my-app - Inside container:
NODE_ENV=production
Using .env Files
You can load environment variables from a file into docker run using --env-file:
docker run --env-file ./config.env my-app
The file format is simple:
NODE_ENV=production
API_KEY=123
- No quotes needed unless values contain spaces.
- The file is not stored in the image, so it’s a good way to avoid baking secrets into the Dockerfile.
In Compose, a .env file in the project directory is automatically read for variable substitution, but it is not passed to containers unless you explicitly include the variables in the environment section.
Pitfalls
- All values are strings—parse numbers/booleans in your app.
- Don’t put secrets in
ENV(they end up in image layers anddocker history). -
docker inspectshows all environment variables—secrets should never be there. - Use
.envfiles for local development; never commit them to version control.
16. Docker Compose (Multiple Containers)
Used for multi‑service applications (backend, database, cache, etc.).
Example: Node + MongoDB
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- MONGO_URI=mongodb://mongo:27017/mydb
mongo:
image: mongo:6
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
Run:
docker compose up # or docker-compose up (older versions)
Stop:
docker compose down
Note: Newer Docker Desktop uses
docker compose(no hyphen). The Compose files are identical.
Real‑World Architecture
A typical production stack:
[ Browser ]
↓
[ Frontend (React) :80 ]
↓
[ Backend (Node API) :3000 ]
├──→ [ Postgres :5432 ]
└──→ [ Redis :6379 ]
All services run as containers, connected by a shared network. Persistent data (databases, uploads) use named volumes. Source code in development uses bind mounts. Secrets are injected via files or a vault.
Debugging Containers
| Need | Command |
|---|---|
| View logs | docker logs <container> |
| Follow logs | docker logs -f <container> |
| Open a shell |
docker exec -it <container> sh (or bash) |
| Watch resource usage | docker stats |
| Inspect metadata | docker inspect <name_or_id> |
Tips
- Use
docker logs --tail 50to see recent lines. -
docker execis also how you run one‑off commands (e.g., database migrations).
Resource Limits (Production Essential)
Prevent one container from exhausting the host.
Docker run
docker run -d --memory="256m" --cpus="1.5" my-app
Compose
services:
app:
deploy:
resources:
limits:
cpus: '0.50'
memory: 512M
Why it matters
A memory leak or runaway process can take down the entire server. Limits ensure isolation and predictable performance.
Health Checks (Is the Container Actually Working?)
A container can be “running” while the app inside is deadlocked or not ready. Health checks let Docker and orchestrators know the real state.
Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f <http://localhost:3000/health> || exit 1
Compose
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "<http://localhost:3000/health>"]
interval: 30s
timeout: 3s
retries: 3
db:
image: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
Usage
-
depends_onwithcondition: service_healthyensures the DB is accepting connections before the API starts. - Swarm and Kubernetes use health checks to restart unhealthy containers.
17. Multi-Stage Builds (The Production Game-Changer)
The Problem
When building a modern application, your environment needs a lot of heavy tools to compile or build your code. For example:
- In TypeScript/Node.js, you need the full TypeScript compiler, development dependencies, and build tools.
- In Go or Rust, you need a massive compiler toolchain.
- In React/Vue/Angular, you need thousands of development npm packages just to output a few static HTML, CSS, and JS files.
If you keep all those build tools in your final image, your production image becomes bloated (often 1GB+), making deployments slow and increasing your security vulnerability surface area.
The Solution
Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM instruction begins a new stage with a completely empty filesystem — it does not automatically inherit anything from the stage before it. Think of each stage as its own separate container that just happens to live in the same file.
This is the one idea that makes everything else click: nothing crosses from one stage to the next unless you explicitly copy it over. That's what makes it possible to use a heavy, bloated environment to build your app, then start completely fresh with a tiny environment for running it, taking only the finished output with you.
Real-World Example: TypeScript Node.js App
Let's see how a multi-stage Dockerfile looks in practice. Instead of two different Dockerfiles, everything happens in one file, divided into distinct stages using the AS keyword.
# ==========================================
# STAGE 1: The Build Environment
# ==========================================
FROM node:18-alpine AS builder
# WORKDIR sets the working directory inside the container for every
# command that follows. If /app doesn't exist yet, Docker creates it.
# Think of it like running `mkdir -p /app && cd /app`.
WORKDIR /app
# Copy ONLY the dependency manifest files first, before any source code.
# Why does order matter here? Docker caches each step (called a "layer").
# If package.json hasn't changed since your last build, Docker will reuse
# the cached result of `npm install` instead of running it again — even
# if you've changed your source code. This alone can save minutes per build.
COPY package*.json ./
RUN npm install
# NOW copy the rest of the source code, after dependencies are installed.
COPY . .
# Compile TypeScript to plain JavaScript. This outputs the production
# JS files into a /dist folder (this script is defined in package.json).
RUN npm run build
# package.json usually lists two kinds of dependencies:
# - "dependencies": needed to RUN the app (e.g. express)
# - "devDependencies": needed to BUILD the app (e.g. typescript, jest)
# `npm install` installs both. Now that the build is done, we no longer
# need devDependencies, so we remove them to shrink things down before
# the next stage copies this folder over.
RUN npm prune --production
# ==========================================
# STAGE 2: The Lean Production Environment
# ==========================================
# This FROM starts completely fresh — a brand new, empty filesystem.
# None of the TypeScript compiler, source code, or dev tools from
# Stage 1 exist here unless we explicitly copy them in below.
FROM node:18-alpine AS runner
WORKDIR /app
# Set environment to production (many libraries check this variable
# to disable debug logging, verbose errors, etc.)
ENV NODE_ENV=production
# CRITICAL STEP: Copy ONLY the compiled JS and production modules
# from Stage 1, by name ("builder"), not from your local laptop.
# This is the one and only bridge between the two stages.
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Run the application as a non-root user, instead of the default root.
# This specific user ("node") is pre-created inside official Node.js
# images as a security convenience — it won't exist on every base image,
# so check your base image's docs if you reuse this pattern elsewhere.
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
One more thing this example leaves out: add a
.dockerignorefile next to your Dockerfile, listing things likenode_modules,.git, and.env. Without it,COPY . .will also copy your localnode_modulesfolder and any secrets in.envstraight into the image — slowing down your build and potentially leaking credentials into a layer that ends up on a registry somewhere.
Breaking Down the Magic 🪄
-
AS builder/AS runner: This names your stages, so later stages can refer back to an earlier one by name instead of by number. -
COPY --from=builder: Instead of copying files from your local host laptop, Docker copies them directly out of the filesystem of the named stage above. This only works because that stage already ran and produced those files. - The Resulting Footprint: Stage 1 contains TypeScript compilers, local test tools, caches, and raw source code — often around 800MB, though the exact number depends on your dependencies. Stage 2 starts from a clean slate, pulls in only the lightweight compiled JavaScript and production packages, and typically finishes somewhere around 100MB.
Why This Matters for Production
- Blazing Fast Deployments: Pulling a 100MB image down to your AWS/GCP servers takes seconds; pulling a 1GB image takes minutes.
- Hardened Security: Attackers can't exploit build tools, package managers, or source code files if they don't exist inside your running production container.
Quick Recap for Beginners
If you only remember three things from this section:
- Every
FROMstarts a brand-new, empty environment — nothing carries over automatically. -
COPY --from=<stage name>is how you deliberately move specific files from one stage to another. - Put your heavy build tools in an early stage, and copy only the finished output into a small final stage.
18. Security: Running as Non‑Root
When a container starts, it normally runs as a superuser called root (like an administrator). If a hacker breaks out of the container, they might get that same superuser power on your whole computer. That's dangerous.
So we tell Docker: "Don't use root. Use a normal, limited user instead."
How to do it in a Dockerfile
If your image is based on Alpine Linux (a small, common base), create a new user and switch to it:
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuser
If you use the official Node image, there is already a user called node. Just switch:
USER node
Important things to remember
- Put
USERat the end of the Dockerfile, after you've installed packages and done things that need root. -
The app files must be readable by that normal user. If you copy files with
COPY, you can add-chownto give ownership to the user:
COPY --chown=node:node . . -
If your app writes files (logs, uploads), make sure the folder is writable by that user. Example:
RUN mkdir -p /app/data && chown node:node /app/data
Why this matters
- It limits damage if something goes wrong.
- It’s a basic safety rule for running containers in production.
Common mistakes
- Trying to use ports below 1024 (like 80) as a non‑root user – normal users usually can't. Use ports like 3000, 8080.
- Using a mounted folder from your host that has permissions your container user can't write to – you may need to adjust permissions or run the container with the same user ID.
- Some images (like
nginx) still run as root by default. Use their non‑root versions (e.g.,nginx:alpineand addUSER nginx).
Bottom line: Always switch to a non‑root user before your container runs in production.
19. Cleaning Up Docker (Reclaim Disk Space)
Docker stores images, containers, volumes, networks, and build cache. Over time, these accumulate and can eat gigabytes.
Check current disk usage
docker system df
Shows disk usage by images, containers, volumes, and build cache.
Remove unused data (safe, but careful with volumes)
| Command | What it removes | Danger level |
|---|---|---|
docker container prune |
Stopped containers only | Safe (data inside containers is lost anyway) |
docker image prune |
Dangling images (untagged, unused) | Safe |
docker image prune -a |
All images not used by a running container | Medium (you may need to re‑pull) |
docker volume prune |
Anonymous and unused named volumes | High (deletes database data!) |
docker network prune |
Unused networks | Safe |
docker system prune |
Stopped containers, dangling images, unused networks, build cache | Medium |
docker system prune -a |
Everything above plus all unused images | High (will delete many images) |
docker system prune -a --volumes |
Adds unused volumes to the previous command | Very high (deletes all unused volumes, including named ones) |
Typical workflow
Development machine (safe, frequent)
docker system prune -f # removes stopped containers, dangling images, etc.
Need to free more space (but keep images in use)
docker image prune -a # removes images not used by any container
Careful cleanup of volumes (only if you're sure)
docker volume prune
Tips
- Use
for-forceto skip confirmation. -
docker system prunedoes not remove named volumes by default (good). - To also remove named volumes, use
docker system prune --volumes– but that will wipe your databases. - Run cleanup regularly (weekly or after large builds) to keep your disk happy.
Rule of thumb:
- Prune stopped containers and dangling images often.
- Prune unused images when disk is low.
- Prune volumes only if you are absolutely sure you don't need the data.
20. Best Practices (Cheat Sheet)
- ✔ Use small base images (
alpinevariants) when possible - ✔ Always include a
.dockerignore - ✔ Order Dockerfile instructions for optimal caching (copy deps first)
- ✔ Use exec form for CMD/ENTRYPOINT
- ✔ Don’t run as root —
USERafter setup - ✔ Define resource limits in production
- ✔ Add health checks
- ✔ Use multi‑stage builds for compiled languages
- ✔ Never store secrets in images or environment variables — use secrets management
- ✔ Tag your images with specific versions (not
:latest) - ✔ Regularly prune unused resources
About This Article
This article is based on my personal notes from my portfolio site, where I document what I'm learning as I go. You can find the original notes here: Docker Handbook Notes.
Top comments (0)