Migration from Virtual Machines to Containers (VM → Docker/K8s)
Moving an application from a VM to containers is a task every team faces sooner or later. In our practice, we've seen how legacy applications that ran for years on dedicated servers broke in containers due to non-obvious dependencies. Our engineers have helped dozens of clients cut deployment time by 3x and eliminate environment issues. Below is a step-by-step methodology proven on projects with 50+ microservices.
Why Containerization Beats VMs
A VM requires OS installation, repository configuration, kernel updates—done manually or via Ansible. A container is built once and runs anywhere. Based on our data, development on containers accelerates CI/CD cycles by 40%. In Kubernetes, you get autoscaling (HPA/VPA), self-healing, and rolling updates out of the box. Plus, OS license savings add up to a 30–50% reduction in infrastructure costs.
Starting with VM Inventory
Before writing the first Dockerfile, you need to understand exactly what's running on the VM. Often, a single production machine hosts: nginx as a reverse proxy, the application itself (Node.js), PostgreSQL, Redis, a cron job for backups, and a local Postfix mail agent. That's seven separate containers if done right.
# What is running on the VM
systemctl list-units --type=service --state=running
ps auxf | grep -v grep
# Which ports are listening
ss -tlnp
# Cron jobs
crontab -l
ls /etc/cron.d/ /etc/cron.daily/
# Application dependencies (example for Node.js)
cat package.json | jq '.dependencies | keys[]'
node --version; npm --version
The result of inventory is a list of everything to be migrated, with an understanding of dependencies between components.
Typical Pitfalls in Dockerfile
Logging. The application writes to /var/log/app/access.log. In a container, logs must go to stdout/stderr—otherwise Docker daemon, Fluentd, or Loki won't pick them up. One option: add a symlink ln -sf /dev/stdout /var/log/app/access.log in the Dockerfile. A better option: fix the application's logging configuration itself.
Configuration via environment variables. A hardcoded path /etc/app/config.yaml must be replaced with ENV variables or a volume mount. The Twelve-Factor App is not just a trendy concept—it's a requirement for normal operation in K8s.
Users and permissions. The application runs as root on the VM. In a container, that's a security risk. Add an unprivileged user:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Init system. The application uses supervisord or spawns child processes directly. In a container, PID 1 must correctly handle SIGTERM and forward signals to child processes. If the application cannot, use tini as init:
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]Stateful data. The application writes uploaded files to /var/uploads/. In a container, this must be moved to a PersistentVolume (K8s) or mounted via -v (Docker). Data must not live inside the container.
Typical Kubernetes Manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-backend
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
containers:
- name: backend
image: registry.internal/app-backend:1.2.3
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
- name: REDIS_URL
value: "redis://redis-service:6379"
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: uploads
mountPath: /app/uploads
volumes:
- name: uploads
persistentVolumeClaim:
claimName: app-uploads-pvc
Missing resources is one of the most common mistakes during the first deployment to K8s. Without limits, a pod can consume all memory on the node; without requests, the scheduler cannot distribute pods correctly.
Cron Jobs → CronJob
System cron is migrated to a K8s CronJob:
apiVersion: batch/v1
kind: CronJob
metadata:
name: db-backup
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: registry.internal/db-backup:latest
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-urlconcurrencyPolicy: Forbid prevents a new job from starting if the previous one is still running.
Ensuring Reliability During Migration
We guarantee that after the transition all key SLO metrics will be under control. Setting up Prometheus+Grafana monitoring is part of the standard package. All configs are versioned in Git—this eliminates the risk of lost changes. Our team consists of certified Kubernetes administrators and cloud engineers with 10+ years of experience. We have completed over 30 successful migrations for fintech, e-commerce, and SaaS products.
| Criterion | VM | Containers (Docker/K8s) |
|---|---|---|
| Boot time | 30–60 sec | 1–2 sec |
| Resource usage | Heavy OS, hypervisor isolation | Lightweight process, shared kernel |
| Cost (TCO) | Higher due to licenses and over-provisioning | 30–50% lower via compact placement |
What Our Migration Service Includes
- Audit of VM infrastructure and dependency map
- Dockerfile for each microservice with multi-stage build
- Helm charts for K8s deployment
- CI/CD setup (GitLab CI / GitHub Actions)
- Monitoring and alerting (Prometheus, Loki, Grafana)
- Operations documentation and runbook
- Knowledge transfer to the client's team (1–2 days training)
Which Migration Strategy to Choose?
The correct sequence for a production application:
- Containerization — write Dockerfile, test locally
- CI for the image — build and push to registry on every merge to main
- Deploy to staging on K8s, run tests
- Parallel run — old VM and new pods run simultaneously, traffic stays on VM
- Traffic switch — Ingress or DNS points to K8s Service
- Observation — one week in production, compare metrics with baseline
- Decommission VM — after confirming stability
Never switch traffic and shut down the VM at the same time. It seems obvious, but in the stress of a deadline, it's easy to forget.
Ready to start the migration? Get a consultation with our engineer.
Timelines
| Application Complexity | Containerization Time | K8s Migration Time |
|---|---|---|
| Simple (1 process, no state) | 1–2 days | 2–3 days |
| Medium (2–3 components, stateful) | 3–5 days | 1 week |
| Complex (5+ components, legacy dependencies) | 1–2 weeks | 2–4 weeks |
Initial VM infrastructure audit and estimate take 1–2 days. Final timelines are determined after that. Order an audit of your VM infrastructure—we'll prepare a migration plan in 1–2 days.







