Docker Finally Made Sense When I Stopped Reading the Docs

TL;DR

Docker finally clicked for me when I stopped reading documentation and started thinking about it as “bundle your app with everything it needs to run, send the bundle anywhere, run it the same way everywhere.” You write a Dockerfile, build an image, run containers. This post walks through Docker tutorial fundamentals with real examples so you go from zero to actually deploying something in 20 minutes.

The Day Docker Made Sense

I’d been trying to understand Docker for months. Watched talks, read tutorials, saw diagrams of layers and registries. Still didn’t get it. Then I broke a developer’s laptop by updating Node from 16 to 18. Their app broke. Everyone else’s worked fine.

That’s when someone said: “Docker puts your app and every dependency in a sealed box. You run the same box on your laptop, the CI server, and production. No ‘works on my machine’ ever again.”

Suddenly it made sense. It’s not about containers or orchestration or Kubernetes. It’s about reproducibility. You define the environment once, bundle your app with it, and run it everywhere identically.

What Docker Actually Is

Docker is a tool for packaging your application with all its dependencies into a standard unit called a container. It works by reading a Dockerfile (a recipe) that describes your app’s environment, building an image (a blueprint), and running containers (actual running instances). The key benefit is that what runs on your laptop runs identically in production.

Think of it like shipping: instead of shipping loose parts (Node, npm modules, config files), you pack everything into a container. Someone receives the container, runs it, everything works. No “I don’t have the right version of Node” or “it works on my machine but not yours.”

There are three core concepts:

  • Dockerfile: A recipe describing how to build your environment
  • Image: A blueprint created from the Dockerfile
  • Container: A running instance of an image

You write the Dockerfile once, build the image once, run containers as many times as you want, on any machine that has Docker.

Writing Your First Dockerfile

Let’s say you have a simple Node.js app. You need Node installed, npm dependencies, your code, and it should run when the container starts.


# Start from official Node image
FROM node:18-alpine

# Set working directory inside container
WORKDIR /app

# Copy package files
COPY package.json package-lock.json ./

# Install dependencies
RUN npm install

# Copy application code
COPY . .

# Expose port
EXPOSE 3000

# Start the app
CMD ["node", "index.js"]

Let’s break this down:

FROM node:18-alpine: Base image. Alpine is a minimal Linux distribution (~5MB), so images stay small. The official Node image includes Node and npm.

WORKDIR /app: Everything that follows happens in /app inside the container.

COPY package.json package-lock.json ./: Copy dependency files from your machine into the container.

RUN npm install: Execute npm install inside the container. Dependencies are installed and included in the image.

COPY . .: Copy your entire application code.

EXPOSE 3000: Document that the app listens on port 3000. (Doesn’t actually open the port; that happens when you run the container.)

CMD [“node”, “index.js”]: The command to run when the container starts.

Now build the image:


docker build -t my-app:1.0 .

The -t flag names the image. The . means “use the Dockerfile in the current directory.”

Now run a container from that image:


docker run -p 3000:3000 my-app:1.0

The -p flag maps port 3000 on your machine to port 3000 in the container. Navigate to localhost:3000 and your app runs—exactly as it would in production.

Docker Tutorial: Building a Real Application

Let’s build a slightly more complex example: an Express app that serves a simple API.

Your app structure:


my-api/
├── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile

app code (index.js):


import express from 'express';

const app = express();

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/data', (req, res) => {
  res.json({ message: 'Hello from Docker', timestamp: new Date() });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

package.json:


{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.0"
  }
}

.dockerignore (files to exclude from the image):


node_modules
npm-debug.log
.git
.env

Dockerfile:


FROM node:18-alpine

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm install --production

COPY . .

EXPOSE 3000

HALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)}) 

CMD ["npm", "start"]

Build and run:


# Build
docker build -t my-api1.0 .

# Run
docker run -p 3000:3000 my-api:1.0

# Test
curl http://localhost:3000/api/data

# List running containers
docker ps

# Stop the container
docker stop <container-id>

Notice the HEALTHCHECK. Docker will periodically check that the health endpoint returns 200. If it fails, Docker marks the container as unhealthy. This is useful for monitoring.

Optimizing Docker Images: Layers and Caching

Docker builds images in layers. Each command (FROM, COPY, RUN) creates a layer. Docker caches layers, so if you rebuild the image, unchanged layers are reused, making rebuilds fast.

Here’s the key: put commands that change frequently at the end, and commands that rarely change at the beginning.


# Good order: This image rebuilds quickly
FROM node:18-alpine         # Layer 1 (cached)
WORKDIR /app                # Layer 2 (cached)
COPY package.json .         # Layer 3 (cached until dependencies change)
RUN npm install             # Layer 4 (cached until dependencies change)
COPY . .                    # Layer 5 (rebuilt every time code changes)
CMD ["npm", "start"]        # Layer 6

When you change your code and rebuild, Docker reuses layers 1-4 from cache and only rebuilds layer 5. Fast.

Compare to putting COPY . . early:


# Bad order: Image rebuilds slowly every time
FROM node:18-alpine
COPY . .                    # Layer 2 (rebuilds every time code changes)
RUN npm install             # Layer 3 (rebuilds every time)

Now every code change forces npm install to re-run. Slow.

