Docker - From Basics to Multi-Stage Builds

If you have ever spent hours troubleshooting why an application runs perfectly on your laptop but crashes the moment it gets deployed to a server, you are not alone. It is a frustrating experience that almost every developer faces.
Thankfully, tools like Docker exist to solve this exact problem. In this post, my goal is to share a simple, practical look at how Docker works, using a regular Node.js application. We will start from the absolute basics and work our way up to optimizing our containers for production.
I hope this guide helps clarify these concepts for you.
At its core, Docker is a tool that allows us to package our application, along with its specific environment, dependencies, and configurations, into a single isolated unit. Why not just use a Virtual Machine (VM)?
Before containers became popular, we used Virtual Machines to isolate applications. While VMs work, they are incredibly heavy.
Before writing any code, it helps to understand two terms that often confuse people at first:
Let’s look at a practical example. Imagine we have a simple Node.js application written in TypeScript. To tell Docker how to package this app, we write a special text file named Dockerfile (with no file extension) in our project root.
Here is what a basic, straightforward Dockerfile looks like:
# Step 1: Use an official Node.js image as our base environment FROM node:20 # Step 2: Set the working directory inside the container WORKDIR /app # Step 3: Copy package files first to leverage Docker's caching system COPY package*.json ./ # Step 4: Install our dependencies (including TypeScript devDependencies) RUN npm install # Step 5: Copy the rest of our application code COPY . . # Step 6: Compile our TypeScript into JavaScript RUN npm run build # Step 7: Expose the port our app listens on EXPOSE 3000 # Step 8: Define the command to start our app CMD ["node", "dist/index.js"]
To turn this blueprint into a running container, we open our terminal and run two simple commands:
docker build -t my-node-app .
(The -t flag gives our image a friendly name, and the . tells Docker to look for the Dockerfile in the current directory).
docker run -d -p 3000:3000 --name my-running-app my-node-app
(The -d runs it quietly in the background, and -p 3000:3000 connects port 3000 of your computer to port 3000 inside the container).
To see inside your running container or test it, you can execute a command to access its terminal:
docker exec -it my-running-app sh
As our apps grow, we usually need more than just our own code. We might need a database, a caching server, or other services to run alongside it.
Dockerfile: This is meant for building a single specific image for your application.
Docker Compose: This is an orchestration tool used to launch and connect multiple containers together using a single, clear configuration file (docker-compose.yml). Instead of typing long terminal commands to connect your Node.js app to a database, Docker Compose does it automatically.
Let's say our Node.js app needs a PostgreSQL database to store data. Writing complex terminal commands to connect them would be tedious. Instead, we can create a docker-compose.yml file in our root folder:
version: '3.8' services: # Our Node.js application service web: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgres://user:password@db:5432/mydb depends_on: - db # Our PostgreSQL database service db: image: postgres:15 environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=mydb ports: - "5432:5432"
To bring this entire system to life, you only need to run one polite command in your terminal:
docker compose up -d
Docker Compose will automatically pull the official Postgres image, build your local Node.js app, and gracefully place them on the same private network so they can talk to each other safely.
If we inspect the image we built in section 3, we will notice it is quite large (often over 1GB). This is because it includes the entire TypeScript compiler, our original source code, and all the original node_modules used during development.
In production, we only need the compiled JavaScript files (dist/) and the raw production dependencies.
This is where a Multi-Stage Build shines. It allows us to use a large "builder" environment to compile our code, and then copy only the compiled results into a fresh, tiny, lightweight production environment.
Here is how we can gently optimize our Dockerfile:
# --- STAGE 1: The Builder --- FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build # --- STAGE 2: The Production Runner --- # We use a slimmed-down image to keep things lightweight FROM node:20-slim AS runner WORKDIR /app # Copy package files again COPY package*.json ./ # Only install production dependencies, skipping heavy development tools RUN npm prune --production # Carefully copy only the compiled JavaScript files from the builder stage COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/index.js"]
By separating the build process from the execution process, our final production image size can be reduced by 80% or more. This makes deployments faster, saves server space, and reduces security vulnerabilities by removing unnecessary tools from production.
Docker doesn't have to be overwhelming. By breaking it down from a single recipe (Dockerfile), to a full kitchen management tool (Docker Compose), and finally to a clean presentation (Multi-Stage builds), we can create incredibly reliable software environments.
Thank you for taking the time to read this. I hope it brings some clarity to your development journey.
As always, I may miss some points, so if you have any questions or suggestions, please feel free to share them with me. If you know a better approach, I’d love to hear about it. I’m always open to learning and improving my knowledge.
Stay humble and keep learning!