How to Dockerize an Application in 15 Minutes: A Practical Walkthrough

Learn how to dockerize an application step by step: write a lean Dockerfile, ignore junk, build a small image, and run your whole stack with one command.
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 lean Dockerfile, 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 a Dockerfile and .dockerignore, build with docker build -t myapp ., run with docker 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
A Dockerfile 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
EXPOSE is documentation — it doesn't publish the port. The actual mapping happens at docker run time 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
.dockerignoreThink 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 myappYou'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.0Check that it's alive:
curl http://localhost:3000/healthYou should get {"status":"ok"}. To watch logs or stop it:
docker logs -f myapp
docker stop myappImportant
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 --buildCompose 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 lean Dockerfile, 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, automating your SSL certificates so the container is actually reachable over HTTPS, and putting a real service behind it. I used this exact pattern to self-host my own Firebase alternative.
Building something similar? Browse more projects or get in touch if you'd like a hand containerizing your stack.
Related Posts
Related Articles

Rate Limiting and Caching at the API Gateway Layer: Protecting Your Backend From Its Own Traffic
Most outages aren't attacks, they're your own traffic overwhelming itself. A concept-first look at how rate limiting and caching work together at the API gateway to keep one misbehaving client, integration, or traffic spike from degrading service for everyone else.

The Backup Strategy That Actually Protects Your VPS Data (And Why Automation Isn't Optional)
Manual backups fail under pressure, and unmonitored automated ones fail silently. A layered VPS backup strategy, the 3-2-1 rule, application-aware dumps, encrypted off-site transfer, dead-man's-switch monitoring.

Backup Strategy for a Dockerized MongoDB Replica Set: Disaster Recovery You've Actually Tested
A replica set isn't a backup — it protects against a dead node, not a dropped collection or a bad migration. Here's the mongodump/oplog setup I actually run against a Dockerized MongoDB replica set, why restore testing is the step everyone skips, and when to graduate to filesystem snapshots or PBM i