Running Containers: Common Commands


# Build an image
docker build -t app-name:version .

# Run a container (foreground)
docker run -p 8080:3000 app-name:version

# Run in background (detached)
docker run -d -p 8080:3000 --name my-container app-name:version

# See running containers
docker ps

# See all containers (including stopped)
docker ps -a

# View logs
docker logs my-container

# View logs in real-time
docker logs -f my-container

# Enter a running container (interactive shell)
docker exec -it my-container sh

# Stop a container
docker stop my-container

# Remove a container
docker rm my-container

# Remove an image
docker rmi app-name:version

# View image size
docker images

Environment Variables and Configuration

Your app shouldn’t have hardcoded database URLs or API keys. Pass them as environment variables.

In your app code:


const dbUrl = process.env.DATABASE_URL || 'localhost:5432';
const apiKey = process.env.API_KEY;

if (!apiKey) {
  console.error('API_KEY not set');
  process.exit(1);
}

Pass environment variables when running:


docker run \
  -e DATABASE_URL=postgres://prod:5432 \
  -e API_KEY=secret123 \
  -p 3000:3000 \
  my-app:1.0

Or use a .env file:


docker run \
  --env-file .env.production \
  -p 3000:3000 \
  my-app:1.0

Multi-Stage Builds: Keeping Images Small

For frontend apps, you need Node to build (transpile, bundle) but not to run. Multi-stage builds let you build in one stage and copy only the final artifacts to another stage.


# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build  # Produces dist/ folder

# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --production  # Skip dev dependencies
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

The builder stage can be huge (all dev dependencies, build tools). But only the final image matters. You copy dist/ from the builder into the runtime image. The final image is small because it has only production dependencies.

Typical size reduction: 800MB → 150MB.

Pushing Images to a Registry

So far you’ve built images locally. To deploy, push them to a registry (Docker Hub, AWS ECR, GitHub Container Registry, etc.).

Docker Hub example:


# Tag image for Docker Hub
docker tag my-app:1.0 myusername/my-app:1.0

# Login
docker login

# Push
docker push myusername/my-app:1.0

# Now anyone can pull and run:
# docker run -p 3000:3000 myusername/my-app:1.0

When NOT to Use Docker

Docker is powerful but not always necessary.

Don’t use Docker if: Your app is simple (no dependencies), you’re deploying to a simple host (single server, no cluster), or your team doesn’t understand containers yet (steeper learning curve).

Do use Docker if: You have multiple dependencies (Node, Redis, PostgreSQL), you’re deploying to multiple environments (dev, staging, production), or you plan to scale (multiple servers, orchestration).

Docker shines when you have complexity. For simple apps, you might be overengineering.

Common Docker Mistakes

Running as root. By default, containers run as root. If someone breaks out of the container, they have full access. Run as a non-root user:


FROM node:18-alpine
RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser
USER appuser
COPY . .
CMD ["node", "index.js"]

Including unnecessary files. Every file increases image size and attack surface. Use .dockerignore to exclude node_modules, .git, etc.

Not versioning images. If you push “my-app:latest” every time, you don’t know which version is running where. Use specific versions: “my-app:1.0.5”.

Forgetting to set EXPOSE. It doesn’t actually open the port, but it documents which ports your app uses. Helpful for other developers.

FAQ

Is Docker the same as virtual machines?

No. VMs are full operating systems (GB in size). Containers are lightweight processes (MB in size) that share the host kernel. Containers start in milliseconds; VMs in seconds.

Can I run Docker on Windows or Mac?

Yes. Docker Desktop (Docker’s official tool) runs on Windows, Mac, and Linux. On Windows and Mac, it runs a lightweight Linux VM behind the scenes.

Why use Alpine Linux instead of Ubuntu?

Alpine is 5MB; Ubuntu is 100MB+. For Docker, smaller is better. Alpine has everything you need, just no bloat. Some native modules might not have Alpine builds, but for most things, Alpine is fine.

How do I debug a container that’s failing?

docker logs container-id shows output. docker exec -it container-id sh gives you a shell inside. docker build –no-cache forces a rebuild without caching (useful for debugging RUN commands).

Should I commit containers to create images?

No. Always build from a Dockerfile. Commits are manual and not reproducible. Dockerfiles are version-controlled and repeatable.

What’s the difference between CMD and ENTRYPOINT?

CMD specifies the default command to run. ENTRYPOINT specifies the command that always runs, and CMD are arguments to it. For most cases, just use CMD.

Can I use Docker for development?

Yes. Mount your code as a volume so changes are reflected in the container instantly: docker run -v $(pwd):/app my-app. But it adds complexity. For development, just run Node locally until you need to match production exactly.

The moment Docker clicked for me was realizing it’s not about technologi—it’s about reproducibility. You define the environment once, and it works everywhere. That solves “works on my machine” forever. Start simple: write a Dockerfile, build an image, run a container. Once you get that, the rest (registries, orchestration, clustering) is just layering tools on top.

Facebook
Twitter
LinkedIn
Pinterest

Leave a Reply

Your email address will not be published. Required fields are marked *

DevelopersCodex

Real-world dev tutorials. No fluff, no filler.

© 2026 DevelopersCodex. All rights reserved.