How to Dockerize an Application in 15 Minutes: A Practical Walkthrough
Your app runs fine on your laptop and breaks on the server. A teammate spends an afternoon chasing a missing dependency. The staging box has the wrong Node version. Every one of these is the same problem: the environment isn't part of the app.
That's exactly what containers fix. In this walkthrough you'll learn how to dockerize an application from an empty folder to a running container, using a small Node.js API as the example. The same shape works for Python, Go, or a plain static site.
By the end you'll have a leanDockerfile, a proper .dockerignore, a multi-stage build that keeps the image small, and a one-command local stack with Docker Compose.
[!TIP] TL;DR — Add aDockerfileand.dockerignore, build withdocker build -t myapp ., run withdocker run -p 3000:3000 myapp, then use a multi-stage build and Compose to keep it small and reproducible.
What you need before you start
You need three things: Docker installed (docker --version should print a version), an app that runs locally, and the command that starts it. That's it. Everything else lives in the files we're about to write.
Our example is a tiny Express server. If your stack is different, only the base image and the start command change — the pattern is identical.
// server.js
import express from "express";
const app = express();
app.get("/", (_req, res) => res.send("Hello from a container"));
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.listen(3000, () => console.log("listening on :3000"));
1. Write a minimal Dockerfile
ADockerfile is a recipe: start from a base image, copy your code in, install dependencies, and declare how the app starts. Create it at the project root.
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Two details matter more than they look:
- Copy
package*.jsonbefore the rest of the code. Docker caches each layer. As long as your dependencies don't change,npm ciis reused from cache and rebuilds stay fast. node:20-alpineis a small base image. Alpine keeps the footprint tiny, which means faster pulls and less to patch.
[!NOTE]EXPOSEis documentation — it doesn't publish the port. The actual mapping happens atdocker runtime with-p. We'll get there in step 4.
2. Add a .dockerignore
Without this file,COPY . . drags your entire node_modules, local .env, and .git history into the build context. That bloats the image and can leak secrets.
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
Think of .dockerignore as .gitignore for your build. It makes builds faster and images cleaner, and it's the single most-skipped step when people first learn how to dockerize an application.
3. Build the image
From the project root, build and tag the image. The-t flag names it so you can refer to it later; the . tells Docker to use the current directory as the build context.
docker build -t myapp:1.0 .
When it finishes, confirm it exists:
docker images myapp
You'll see your image with the 1.0 tag and its size. Tagging with a real version (not just latest) is a habit worth forming early — it makes rollbacks a one-word change.
4. Run the container
Start a container from the image and publish the port so your host can reach it. The-p 3000:3000 maps host port 3000 to the container's port 3000.
docker run -d --name myapp -p 3000:3000 myapp:1.0
Check that it's alive:
curl http://localhost:3000/health
You should get {"status":"ok"}. To watch logs or stop it:
docker logs -f myapp
docker stop myapp
[!IMPORTANT]Containers are disposable. Never store data you care about inside one — it disappears when the container is removed. Use a volume or an external database for anything that must survive a restart.
5. Shrink it with a multi-stage build
The first Dockerfile works, but it ships build tooling you don't need at runtime. A multi-stage build compiles in one stage and copies only the finished artifacts into a clean final image.
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
The runtime stage never sees your dev dependencies or source tree — only the built output. The difference is real:
| Approach | Ships dev deps? | Typical image size |
|---|---|---|
| Single-stage | Yes | **~400 MB** |
| Multi-stage | **No** | **~150 MB** |
Smaller images pull faster, expose less surface area, and cost less to store in a registry.
6. Run the whole stack with Docker Compose
Most real apps aren't alone — they need a database or a cache. Docker Compose describes every service in one file so the whole stack starts with a single command.
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Bring everything up — build included — with one command:
docker compose up --build
Compose puts both containers on a shared network, so your app reaches Postgres at the hostname db. The named db-data volume keeps your database intact across restarts. Tear it all down with docker compose down when you're finished.
[!WARNING]
The inline passwords above are fine for local development only. In production, pull secrets from environment variables or a secrets manager — never commit real credentials to docker-compose.yml.
Common mistakes to avoid
A handful of missteps trip up almost everyone the first time they containerize an app. Knowing them upfront saves you the debugging.
- Running as root. By default the container runs as root, which is a security risk. Add a non-root user and switch to it before
CMD:RUN adduser -D app && chown -R app /appthenUSER app. - Copying code before dependencies. If you
COPY . .before installing packages, every source edit busts the dependency cache and every build reinstalls from scratch. Copy the manifest first, as in step 1. - Baking secrets into the image. Anything you
COPYorENVinto an image is readable by anyone who pulls it. Pass secrets at runtime with-eor an env file, never at build time. - Ignoring image size. A 1 GB image is slow to push, pull, and deploy. A multi-stage build and an Alpine base — both covered above — usually cut that by more than half.
- No health check. Without one, your orchestrator can't tell a hung container from a healthy one. Add
HEALTHCHECK CMD curl -fsS http://localhost:3000/health || exit 1so failures are visible.
Fix these five and your images are already better than most you'll find in the wild.
Wrapping Up
You've gone from an app that only ran on your machine to one that runs the same way anywhere Docker does: a leanDockerfile, a .dockerignore that keeps builds clean, a multi-stage build that cuts image size, and a Compose file that boots the whole stack in one command. That's the core of how to dockerize an application, and it scales from a side project to production.
The next step is shipping it — wiring this image into CI so every push builds, tags, and deploys a container automatically.
Building something similar? Browse more blank" class="text-primary hover:text-accent underline decoration-primary/30 hover:decoration-accent transition-colors">projects or get in touch if you'd like a hand containerizing your stack.

