ismile@portfolio:~$ cat notes/0008-docker-guide.md
Docker Guide
A simple, structured guide to understanding Docker from zero to building and running real applications.
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 Linuxpostgres:15– PostgreSQL database servernginx– 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.ioVerify:
docker --version
docker compose version # new plugin, replaces docker-compose5. Your First Docker Command
Run your first container:
docker run hello-worldWhat 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 imagesPull image
docker pull nodeBuild 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.0Push to Docker Hub
docker login
docker push username/my-app:v1.0Remove image
docker rmi node7. Working with Containers
Run a container in the foreground
docker run nginxPress Ctrl+C to stop.
Run in the background (detached)
docker run -d nginxList running containers
docker psList all containers (including stopped)
docker ps -aStop 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 nginx8080→ your machine’s port80→ 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 ubuntuStep 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 --versionStep 4: Write a tiny server (still inside the container)
apt install -y nano
nano server.jsPaste:
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 IDNow save the current state of that container as a new image:
docker commit <container_id> my-manual-node-imageNext 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 -yCreate 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-appCritical 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
*.logUnderstanding CMD and ENTRYPOINT (the #1 confusion)
Both define what command runs when the container starts, but they behave differently.
| Instruction | Purpose | Override at docker run |
|---|---|---|
CMD |
Default command (or default args) | Completely replace with anything you type after the image name |
ENTRYPOINT |
Fixed command; the container is this executable | Arguments you pass are appended, not replaced |
Example: making a CLI tool
ENTRYPOINT ["curl"]
CMD ["--help"]
docker run my-curl→ runscurl --helpdocker run my-curl https://example.com→ runscurl https://example.com(CMD overridden)
Shell form vs Exec form
- Shell form:
CMD node server.js– runs inside/bin/sh -c, which swallows Unix signals. Avoid for main processes. - Exec form:
CMD ["node", "server.js"]– recommended; signals are forwarded, and graceful shutdown works.
Always prefer the exec form.
11. Docker Layers (Important Concept)
Each instruction in a Dockerfile creates a new layer. Layers are cached and reused.
Layer 1: FROM node:18 (base OS + Node)
Layer 2: WORKDIR /app (metadata only)
Layer 3: COPY package*.json ./
Layer 4: RUN npm install (dependencies)
Layer 5: COPY . . (your source code)
Why order matters:
If you change index.js but not package.json, Docker reuses the cached layer from npm install — saving minutes per build. Always copy dependency manifests first.
12. Volumes (Persist Data and Share Files)
The Problem
Containers are ephemeral — when removed, everything written inside them disappears. That’s fatal for databases. Volumes solve this.
Docker offers four kinds of mounts:
| 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 |
| tmpfs Mount | Docker (RAM) | Memory, never disk | Temporary secrets, in‑memory caches |
| 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️⃣ tmpfs Mounts
Stored in RAM, never touches disk, gone when the container stops.
docker run --tmpfs /app/secrets nodeUse for sensitive temporary data (Linux/Docker Desktop only).
4️⃣ Anonymous Volumes (the /app/node_modules trick)
You saw this line in Docker Compose examples:
volumes:
- .:/app # bind mount
- /app/node_modules # anonymous volumeWhat’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-imageNotice two -v flags on the API container:
v $(pwd):/app— a bind mount, so your local code changes reflect instantly inside the containerv /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 aboveRun the whole stack:
docker-compose upNow: 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-netLet's actually explain what that does, because 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-imageIf 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-imageBecause 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 let 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
Pass configuration at runtime:
docker run -e NODE_ENV=production -e API_KEY=123 my-appOr in Dockerfile:
ENV NODE_ENV=productionIn Compose:
environment:
- NODE_ENV=production
- API_KEY=${API_KEY} # from host env or .env file⚠️ Avoid hard‑coding secrets. For production, use Docker secrets or an external vault.
15. Secrets Management (Don’t Bake Passwords)
Environment variables like -e POSTGRES_PASSWORD=secret are visible in docker inspect. For production, use Docker Compose secrets:
services:
db:
image: postgres
secrets:
- db_password
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./password.txtThe secret is mounted as a file, never exposed in the environment or image layers.
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 upStop:
docker-compose downNote: Newer Docker Desktop uses docker compose (no hyphen). The Compose files are identical.
17. 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.
18. Debugging Containers
Logs
docker logs <container_id>
# Follow logs
docker logs -f <container_id>Open a shell inside a running container
docker exec -it <container_id> sh # or bashCheck resource usage
docker statsInspect image or container metadata
docker inspect <name_or_id>18. Resource Limits (Production Essential)
Prevent a single container from hogging the host:
docker run -d --memory="256m" --cpus="1.5" my-appIn Docker Compose (version 3.x+):
services:
app:
deploy:
resources:
limits:
cpus: '0.50'
memory: 512MWithout limits, a memory leak in one container can crash the entire server.
19. Health Checks (Is the Container Actually Working?)
A container can be running, but the app inside might be deadlocked. Docker can’t know unless you define a health check.
In a Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1In 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"]
...Then use depends_on with condition: service_healthy to ensure the database is accepting connections before the API starts.
20. 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.
21. Security: Running as Non‑Root
By default, containers run as root. If an attacker escapes the container, they get root on the host. Always drop privileges.
In your Dockerfile, after copying everything, add:
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuserFor official Node images, a node user already exists, so just:
USER node22. Cleaning Up (Reclaim Disk Space)
Docker accumulates unused images, containers, and volumes.
- See disk usage:
docker system df- Remove everything not currently running:
docker system prune -a- Remove only volumes (careful, this deletes databases):
docker volume pruneMake this a habit, especially on development machines.
23. 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