87% of companies use containers in production. Here’s how to join them.
Not a projected figure. Not a marketing claim plucked from a vendor whitepaper. Datadog’s 2023 container report surveyed thousands of real organizations, and the adoption curve keeps climbing. In India alone, container job postings on Naukri jumped roughly 40% between mid-2024 and early 2026. Bangalore startups, Pune product shops, enterprise teams at Infosys and TCS — containers aren’t some future bet anymore. Everybody’s shipping with them.
So why does Docker still feel intimidating to newcomers?
Probably because most tutorials dump you straight into Kubernetes, swarm orchestration, or 47-line YAML files without ever explaining what a container actually does for you in practice. We’re going to fix that today. By the end of this tutorial, you’ll have a working multi-container application — a Node.js API backed by PostgreSQL, running on your own machine, with persistent storage and proper networking. Real code, real database, real results.
I won’t pretend every step will feel intuitive on your first pass. Docker introduces concepts that don’t quite map to anything you’ve done before. But stick with it. Once the mental model clicks, you’ll wonder how you ever deployed software without containerization.
Why Docker? The Numbers Behind the Hype
Before we touch a single command, let’s look at why Docker dominates. Understanding the “why” makes the “how” stick better.
Here’s the core problem Docker solves. You build an app on your laptop. Works perfectly. You hand it to a teammate, and something breaks — wrong Node version, missing library, different OS configuration. Deploy it to a staging server, and a third set of problems appears. Different Python path. Missing system dependency. Conflicting port.
Docker eliminates all of that. A container packages your application code, its runtime, its libraries, and its system dependencies into a single portable unit. Ship that container anywhere — your colleague’s MacBook, a CI server, an AWS instance in Mumbai — and it runs identically. No “works on my machine” excuses.
Containers vs. Virtual Machines: a quick comparison helps here.
- VMs virtualize hardware. Each VM runs a full operating system, which means gigabytes of overhead and minutes to boot.
- Containers virtualize the OS layer. They share the host kernel, so they’re megabytes in size and start in seconds.
- A typical server might run 3-5 VMs comfortably. That same server can run 50+ containers without breaking a sweat.
For most web applications, containers win on every metric that matters: startup time, resource efficiency, deployment speed, and developer experience. VMs still make sense for workloads that need full OS isolation, but for your API server and database? Containers are the right tool.
Installing Docker on Your Machine
Let’s get Docker running. Installation varies by OS, so pick your path.
On Ubuntu or Debian, run these commands in your terminal:
# Ubuntu / Debian
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Add your user to the docker group (avoids needing sudo)
sudo usermod -aG docker $USER
newgrp docker
# Verify installation
docker --version
docker compose version
On Windows or macOS, install Docker Desktop which includes everything. Download, run the installer, and you’re done. Docker Desktop also gives you a GUI for monitoring containers, which is nice when you’re starting out — though I’d recommend getting comfortable with the CLI early.
docker ps throws a permission error, that’s almost certainly why.
Verify your installation works by running docker --version. You should see something like Docker version 27.x or newer. If docker compose also responds with a version number, you’re set.
Building Your First Dockerfile: A Node.js Task API
Here’s where it gets hands-on. We’re going to containerize a Node.js Express application that talks to PostgreSQL. Not a “Hello World” — a proper CRUD API with database operations, because that’s what you’d actually build at work.
First, the application code. Create a file at src/index.js:
// src/index.js
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
});
// Initialize database table
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
}
app.get('/tasks', async (req, res) => {
const { rows } = await pool.query(
'SELECT * FROM tasks ORDER BY created_at DESC'
);
res.json(rows);
});
app.post('/tasks', async (req, res) => {
const { title } = req.body;
const { rows } = await pool.query(
'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
[title]
);
res.status(201).json(rows[0]);
});
app.patch('/tasks/:id', async (req, res) => {
const { id } = req.params;
const { completed } = req.body;
const { rows } = await pool.query(
'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
[completed, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(rows[0]);
});
app.delete('/tasks/:id', async (req, res) => {
await pool.query('DELETE FROM tasks WHERE id = $1', [req.params.id]);
res.status(204).send();
});
const PORT = process.env.PORT || 3000;
initDb().then(() => {
app.listen(PORT, () => console.log(`API running on port ${PORT}`));
});
Notice how the database connection reads from environment variables with fallback defaults. We’ll inject the real values through Docker later. Keeping config in environment variables is a pattern you’ll see everywhere in containerized apps — it’s one of the twelve-factor app principles, and it makes containers portable across environments.
Now the star of the show: the Dockerfile.
# Dockerfile
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy dependencies from the deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
# Switch to non-root user
USER appuser
# Expose port and define the startup command
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "src/index.js"]
Let me walk through what’s happening here, because this Dockerfile uses a technique called multi-stage builds — and it’s worth understanding why.
Stage 1 (deps) installs your npm packages. npm ci is the clean-install command; it’s faster and more deterministic than npm install because it uses the lockfile exactly as written.
Stage 2 (runner) creates the actual image you’ll deploy. It copies only the installed node_modules and your source code — no build tools, no package manager cache, nothing extra. Smaller image means faster pulls, less bandwidth, and a reduced attack surface.
One detail that’s easy to overlook: the USER appuser line. Running your container process as root is a known security risk. If an attacker somehow gets code execution inside your container, a non-root user limits what damage they can do. Security teams at companies like Razorpay and Zerodha enforce non-root containers as policy. Worth baking in from day one.
node:20 (full Debian) produces an image around 1.1 GB. With node:20-alpine and multi-stage builds, you’ll likely see 150-200 MB. That’s an 80%+ reduction.
Build and run the image:
# Build the image
docker build -t my-task-api:1.0 .
# List images
docker images
# Run the container (won't work yet - no database)
docker run -p 3000:3000 --name task-api my-task-api:1.0
That -p 3000:3000 flag is called port mapping. Format is host:container. Your machine’s port 3000 now forwards traffic to port 3000 inside the container. If port 3000 is already in use on your host, try -p 8080:3000 instead — you’d then access the API at localhost:8080.
Running this container alone will crash, obviously. Our API expects PostgreSQL, and there’s no database. Which brings us to Docker Compose.
Docker Compose: Wiring Multiple Containers Together
Real-world applications almost never run as a single container. You’ve got your API, a database, maybe a cache layer, possibly a message queue. Docker Compose lets you define all these services in one YAML file and spin them up together.
Create a file named docker-compose.yml in your project root:
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: taskdb
DB_USER: taskuser
DB_PASSWORD: secretpassword
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: taskdb
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: secretpassword
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
driver: local
networks:
app-network:
driver: bridge
Lots going on here. Let me break down each piece, because understanding docker-compose configuration is half the battle of working with Docker in practice.
depends_on with condition: service_healthy — Docker won’t start the API container until PostgreSQL reports healthy. Without this, your API might boot faster than Postgres, try to connect, fail, and crash. Race conditions like that are surprisingly common in multi-container setups. Ask me how I know.
volumes: pgdata:/var/lib/postgresql/data — a named volume that persists your database data. Stop the container, restart it, even rebuild the image — your data survives. Remove this line, and every docker compose down wipes your database clean. I’ve seen developers lose hours of seed data because they forgot about volume persistence. Don’t be that person.
networks: app-network — both services share a Docker bridge network. On this network, containers find each other by service name. Notice the API’s DB_HOST is set to postgres — that’s the service name, not localhost. Docker’s internal DNS handles the resolution. Clean and predictable.
healthcheck — Postgres runs pg_isready every 5 seconds. After 5 consecutive failures, Docker marks it unhealthy. Other services watching for service_healthy won’t start until the check passes.
Fire everything up:
# Start all services in detached mode
docker compose up -d
# View logs
docker compose logs -f
# View running containers
docker compose ps
# Test the API
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Docker"}'
# Stop everything
docker compose down
# Stop and remove volumes (deletes database data)
docker compose down -v
When you run docker compose up -d, Docker builds your API image (if it hasn’t already), pulls the Postgres image from Docker Hub, creates the network, creates the volume, and starts both containers. All from a single command. The -d flag runs everything in the background so you get your terminal back.
Try hitting the POST endpoint. You should get back a JSON object with an id, title, and created_at timestamp. Then GET /tasks to see it listed. Congratulations — your containerized API is talking to a containerized database.
Understanding Volumes: Where Your Data Lives
Containers are designed to be disposable. Stop one, start a new one, no big deal. But databases can’t be disposable — you need your data to survive. Volumes bridge that gap.
Docker offers three flavors:
# Named volume (managed by Docker, best for databases)
volumes:
- pgdata:/var/lib/postgresql/data
# Bind mount (maps a host directory, best for development)
volumes:
- ./src:/app/src
# tmpfs mount (in-memory, best for sensitive temporary data)
tmpfs:
- /app/tmp
Named volumes are what you want for production data. Docker manages the storage location (usually somewhere under /var/lib/docker/volumes/ on Linux). You don’t need to know or care about the exact path — just reference the volume by name.
Bind mounts map a directory from your host machine directly into the container. Change a file on your laptop, and the container sees it instantly. Perfect for development, terrible for production. We’ll use one in a moment for live-reloading.
tmpfs mounts exist only in memory. Data vanishes when the container stops. Use these for temporary files that shouldn’t hit disk — session tokens, intermediate processing data, that sort of thing.
For development, bind mounts let you edit code on your host and see changes reflected inside the container immediately. Here’s a development override file that makes that work:
# docker-compose.override.yml (auto-loaded in dev)
services:
api:
build:
context: .
target: deps # Use the deps stage, not runner
volumes:
- ./src:/app/src # Live-reload source code
environment:
NODE_ENV: development
command: npx nodemon src/index.js
Docker Compose automatically merges docker-compose.override.yml with your main file. Drop this in your project and docker compose up gives you live-reloading without modifying your production config. Neat trick that saves a lot of manual switching.
docker volume ls to see all volumes on your system. Old, forgotten volumes eat disk space quietly. Periodic cleanup with docker volume prune keeps things tidy.
Network Isolation: Controlling Who Talks to Whom
By default, Docker Compose puts all your services on one shared network. Everyone can reach everyone. For a small project, that’s fine. For anything with a frontend, an API, and a database, you probably want boundaries.
Consider a three-tier setup where the frontend shouldn’t be able to hit the database directly:
# Example: Frontend can reach API, but not database directly
services:
frontend:
image: nginx:alpine
networks:
- frontend-net
api:
build: .
networks:
- frontend-net
- backend-net
postgres:
image: postgres:16-alpine
networks:
- backend-net
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
Here, the API lives on both networks — it bridges the two tiers. Frontend can reach the API (same network), and the API can reach Postgres (same network). But the frontend container has zero visibility into backend-net. It can’t even attempt a connection to Postgres. Clean separation.
In production, this kind of network segmentation matters a lot. A compromised frontend container can’t pivot to your database. Security auditors love seeing this pattern, and it takes about ten extra lines of YAML to implement.
Essential Docker Commands You’ll Use Daily
Here’s a reference you’ll probably bookmark. I still look at this list after years of using Docker — the flags aren’t always intuitive.
# Images
docker build -t myapp:1.0 . # Build image
docker images # List images
docker rmi myapp:1.0 # Remove image
docker image prune -a # Remove unused images
# Containers
docker run -d -p 3000:3000 myapp:1.0 # Run in background
docker ps # List running containers
docker ps -a # List all containers (including stopped)
docker stop <container_id> # Stop a container
docker rm <container_id> # Remove a container
docker logs -f <container_id> # Follow container logs
docker exec -it <container_id> sh # Open a shell inside a container
# Cleanup
docker system prune -a # Remove ALL unused data (careful!)
docker volume prune # Remove unused volumes
A few of these deserve extra attention.
docker exec -it <container_id> sh drops you into a shell inside a running container. Incredibly useful for debugging. Can’t figure out why your app doesn’t see a file? Exec in and check. Environment variable not set correctly? Exec in and run env. Nine times out of ten, this command is how you’ll troubleshoot container issues.
docker system prune -a is the nuclear option. It removes all stopped containers, all unused networks, all dangling images, and all build cache. Reclaims a ton of disk space, but it also means your next docker compose up will need to rebuild images from scratch. Use it when your disk is screaming, not as a daily habit.
docker logs -f follows container output in real time, similar to tail -f. Pair it with a container name or ID. When something goes wrong, logs are always your first stop.
Common Pitfalls (and How to Dodge Them)
After helping a dozen developers at our Bangalore office adopt Docker, I’ve seen roughly the same mistakes crop up over and over. Here’s what tends to go wrong, based on that experience.
1. Running as root inside the container. We covered this in the Dockerfile section, but it’s worth repeating. Default Docker behavior runs processes as root. Always add a non-root user. Security scans will flag it, and your ops team will thank you.
2. Using latest as your image tag. FROM node:latest seems convenient, but it means your build might use Node 20 today and Node 22 next month — potentially breaking things without warning. Pin your versions. node:20-alpine gives you predictability.
3. Forgetting .dockerignore. Without a .dockerignore file, Docker copies your entire project directory into the build context — including node_modules, .git, test files, and anything else lying around. Create a .dockerignore that excludes at minimum:
node_modules.git*.md.env(never bake secrets into images)
4. Not using health checks. Without a healthcheck, Docker considers a container “running” the moment the process starts — even if it hasn’t finished booting or is in a crash loop. Health checks give you honest status reporting. Always define them for databases and services with startup delays.
5. Storing data inside the container filesystem. If your application writes to disk (logs, uploads, database files) and you haven’t mounted a volume, that data vanishes when the container stops. This catches beginners more than any other issue.
Docker in the Indian Tech Ecosystem: Where It Fits
A quick detour on context, since most of us reading this are building software in India or for Indian companies.
Flipkart runs thousands of microservices in containers. Swiggy’s engineering blog has multiple posts about their Docker and Kubernetes journey. Razorpay’s payment processing pipeline is containerized end-to-end. Freshworks, Zoho, Postman — same story. Even traditional IT services companies like Wipro and HCL now require container skills for their cloud practices.
On the job market side, a quick LinkedIn search for “Docker” in India returns over 35,000 active postings as of early 2026. Most of them don’t require Kubernetes expertise — just Docker fundamentals and Docker Compose. That’s exactly what we’ve covered today.
For freelancers and indie developers, Docker changes how you deliver projects. Hand a client a docker-compose.yml instead of a 15-page deployment guide. They run one command. Everything works. You look brilliant. Clients probably don’t care how elegant your code is, but they definitely notice when deployment is painless.
What We Covered: A Quick Map
Let’s zoom out for a moment. Here’s what you now have working knowledge of, and what each piece does:
- Dockerfile — a recipe for building an image. Multi-stage builds keep images small and secure.
- Docker image — a read-only snapshot of your application and its dependencies. Built once, runs anywhere.
- Docker container — a running instance of an image. Lightweight, isolated, disposable.
- docker-compose — a tool for defining and managing multi-container applications via YAML.
- Port mapping — connects a port on your host machine to a port inside the container.
- Volume — persistent storage that survives container restarts. Essential for databases.
- Network — a virtual network where containers discover each other by service name.
- Healthcheck — periodic probes that verify a service is actually ready, not just running.
That’s a solid foundation. You’re not a Docker expert yet — I’m certainly not claiming you will be after one tutorial. But you’re past the hardest part, which is getting from zero to a working multi-container setup. Everything after this is incremental.
Your Containerization Checklist: What to Docker Next
Knowledge without action doesn’t ship software. Here’s a concrete checklist to keep you moving forward. Print it out, pin it to your monitor, work through it over the next couple of weeks.
- Containerize one existing project. Pick something you’ve already built — a personal project, a college assignment, that side project collecting dust. Write a Dockerfile for it. Get it running inside a container.
- Add a database with Docker Compose. If your project uses a database, add it as a second service. Configure volumes for persistence. Verify data survives a
docker compose downanddocker compose up. - Create a
.dockerignorefile. Check your image size before and after. You’ll likely shave off hundreds of megabytes. - Run as non-root. Add a non-root user to your Dockerfile. Test that the app still works. Run
docker execand verify withwhoami. - Set up a dev override. Create a
docker-compose.override.ymlwith bind mounts and live-reloading. Edit a file on your host. Confirm the container picks up the change. - Practice the essential commands. Build, run, stop, remove, exec, logs. Muscle memory matters. Run through the command reference from this tutorial at least three times.
- Push an image to Docker Hub. Create a free account, tag your image, push it. Pull it on a different machine. Verify it runs identically.
- Explore network isolation. Set up a three-service stack (frontend, API, database) with separate networks. Verify the frontend can’t reach the database directly.
- Measure your image size. Compare
node:20vsnode:20-alpinevs multi-stage builds. Note the differences. Aim for under 200 MB for a Node.js API. - Read one Docker blog post per week. Docker’s official blog, Datadog’s container reports, and engineering blogs from Indian tech companies (Swiggy, Razorpay, Freshworks) are all excellent sources.
Containers aren’t going anywhere. If anything, adoption data suggests they’re becoming as fundamental to backend development as version control. You don’t need to learn everything at once — nobody does. But getting this first application containerized? That’s the step that separates “I’ve heard of Docker” from “I use Docker.” And you’ve just taken it.
Go containerize something.