Docker packages an application and everything it needs into a container. Kubernetes runs and manages many of those containers across machines. In this tutorial you will build a small Node.js app, package it with Docker, deploy it to a local Kubernetes cluster, scale it, update it and roll it back. No cloud account is needed.
What you need: Docker Desktop (or Docker Engine), Node.js (current LTS), kubectl, and kind (or minikube). Allow about 30 minutes.
What Is Containerization?
Containerization packages an application with its code, runtime, libraries and configuration into one isolated unit called a container. Because the container carries its own dependencies, it behaves the same on a laptop, a CI server or a cloud VM. That removes the classic “it works on my machine” problem and makes builds, tests and releases more predictable.
Containers are not virtual machines. A virtual machine includes a full guest operating system. A container shares the host’s kernel, so it is much smaller, starts in seconds and lets you run more workloads on the same hardware.
| Virtual machine | Container | |
|---|---|---|
| Includes | Full guest OS | App and its dependencies only |
| Size | Gigabytes | Usually tens to hundreds of MB |
| Startup | Minutes | Seconds |
| Isolation | Strong (hardware level) | Process level, shares host kernel |
Docker vs Kubernetes: What Is the Difference?
Docker and Kubernetes solve different problems, and most teams use both.
| Docker | Kubernetes | |
|---|---|---|
| Main job | Build images and run containers | Run and manage containers across a cluster |
| Scope | One machine | Many machines (nodes) |
| Scaling | Manual, or Compose on one host | Built in: replicas, autoscaling |
| Self-healing | Restart policies only | Replaces failed containers, reschedules on healthy nodes |
| Updates | Stop and start | Rolling updates and rollbacks |
| Typical use | Local development, CI builds | Production workloads |
In short, Docker builds and runs the container, and Kubernetes decides where containers run and keeps them healthy. One detail often confuses beginners: Kubernetes no longer uses Docker Engine as its runtime (it uses containerd or CRI-O), but images you build with Docker follow the OCI standard and run on Kubernetes unchanged.
Step 1: Create a Simple Node.js App
Create a project folder and initialize it:
mkdir my-docker-app
cd my-docker-app
npm init -y
npm install express
Create app.js:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('Hello, Docker and Kubernetes!');
});
// Used later by Kubernetes to check the app is ready
app.get('/health', (req, res) => {
res.status(200).send('ok');
});
app.listen(port, () => {
console.log(`App running on port ${port}`);
});
In package.json, make sure the scripts section contains a start command:
"scripts": {
"start": "node app.js"
}
Run npm start and open http://localhost:3000 to confirm it works, then stop it with Ctrl+C.
Step 2: Dockerize the App
Create a file named Dockerfile (capital D, no extension):
# Use a current LTS Node.js image. Check nodejs.org for the supported LTS line.
FROM node:24-alpine
WORKDIR /app
# Copy dependency files first so Docker can cache this layer
COPY package*.json ./
RUN npm ci --omit=dev
# Copy the rest of the source code
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
# Run as the unprivileged user that ships with the Node image
USER node
CMD ["node", "app.js"]
Why it is written this way:
node:24-alpineis a small, maintained base image. Old tutorials usenode:14, which reached end of life in 2023 and no longer receives security fixes.- Copying
package*.jsonfirst lets Docker reuse the cached dependency layer when only your source code changes, which makes rebuilds much faster. npm ci --omit=devinstalls exact versions frompackage-lock.jsonand skips development packages.USER nodeavoids running your app as root inside the container.
Create a .dockerignore file so you do not copy unnecessary files into the image:
node_modules
npm-debug.log
.git
Dockerfile
.dockerignore
Build and run the image:
docker build -t my-node-app:1.0 .
docker run -d --name my-node-app -p 3000:3000 my-node-app:1.0
Note the space and dot at the end of the build command: the dot tells Docker to use the current folder as the build context. Open http://localhost:3000 and you should see the greeting from inside the container.
Useful commands while you work:
docker ps # list running containers
docker logs my-node-app # view app output
docker stop my-node-app # stop the container
docker rm my-node-app # remove it
Stop and remove the container before moving on so port 3000 stays free.
Kubernetes Concepts You Need
You only need four terms for this tutorial:
- Cluster: a set of machines (nodes) managed by Kubernetes.
- Pod: the smallest deployable unit. It wraps one or more containers. Pods are disposable.
- Deployment: declares how many copies (replicas) of a Pod should run and handles rolling updates.
- Service: gives a group of Pods one stable address, because individual Pod IPs change.
You describe the state you want in YAML files, and Kubernetes works continuously to make reality match it.
Step 3: Create a Local Kubernetes Cluster
Install kind (Kubernetes in Docker) and kubectl, then create a cluster:
kind create cluster --name demo
kubectl cluster-info --context kind-demo
A kind cluster cannot see images in your local Docker, so load the image into it:
kind load docker-image my-node-app:1.0 --name demo
Using minikube instead? Run minikube start, then minikube image load my-node-app:1.0. Docker Desktop’s built-in Kubernetes also works.
Step 4: Write the Deployment and Service
Create deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-node-app
labels:
app: my-node-app
spec:
replicas: 2
selector:
matchLabels:
app: my-node-app
template:
metadata:
labels:
app: my-node-app
spec:
containers:
- name: my-node-app
image: my-node-app:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
This asks Kubernetes for two replicas of your image. The readiness probe stops traffic from reaching a Pod until /health responds, and the resource settings stop one container from starving the others.
Create service.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-node-app
spec:
type: ClusterIP
selector:
app: my-node-app
ports:
- port: 80
targetPort: 3000
The Service listens on port 80 and forwards to port 3000 on any Pod labeled app: my-node-app.
Step 5: Deploy and Access the App
Apply both files and check the result:
kubectl apply -f deployment.yaml -f service.yaml
kubectl get pods
kubectl get service my-node-app
Wait until both Pods show Running and READY 1/1. To reach the app from your machine, use kubectl port-forward:
kubectl port-forward service/my-node-app 8080:80
In a second terminal (or your browser), open http://localhost:8080. You are now reaching your containerized app through Kubernetes. Port-forwarding is a development convenience; in production you would expose the app through an Ingress or a LoadBalancer Service.
Step 6: Scale, Update and Roll Back
Scale up to four replicas and watch the new Pods appear:
kubectl scale deployment my-node-app --replicas=4
kubectl get pods
Self-healing: delete a Pod with kubectl delete pod <pod-name> and run kubectl get pods again. The Deployment creates a replacement immediately.
Roll out an update: change the greeting in app.js, then build and load version 1.1 and switch the Deployment to it:
docker build -t my-node-app:1.1 .
kind load docker-image my-node-app:1.1 --name demo
kubectl set image deployment/my-node-app my-node-app=my-node-app:1.1
kubectl rollout status deployment/my-node-app
Kubernetes replaces Pods gradually, so the app stays available during the update.
Roll back if something is wrong:
kubectl rollout undo deployment/my-node-app
Troubleshooting Common Problems
| Symptom | Likely cause | Fix |
|---|---|---|
ImagePullBackOff or ErrImagePull | The cluster cannot find the image | Run kind load docker-image (or minikube image load) and check the tag matches the YAML |
CrashLoopBackOff | The app exits on start | Run kubectl logs <pod-name> and fix the error shown |
Pod stuck at 0/1 Ready | Readiness probe failing | Confirm /health returns 200 and the port matches containerPort |
port-forward fails or drops | Port already in use, or Pod restarted | Pick another local port, then re-run the command |
| Page does not load on port 3000 in Docker | Port not published | Use -p 3000:3000 and make sure the app listens on 0.0.0.0 or the default |
For anything else, kubectl describe pod <pod-name> shows events that usually explain the problem.
Clean Up
kubectl delete -f deployment.yaml -f service.yaml
kind delete cluster --name demo
Best Practices Before You Go to Production
- Pin image versions and avoid the
latesttag. - Keep images small with slim or alpine bases and multi-stage builds.
- Never bake secrets into images. Use Kubernetes Secrets or a secrets manager.
- Always set resource requests and limits, and add readiness and liveness probes.
- Scan images for vulnerabilities in CI and run containers as a non-root user.
- Store manifests in Git and deploy through a CI/CD pipeline.
Frequently Asked Questions
Q. Do I need Docker to use Kubernetes?
A. You need a way to build container images, and Docker is the most common choice. Kubernetes itself runs containers through containerd or CRI-O, not Docker Engine.
Q. Is Kubernetes better than Docker?
A. They are not alternatives. Docker builds and runs containers; Kubernetes orchestrates them at scale. A single app on one server often needs only Docker (with Compose), while many services across several servers benefit from Kubernetes.
Q. What is the difference between a container and a Pod?
A. A container is one running image. A Pod is the Kubernetes wrapper around one or more containers that share networking and storage.
Q. Should I learn Docker or Kubernetes first?
A. Learn Docker first. Kubernetes assumes you already understand images, containers and ports.
Q. Why use kubectl port-forward?
A. It creates a temporary tunnel from your machine to a Pod or Service so you can test an app without exposing it publicly.
Next Steps
You have containerized an app and run it on Kubernetes with scaling, rolling updates and rollbacks. To go further, continue with Part 2: Taking Your Docker Skills to the Next Level, and browse our other Kubernetes articles.
If your team needs help moving workloads to containers, designing a Kubernetes platform or setting up CI/CD, see our platform engineering and enterprise integration services or get in touch.
