HomeDocumentationc_002_deployment
c_002_deployment
18 min read

From One VM to Kubernetes: A Practical Guide to Growing Your Deployment Without Breaking Things

A practical guide to evolving your Node.js deployment without over-engineering or breaking what already works.

Introduction

You have a VM somewhere. DigitalOcean, Hetzner, Lightsail — doesn't matter. On it: a Node.js backend, a React frontend served as static files, MariaDB, and nginx wiring it all together. Everything lives under `/apps`. You deploy with a shell script. Pages load fast. The app works.

Your deploy script probably looks something like this:

javascript
#!/bin/bash
# deploy.sh - the script that's different on everyone's machine
cd /apps/backend
git pull origin main
npm install
pm2 restart backend

cd /apps/frontend
git pull origin main
npm install
npm run build
# nginx serves /apps/frontend/dist as static files

Maybe you have a variation of this in a GitHub Action. Maybe you run it by hand when a teammate calls you. Maybe you've got three slightly different versions of it across the team and nobody's sure which one is canonical. It's fine. It works. This is not wrong.

For a solo project, an early-stage startup, or a side project that turned real — this setup is completely defensible. You can ship fast, you understand every piece, and when something breaks you can SSH in and fix it in ten minutes. There's no abstraction layer to fight through. The simplicity is a feature.

The cracks don't show up immediately. They show up gradually, usually at the worst possible time.

The Elephant in the Room: Your Database is on the Same Machine as Everything Else

Before even touching Docker, it's worth being honest about what the bare VM setup actually means for your database.

When MariaDB runs on the same host as your Node.js backend, a few things are true simultaneously:

You have one point of failure.

That VM goes down — maintenance window, kernel panic, full disk, someone runs `rm -rf` in the wrong directory — and your database goes down with your API. Recovery is manual. If you have backups, great. Now restore them, bring the service back up, verify data integrity, notify users. That's a bad afternoon. If your backups are VM snapshots taken weekly, it's a worse afternoon.

Cloud providers have maintenance windows. AWS will live-migrate most instances, but not all. GCP will sometimes just reboot your VM to apply a microcode update. It's infrequent. But it happens, and it never happens at 3am on a Tuesday when you're awake and bored.

Your database and app compete for the same CPU and disk.

Node.js under real traffic spikes CPU. A busy React SSR process or a background job doing heavy lifting will push CPU to 80%. Meanwhile, MariaDB needs consistent IOPS for queries. When your application is busiest — exactly when performance matters most — your database is fighting for resources with the thing making requests to it. You'll see this as slow queries during traffic peaks, and it's genuinely hard to diagnose from the outside.

The security surface is larger than it needs to be.

Your database is reachable by the same process that handles public HTTP requests. If there's a vulnerability in your Node.js app — a dependency with an RCE, a misconfigured endpoint, a code path you didn't think about — an attacker who gets code execution can reach your database directly. Not through a network hop, not through a firewall rule. Directly, because it's localhost. On a well-designed system, the database lives on a separate network segment that the application tier can reach but the public internet cannot.

Backups are blunt.

Snapshotting the entire VM gives you a backup, technically. But restoring from a VM snapshot is a nuclear option — you restore everything, all at once, to a point in time. A managed database service gives you point-in-time recovery down to the second, lets you restore just the database, and doesn't require taking down your app.

Real talk:

A lot of production systems run this exact architecture for years without incident. The problem isn't that it breaks immediately — it's that when it breaks, recovery is slow and complicated, and it tends to break at the worst possible moment. Separation isn't about perfection. It's about reducing the blast radius when something goes wrong. The smaller the blast radius, the faster you recover, the less data you lose, the fewer users notice.

The "Works on My Machine" Problem

Here's a story. A new developer joins the team. They clone the repo, follow the README (if there is one), and try to run the backend locally. They have Node 24 installed because they set up their machine last month. Production has Node 16, because someone ran `apt install nodejs` a couple of years ago and nobody touches a working system.

