Containerizing legacy Java and PHP apps onto Kubernetes
Migrating a decade-old Java Spring and PHP Symfony estate to containers and Kubernetes without a big-bang rewrite — the CI/CD, Docker, and runtime patterns that made it work.
The problem
Not every modernization starts with greenfield code. The estate I worked on was a decade of Java Spring and PHP Symfony services running on VMs with hand-written deployment scripts. The code itself was in good shape — test-driven development had kept coverage at 85% — but the runtime was the problem: every deploy was a bespoke shell script, and there were 30+ workloads to move. The goal was containers and Kubernetes, but the budget and the business appetite for risk did not allow a rewrite. The applications had to move as they were.
Containerization without the rewrite
The discipline was: lift, then optimize.
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
FROM eclipse-temurin:17-jre
COPY --from=build /src/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Multi-stage builds kept images small and reproducible. Config moved out of the process and into ConfigMaps. State that lived on the VM disk moved to proper storage or databases. Logs stopped being files and became stdout for the platform to collect.
The first version of each workload on the cluster was intentionally boring — a Deployment that mirrored the VM deployment, with probes and resource bounds so the scheduler had what it needed:
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing
spec:
replicas: 3
selector:
matchLabels: { app: billing }
template:
metadata:
labels: { app: billing }
spec:
containers:
- name: billing
image: registry.internal/billing:1.4.2
ports: [{ containerPort: 8080 }]
envFrom:
- configMapRef: { name: billing-config }
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
readinessProbe:
httpGet: { path: /actuator/health, port: 8080 }
CI/CD
GitLab CI became the delivery backbone: build, test, and deploy pipelines ran on every merge, with images pushed to a private registry and deployed through the Kubernetes manifest pipeline. The test stage enforced the 85% coverage gate, so the CI/CD discipline carried the TDD culture forward:
stages: [test, build, deploy]
test:
script:
- mvn test
- mvn jacoco:check # 85% coverage gate
build:
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
deploy:
script:
- kubectl set image deployment/billing billing=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Runtime patterns
On the cluster, the classic Kubernetes primitives carried the day:
- Deployments for rollout strategy and self-healing.
- Services and Ingress for routing.
- ConfigMaps and Secrets for configuration.
- Resource requests and limits so scheduling was predictable.
Observability
Prometheus metrics, Grafana dashboards, and CloudWatch log analysis gave the team the same visibility the applications had never had on VMs — suddenly we could see latency, saturation, and error rates per service.
Lessons learned
- Move the workload, not the problem. The first containerized version of a legacy app should be behaviorally identical to the VM version. Optimize later.
- Dockerfiles are architecture reviews. The effort to write a clean multi-stage Dockerfile forces you to actually understand the app's build and runtime.
- Observability is the unlock. The biggest win of Kubernetes for legacy apps was not portability — it was finally seeing them.
Containers do not fix legacy code. But they give you a safe, fast path to keep running it while you improve it incrementally — which is how real modernization happens.