How to Containerize a Legacy Application with Docker
Legacy applications — the ones running on bare metal servers, old VM images, or aging shared hosting accounts — are a fact of life for most engineering teams. Containerizing them with Docker is the first step toward modernization: it makes the app portable, reproducible, and deployable on any modern cloud PaaS. This tutorial walks through the process from initial audit to a working Dockerfile.
What "Legacy" Usually Means
Legacy apps tend to share a few characteristics:
- Hardcoded file paths or configuration
- Runtime dependencies installed globally on the host
- No separation between application code and uploaded files
- Direct database connections without connection pooling
- Session state stored on disk rather than in Redis
None of these are blockers for containerization — they just need to be addressed systematically.
Step 1: Audit Your Runtime Dependencies
Before writing a Dockerfile, understand exactly what the application needs:
# On the legacy server — capture installed packages
dpkg --get-selections > installed-packages.txt
# For Python apps — capture exact versions
pip freeze > requirements.txt
# For Node.js apps
npm list --depth=0 > node-deps.txt
# Check what processes are actually running at runtime
ps aux | grep -E "(node|python|php|ruby|java)"
# Find config files your app reads
strace -e openat -p $(pgrep your-app) 2>&1 | grep -v ENOENT | head -50Step 2: Identify External Dependencies
# Check what the app connects to
ss -tnp | grep your-app-pid
# Review database connection strings
grep -r "DB_HOST|DATABASE_URL|REDIS_URL" /path/to/app --include="*.env" --include="*.conf"
# Find filesystem dependencies (uploads, caches, logs)
lsof -p $(pgrep your-app) | grep REG | awk '{print $9}' | sort -uDocument every external port, file path, and environment variable. These become your Docker environment variables and volume mounts.
Step 3: Write Your First Dockerfile
Start with the closest official base image for your runtime. Don't try to replicate the legacy OS exactly — use a modern, minimal image.
For a Node.js 14 app (common legacy version):
FROM node:20-alpine
# Install any native dependencies your packages need
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy dependency files first (layer caching)
COPY package*.json ./
RUN npm ci --only=production
# Copy application code
COPY . .
# Never run as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]For a Python Flask app:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -m appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2"]Step 4: Handle Configuration Without Hardcoding
Legacy apps often have configuration baked into files. Externalize it:
# Find hardcoded config
grep -rn "localhost|127.0.0.1|/var/www|/home/deploy" /path/to/app --include="*.js" --include="*.py" --include="*.php"Replace each hardcoded value with an environment variable read at startup. For Node.js:
const dbHost = process.env.DB_HOST || 'localhost';
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads';Step 5: Handle File Uploads and Persistent Storage
Containers are ephemeral — anything written to the container filesystem is lost on restart. Move file uploads to object storage (S3, GCS) or mount a persistent volume.
For local testing, use a named volume:
docker run -v app-uploads:/app/uploads my-legacy-appStep 6: Build, Test, and Iterate
# Build the image
docker build -t my-legacy-app:v1 .
# Run with a test environment file
docker run -p 3000:3000 --env-file .env.test --name legacy-test my-legacy-app:v1
# Tail the logs
docker logs -f legacy-test
# Run your smoke tests against the container
curl http://localhost:3000/health
curl -X POST http://localhost:3000/api/users -H "Content-Type: application/json" -d '{"email": "test@example.com"}'
# Clean up
docker stop legacy-test && docker rm legacy-testStep 7: Push to GitHub and Deploy on PandaStack
Once the container passes local tests, push to GitHub and connect to PandaStack:
# Push your Dockerfile and code to GitHub
git add Dockerfile .dockerignore
git commit -m "Add Docker container configuration"
git push origin main
# Install CLI and deploy
npm install -g @pandastack/cli
panda login
panda initPandaStack automatically builds and deploys your container on every push to your configured GitHub branch. Set your environment variables in the dashboard at [dashboard.pandastack.io](https://dashboard.pandastack.io) and you're live.
Containerization is the gateway to everything else: autoscaling, zero-downtime deploys, multiple environments, and modern CI/CD. The investment pays off immediately — and your future self will thank you for making the legacy app portable. See [docs.pandastack.io](https://docs.pandastack.io) for deployment configuration options.