What containerizing actually involves
Containerizing an existing app means packaging it, and everything it needs to run, into a portable image that behaves the same on a laptop, in CI, and in production. The Dockerfile is the visible part, but the real work is making your app *container-friendly*: stateless, configured via environment, logging to stdout, and listening on a port it's told about. Get those right and the Dockerfile almost writes itself.
Step 1: make the app twelve-factor-ish
Before writing a Dockerfile, fix the things that fight containers:
- Config from the environment, no hardcoded hosts, ports, or credentials. Read them from env vars.
- Logs to stdout/stderr, don't write log files inside the container; they vanish on restart.
- Stateless process, don't store uploads or session data on local disk; use object storage or a database.
- Listen on a configurable port, read
PORTfrom the environment.
These changes are usually small and pay off immediately, they're also exactly what makes the app deployable anywhere.
Step 2: choose a base image
Your base image is a trade-off between size, security surface, and convenience:
| Base | Size | Notes |
|---|---|---|
node:20 (Debian) | Large | Everything included; easy but bigger attack surface |
node:20-slim | Medium | Trimmed Debian; good default |
node:20-alpine | Small | musl libc; tiny, but watch for native-module quirks |
distroless | Smallest | No shell/package manager; most secure, harder to debug |
Start with a -slim variant for a good balance. Move to Alpine or distroless once the app builds cleanly and you want to shrink the image and attack surface. Always pin a specific tag (node:20, not node:latest) for reproducibility.
Step 3: write a multi-stage Dockerfile
The single biggest lever for small, secure images is the multi-stage build: use a fat "builder" stage with all the build tools, then copy only the runtime artifacts into a lean final stage. The compilers and dev dependencies never reach production.
# syntax=docker/dockerfile:1
# ---- Build stage ----
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # includes dev deps for the build
COPY . .
RUN npm run build # produces ./dist
# ---- Runtime stage ----
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev # production deps only
COPY --from=builder /app/dist ./dist
# Run as a non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]The runtime image contains no build tools and no dev dependencies, just what's needed to run.
Step 4: order layers for cache efficiency
Docker caches layers top-to-bottom and invalidates everything below the first change. Copy dependency manifests and install *before* copying your source, so a code change doesn't bust the dependency-install layer:
COPY package*.json ./ # changes rarely
RUN npm ci # cached unless package*.json changed
COPY . . # changes often, but doesn't re-trigger npm ciThis one ordering trick turns multi-minute rebuilds into seconds.
Step 5: a thorough .dockerignore
Without a .dockerignore, you copy node_modules, .git, secrets, and local cruft into the build context, slow, bloated, and a leak risk:
node_modules
.git
.env
*.log
dist
Dockerfile
.dockerignore
coverageThis speeds up builds and keeps secrets and junk out of your image.
Step 6: security hardening
- Run as non-root. Add
USER node(or a created user) so a container compromise isn't immediately root. - Don't bake secrets in. Use runtime env vars or build secrets (
--mount=type=secret), neverCOPY .env. - Minimize the image. Fewer packages = smaller attack surface. This is the case for slim/distroless.
- Scan the image. Run a scanner (Trivy, Grype) in CI to catch known CVEs in your dependencies and base.
- Add a healthcheck endpoint so orchestrators can tell when the container is ready (see the dedicated health-checks guide).
Step 7: handle persistent state
Containers are ephemeral, anything written to the container filesystem is lost on restart. Externalize all state:
- Database → a managed database, not a file in the container.
- Uploads/files → object storage (S3-compatible).
- Sessions/cache → Redis, not in-process memory (so multiple replicas stay consistent).
If your legacy app writes to local disk, this is usually the biggest refactor, and the most important for being able to run multiple replicas.
Step 8: test the image locally
docker build -t myapp .
docker run --rm -p 3000:3000 \
-e DATABASE_URL="postgres://..." \
-e PORT=3000 \
myapp
# Confirm it starts, serves traffic, and reads config from env
docker images myapp # check the final image sizeVerify it reads all config from env, logs to stdout, and survives a restart without losing important state.
Deploying your container on PandaStack
Once you have a working Dockerfile, deploying is straightforward. PandaStack runs container apps from any Dockerfile (or, if you'd rather not write one, auto-buildpacks for Node, Python, Go, and more). Connect your repo and PandaStack builds the image, in rootless BuildKit inside ephemeral Kubernetes Job pods, with no host Docker socket, pushes it to Google Artifact Registry, and Helm-deploys it to GKE.
The container-friendly work from earlier pays off directly here:
- Your env-var config maps to PandaStack's environment variables.
- If you externalized your database, add a managed database and PandaStack injects
DATABASE_URLautomatically, no connection string in your image. - Your stdout logs show up as live app logs.
- A readiness endpoint enables zero-downtime rolling deploys and clean rollbacks.
# Set runtime config as env vars on the service:
PORT=3000
# DATABASE_URL injected by the platform when you add a managed DB.Because builds are rootless and isolated, you get the security benefits of the BuildKit model without managing any build infrastructure yourself.
Conclusion
Containerizing a legacy app is 20% Dockerfile and 80% making the app behave like a good container citizen: config from the environment, logs to stdout, no local state, a configurable port. Add a multi-stage build, careful layer ordering, a .dockerignore, a non-root user, and image scanning, and you'll ship small, secure, reproducible images.
PandaStack deploys any Dockerfile (or auto-detects a buildpack) with managed rootless builds, injected databases, live logs, and rollbacks, so the step after "docker build" is just "connect the repo." Try it on the free tier at https://dashboard.pandastack.io.
References
- Dockerfile best practices: https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
- Docker multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Distroless images: https://github.com/GoogleContainerTools/distroless
- Trivy image scanner: https://aquasecurity.github.io/trivy/
- The Twelve-Factor App: https://12factor.net/