The backend mostly runs. But one of your dependencies — maybe something that wraps a native binary, maybe just something that changed behavior between Node versions — behaves differently. A particular query returns results in a different order. An async error gets swallowed instead of thrown. Two hours of debugging, and someone eventually says "just use the same version as production." So they install nvm. Someone pushes a `.nvmrc` file. Three weeks later there are two `.nvmrc` files in the repo that contradict each other, plus a comment in the README saying to use a specific version.

Then there's the database. The new developer has MySQL 8.0 locally (or maybe Postgres, because that's what they're used to). Production has MariaDB 10.6. Most queries work. Until one doesn't — usually a date function, or a specific collation behavior, or a SQL mode flag that defaults differently. The bug exists only in production. Nobody can reproduce it locally.

This isn't so hypothetical. Environment drift between development, staging, and production is one of the most common sources of "it worked locally" bugs.

The longer a project runs without enforced environment consistency, the worse the drift gets. You don't notice it happening. You just notice, six months in, that your staging environment hasn't been updated in a while and nobody's sure if it still matches production.

This is the actual problem Docker solves. Not "containerization" as an abstract concept. Not cloud-native buzzwords. This: your application runs in a defined, reproducible environment that is identical on your laptop, your teammate's laptop, your CI system, and your production server.

Pro Tip
The Dockerfile is the environment specification.

Docker: Package the Environment with the Application

A Dockerfile for your Node.js backend looks like this:

javascript
# backend/Dockerfile
FROM node:18-alpine

WORKDIR /app

# Copy package files first — layer caching means npm install
# only reruns when dependencies actually change
COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000
CMD ["node", "src/index.js"]

The key insight beginners miss is layer caching. Docker builds images in layers. Each instruction in a Dockerfile is a layer.

Pro Tip
When you rebuild an image, Docker only reruns layers that have changed — everything above an unchanged layer is pulled from cache:

If you copy your source code first and then run `npm install`, any code change invalidates the `npm install` layer and Docker reinstalls all your dependencies from scratch. That's slow. Copy `package.json` first, run `npm install`, then copy the source. Now `npm install` only reruns when `package.json` actually changes. Most rebuilds just copy new source files and skip the install step entirely.

Use `npm ci` instead of `npm install` in Dockerfiles. `npm ci` installs exactly what's in `package-lock.json` without updating anything. This makes builds deterministic:

The same lockfile always produces the same `node_modules`. `npm install` can silently update packages within semver ranges. In a Dockerfile that's a reproducibility problem: your image might have slightly different dependencies depending on when you built it.

The React frontend needs a different approach. You don't want to ship `node_modules` to production — you just need the built output. Multi-stage builds handle this cleanly:

javascript
# frontend/Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage — just nginx serving the built files
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

The final image contains only nginx and the built static files. No Node.js runtime, no `node_modules`, no build tools. The production image ends up around 25MB instead of 800MB.

With a simple nginx config that handles React Router correctly:

javascript
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # React Router: serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Build and run:

javascript
docker build -t myapp/backend:latest ./backend
docker build -t myapp/frontend:latest ./frontend

docker run -d -p 3000:3000 myapp/backend:latest
docker run -d -p 80:80 myapp/frontend:latest

This works. But the backend still needs to talk to MariaDB. And now what — run MariaDB in a container too? How do the containers find each other? How do you manage environment variables? How do you start them in the right order so Node doesn't crash trying to connect to a database that isn't ready yet?

This is where Docker Compose comes in.

Docker Compose: Your Entire Stack in One File

Compose is the thing that lets you define your whole application — backend, frontend, database, cache, whatever — as a single declarative configuration. Instead of three separate `docker run` commands with different flags that you half-remember, you write `compose.yaml` once and run `docker compose up`.

javascript
# compose.yaml
services:
  db:
    image: mariadb:10.11
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_DATABASE: myapp
      MARIADB_USER: appuser
      MARIADB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - backend-net
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build: ./backend
    restart: unless-stopped
    environment:
      DB_HOST: db
      DB_NAME: myapp
      DB_USER: appuser
      DB_PASSWORD: ${DB_PASSWORD}
      NODE_ENV: production
    ports:
      - "3000:3000"
    networks:
      - backend-net
      - frontend-net
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build: ./frontend
    restart: unless-stopped
    ports:
      - "80:80"
    networks:
      - frontend-net
    depends_on:
      - backend

