Docker Migration Guide: Containerizing Your Legacy Stack
Most legacy stacks aren't a single app — they're a constellation of services: a web frontend, an API backend, a background worker process, scheduled scripts, and one or more databases. Containerizing each component individually and orchestrating them with Docker Compose (for local development) and a cloud PaaS (for production) is how you modernize without a full rewrite. This tutorial covers the complete process.
Anatomy of a Typical Legacy Stack
The stack we'll containerize:
- Nginx — reverse proxy and static file serving
- Node.js API — REST backend
- Python worker — background job processor (queue consumer)
- Cron scripts — scheduled data processing
- PostgreSQL — primary database
- Redis — session store and job queue
Step 1: Containerize the Node.js API
# api/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
COPY package*.json ./
RUN addgroup -S api && adduser -S api -G api
USER api
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]# Test the API container
docker build -t my-api:dev ./api
docker run -p 3000:3000 -e DATABASE_URL=postgresql://user:pass@host:5432/db -e REDIS_URL=redis://localhost:6379 my-api:dev
curl http://localhost:3000/healthStep 2: Containerize the Python Worker
# worker/Dockerfile
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 worker/ ./worker/
COPY shared/ ./shared/
RUN useradd -m worker && chown -R worker /app
USER worker
CMD ["python", "-m", "worker.main"]Step 3: Handle Cron Scripts as Separate Containers
Legacy cron scripts running on the host become dedicated cronjob containers on the cloud. First, make each script self-contained:
# cronjobs/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY scripts/ ./scripts/
RUN useradd -m cronuser && chown -R cronuser /app
USER cronuser
# No CMD — the schedule determines which script runs# Test a specific script locally
docker build -t my-cronjobs:dev ./cronjobs
docker run --env-file .env.test my-cronjobs:dev python scripts/daily-report.pyStep 4: Write docker-compose.yml for Local Development
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: dev
POSTGRES_PASSWORD: devpassword
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d myapp"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ./api
target: production
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://dev:devpassword@postgres:5432/myapp
REDIS_URL: redis://redis:6379
NODE_ENV: development
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
worker:
build: ./worker
environment:
DATABASE_URL: postgresql://dev:devpassword@postgres:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
postgres-data:# Start the full stack locally
docker compose up --build
# Run database migrations
docker compose exec api npx sequelize-cli db:migrate
# Verify everything is healthy
docker compose ps
docker compose logs api --tail=20Step 5: Push Images to GitHub Container Registry
# Log in to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
# Build and tag
docker build -t ghcr.io/your-org/my-api:latest ./api
docker build -t ghcr.io/your-org/my-worker:latest ./worker
docker build -t ghcr.io/your-org/my-cronjobs:latest ./cronjobs
# Push
docker push ghcr.io/your-org/my-api:latest
docker push ghcr.io/your-org/my-worker:latest
docker push ghcr.io/your-org/my-cronjobs:latestStep 6: Deploy Each Component on PandaStack
npm install -g @pandastack/cli
panda login
# Deploy the API container (auto-deploys on GitHub push)
panda deploy --name my-api --image ghcr.io/your-org/my-api:latest
# Deploy the worker as a background container
panda deploy --name my-worker --image ghcr.io/your-org/my-worker:latest --type worker
# Create cronjobs for each scheduled script
panda cronjob create --name daily-report --image ghcr.io/your-org/my-cronjobs:latest --schedule "0 6 * * *" --command "python scripts/daily-report.py"
panda cronjob create --name hourly-cleanup --image ghcr.io/your-org/my-cronjobs:latest --schedule "0 * * * *" --command "python scripts/cleanup.py"Step 7: Run Migrations in Production
# Run migrations as a one-off container execution before going live
panda run --image ghcr.io/your-org/my-api:latest --command "npx sequelize-cli db:migrate" --env DATABASE_URL="$PROD_DB_URL"Containerizing a legacy stack is an iterative process — start with the web service, get it running in production, then migrate the worker, then the cron jobs. Each step is independently verifiable. By the end, your legacy stack is portable, reproducible, and deployable anywhere Docker runs. Visit [docs.pandastack.io](https://docs.pandastack.io) for PandaStack's full container deployment documentation.