volumes:
  db_data:

networks:
  backend-net:
  frontend-net:

A few things worth understanding here.

Networking.

Notice that `db` is only on `backend-net`, not `frontend-net`. The frontend container cannot reach the database directly. It can only reach the backend. This is the network separation we talked about earlier — enforced automatically by Compose. You're not configuring firewall rules or iptables. You're just listing which services belong to which network.

Service discovery.

The backend connects to MariaDB using `DB_HOST: db`. Not an IP address. Just the service name. Docker's internal DNS resolves `db` to whatever IP the database container currently has. If you restart the database container and its IP changes, nothing breaks. The service name stays constant.

The healthcheck and `depends_on`.

Without `condition: service_healthy`, Compose starts the backend when the database container is running, not when it's ready to accept connections. MariaDB takes several seconds to initialize its data directory and start accepting connections.

Without the healthcheck, you get a race condition: Node tries to connect, the database refuses, the backend crashes, pm2 would restart it, but in Docker it just exits. The healthcheck makes Compose wait until MariaDB actually responds before starting dependent services.

Volumes.

`db_data` is a named volume. Your database data persists across container restarts and `docker compose down`. If you run `docker compose down -v`, that destroys the volume and all your data. Don't do that on production.

Pro Tip
Put your secrets in a `.env` file next to `compose.yaml` and add `.env` to `.gitignore`.

Docker Compose automatically reads `.env` and substitutes `${VARIABLE}` references. The `compose.yaml` file goes in git. The `.env` file doesn't. Anyone who clones the repo gets a template they can fill in; they don't get your actual database password committed to history.

The commands you'll actually use:

javascript
# Start everything in the background
docker compose up -d

# See what's running
docker compose ps

# Follow logs from all services
docker compose logs -f

# Just the backend logs
docker compose logs -f backend

# Rebuild and restart just the backend (after a code change)
docker compose up -d --build backend

# Stop everything (preserves volumes)
docker compose down

# Stop and delete volumes — WARNING: destroys database data
docker compose down -v

Here's what this gives you that the bare VM bash script doesn't: anyone on the team can clone the repo, copy `.env.example` to `.env`, fill in credentials, and run `docker compose up -d`. They have a working local environment with the exact same Node version, the exact same MariaDB version, and the exact same network topology as production.

No setup doc to follow. No version mismatches to debug. Just the one command.

That's the practical win. Not "containerization." Just: your dev environment and your production environment are the same thing.

The Limits of Compose

Compose is, pretty much, single-machine. Everything in that `compose.yaml` runs on one host. For a lot of applications, that's fine for a very long time. But eventually you might hit a wall.

Your backend is getting real traffic. A single VM with 4 vCPUs is starting to struggle at peak hours. You want to run two instances of the backend in parallel. You can do that on one machine with Compose, but you're still limited to one machine's resources. When that VM goes down for maintenance, everything goes down.

You want zero-downtime deploys. With Compose, deploying a new image version means stopping the old container and starting the new one. There's a gap. With a load balancer and multiple replicas, you can drain one container while the new version starts — but Compose doesn't orchestrate that for you.

You want the system to self-heal. If the backend container crashes, Compose with `restart: unless-stopped` will restart it on the same machine. But if the machine itself goes down, nothing restarts it anywhere.

These are the problems that orchestration systems solve.

Docker Swarm: The Middle Ground Nobody Talks About Enough

Swarm is Docker's built-in orchestration mode, and it's underrated. It lets you treat a cluster of VMs as a single Docker host, and it uses almost exactly the same `compose.yaml` format you already know.

javascript
# On your primary VMthis becomes the manager node
docker swarm init

# The output gives you a join command for additional nodes:
# docker swarm join --token SWMTKN-1-... 192.168.1.10:2377

# Deploy your stack (uses your compose.yaml)
docker stack deploy -c compose.yaml myapp

# Scale the backend to 3 replicas across your nodes
docker service scale myapp_backend=3

# Check what's running and where
docker service ls
docker service ps myapp_backend

Swarm handles load balancing across replicas, rolling updates (new containers start before old ones are removed), and automatic restarts on healthy nodes when a container crashes or a node goes down.

Swarm never took over the world. Most teams that adopt container orchestration either stay on Compose or jump straight to Kubernetes, skipping Swarm entirely. It's worth knowing it exists — if you have genuinely modest needs (a few services, 2-3 nodes, no complex routing requirements), Swarm is a completely reasonable choice and has almost zero learning curve if you already know Compose.

But the ecosystem tooling, the job market, and community momentum are solidly on Kubernetes. If you're going to invest time learning orchestration, learn the one that pays off more broadly.

Kubernetes: When Your Problems Get Bigger Than One Machine

Kubernetes doesn't make sense until you have problems that need it. Here's the set of problems it's designed for.

Your Node.js backend gets real traffic. Some endpoints are CPU-heavy. You're running three VMs, and you want backend pods spread across all three so that losing one machine doesn't take down the API. At 9am when users come online, you want more backend instances to spin up automatically.

At 3am when traffic drops, you want them to scale back down so you're not paying for idle compute. When any pod crashes for any reason — OOM kill, unhandled exception, whatever — you want it restarted immediately, on a healthy node, without a pager alert waking you up.

That's the problem space. Kubernetes is the solution.

The mental model before the YAML:

A **Node** is a machine — a VM or physical server — in your cluster. Your application runs across multiple nodes.

A **Pod** is the smallest deployable unit. Usually one container. Every pod gets its own IP address.

A **Deployment** describes the desired state: run 3 replicas of this container image, with these environment variables, these resource limits, these health checks. Kubernetes continuously reconciles the actual state with the desired state. You declare what you want; Kubernetes figures out how to achieve it.

A **Service** is a stable network endpoint for a set of pods. Pods die and restart with new IPs constantly. A Service gives you a consistent hostname and IP that always routes to currently-healthy pods.

An **HPA (Horizontal Pod Autoscaler)** watches metrics — CPU, memory, custom metrics if you set them up — and automatically adds or removes replicas based on load.

Here's what a backend Deployment and Service actually look like:

javascript
# backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
spec:
  replicas: 2
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
        - name: backend
          image: myapp/backend:latest
          ports:
            - containerPort: 3000
          env:
            - name: DB_HOST
              value: mariadb-service
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: backend-service
spec:
  selector:
    app: backend
  ports:
    - port: 80
      targetPort: 3000

The `readinessProbe` is important. Kubernetes won't route traffic to a pod until its `/health` endpoint returns 200. During a rolling update, old pods keep serving traffic while new ones start up. New pods become ready, old pods get removed. Zero downtime. No configuration beyond adding the probe.

The resource `requests` and `limits` tell Kubernetes how much CPU and memory each pod needs. Kubernetes uses `requests` to decide which node to schedule a pod on — it won't place a pod on a node that doesn't have enough capacity. `limits` prevent a misbehaving pod from consuming the whole node's resources and starving other pods.

Working with a cluster:

javascript
# Apply a deployment
kubectl apply -f backend-deployment.yaml

# Watch pods come up in real time
kubectl get pods -w

# Debug a pod that's not starting
kubectl describe pod <pod-name>
kubectl logs <pod-name>

# Scale manually
kubectl scale deployment backend --replicas=5

# Set up autoscaling: scale between 2 and 10 replicas
# target 70% CPU utilization as the trigger
kubectl autoscale deployment backend --min=2 --max=10 --cpu-percent=70

# Check HPA status
kubectl get hpa

Service discovery works the same as Compose.

Your backend connects to the database using the service name `mariadb-service` as the hostname. Kubernetes DNS resolves that name to the Service's ClusterIP, which routes to healthy database pods automatically. Your Node.js `DB_HOST` config is just a string — it doesn't need to know what machine the database is on, what IP it has, or how many replicas are running.

Every pod in Kubernetes gets its own IP address from the cluster's pod CIDR (something like `10.244.1.5`). But you almost never use pod IPs directly — they change every time a pod restarts. Services are the stable abstraction. The Service IP (ClusterIP) stays constant. Kubernetes handles routing to whichever pods are currently healthy behind it. This is the same principle as Docker Compose networking, just operating at a larger scale across multiple machines.

What you actually gain with Kubernetes:

Self-healing. A pod crashes, Kubernetes restarts it immediately. A node goes down, Kubernetes reschedules its pods on healthy nodes. You stop getting paged for container crashes.

Rolling updates. Deploy a new image version with `kubectl set image deployment/backend backend=myapp/backend:v2`. Old pods stay up while new ones start. Traffic shifts as new pods become ready. The whole process takes 30 seconds and users don't notice.

Horizontal scaling. Traffic spikes? HPA adds replicas. Traffic drops? HPA removes them. You're not paying for idle capacity.

Resource isolation. A background job can't starve your API of CPU because they both have limits. The API's response time doesn't degrade when the job runs.

Observability infrastructure. Prometheus, Grafana, Jaeger for tracing, Cilium for network-level visibility — all of this integrates naturally with a Kubernetes cluster. The ecosystem is enormous.

What it costs:

Kubernetes is operationally complex. There's a control plane to manage, node pools to maintain, certificates to rotate, etcd to back up. For most teams, the answer to this is: use a managed Kubernetes service.

GKE, EKS, AKS, DigitalOcean Kubernetes — you get all the Kubernetes capabilities without managing the cluster itself. The provider handles control plane availability, upgrades, etcd backups. You manage your deployments.

There's also a YAML surface area that can feel enormous. Helm helps with this — it's a package manager for Kubernetes that lets you parameterize and reuse manifests instead of copy-pasting YAML and changing values by hand.

How to Know Which Level You're Actually At

This is the thing the tooling evangelists don't always say clearly: the right answer depends entirely on where your project actually is, not where you imagine it might be someday.

Solo project, early startup, known domain:

The bare VM with a deploy script is completely fine. Don't let anyone tell you otherwise. The simplicity pays for itself in how fast you can move. When you hit the ceiling — and you'll know when you hit it — you'll migrate. That's a solvable problem.

Team of 3–8 people, multiple services, reproducibility starting to matter:

Docker Compose is the immediate upgrade. You're not throwing away what you have — you're wrapping it in something that makes it consistent across the team and gives you the network isolation that separates your database from your app tier. The lift is a few days. The payoff is immediate.

Real traffic, multi-service, zero-downtime deploys matter, reliability expectations are real:

Kubernetes on a managed service. The operational overhead pays for itself through reduced on-call burden and the ability to scale without manual intervention. Start with a small cluster on DigitalOcean or GKE, apply your Compose logic as Kubernetes manifests, and migrate services one at a time.

Once you're on Kubernetes, the next layers become available. Helm for packaging and versioning deployments across environments. ArgoCD for GitOps — your cluster state is declared in git, and ArgoCD continuously reconciles the cluster to match. Cilium for CNI-level network policy and observability — you can see exactly which pods are talking to which services and enforce that the frontend never reaches the database directly. Prometheus and Grafana for metrics and alerting with dashboards that actually reflect your cluster's behavior.

Each one is a multiplier on the infrastructure you already have. You don't need all of them at once. You add them when you need what they provide.

Final thoughts

The bare VM setup isn't the enemy. It's a reasonable starting point that a lot of real systems run in production for years. The problem is outgrowing it without realizing it — continuing to add complexity on top of a foundation that stopped scaling, and then discovering the limits during an incident at 2am.

The goal isn't to adopt every layer of the cloud-native stack because it's the right thing to do. The goal is to have the tools that match your actual problems. Right now, if your setup works, it works. When it stops working, you'll know what to reach for.

Related Topics

nodejs deployment guidekubernetes vs docker composemigrate from VM to kubernetesdocker swarm vs kubernetescontainer orchestration beginners guide

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.