DevOps Deep Dive

Containers — One Box, Runs Anywhere

A container is a Linux process with its own filesystem, namespaces, and resource limits. The image is a frozen snapshot of everything that process needs. Together they solved the hardest problem in operations: making "it works on my machine" actually true everywhere else.

DockerOCIImagesRegistriesKubernetesCompose
← Back to DevOps
Quick Facts

What's Actually Under the Hood

Basic Concepts

  • Container: a normal Linux process, isolated by kernel features (namespaces, cgroups, capabilities). Not a VM.
  • Image: a tarball of filesystem layers + metadata describing how to start it. Format standardized as OCI.
  • Layer: one filesystem diff. Layers are content-addressed and shared between images that use the same base.
  • Registry: a server that stores and distributes images (Docker Hub, GHCR, ECR, GCR, Harbor).
  • Runtime: the thing that runs the container — Docker, Podman, containerd, CRI-O. All speak OCI.
  • Orchestrator: the thing that runs many containers across many machines. Usually Kubernetes.

Container ≠ VM. A VM ships a kernel; a container shares the host's. That's why containers are seconds-to-start instead of minutes.

Why

What Containers Solved

  • Environment parity. Dev, CI, staging, prod — same image, same bytes, same behavior.
  • Dependency hell. Two services need different Python versions? Each gets its own image.
  • Density. Containers share the host kernel, so you can pack far more onto one machine than VMs allow.
  • Immutability. The running container is read-only by convention. To change something, you build a new image — auditable, repeatable.
  • Portability. The same image runs on a laptop, a CI runner, a Kubernetes cluster, a Lambda, a Fly.io machine.
Anatomy

How an Image Is Built

A Dockerfile is a recipe. Each instruction creates a new layer; layers cache aggressively. The order of instructions is the difference between a 30-second rebuild and a 12-minute one.

# 1. Build stage — has compilers, build deps, source
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./             # cached if package.json hasn't changed
RUN  npm ci
COPY . .
RUN  npm run build                # produces /app/dist

# 2. Runtime stage — minimal, no build tools
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN  npm ci --omit=dev
COPY --from=build /app/dist ./dist

USER node                          # don't run as root
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

Multi-stage = small final image. COPY package*.json first means dependency layer is cached until those files change.

Image Hygiene

Small, Safe, Reproducible

  • Use a slim base. alpine, distroless, or debian:slim beats a full distro by 100s of MB.
  • Pin by digest in production. node:20@sha256:… — tags can move under you.
  • Don't run as root. Add a non-root USER. Many CVEs need root inside the container to matter.
  • Fewer layers, smaller layers. Combine RUNs where it makes sense; clean up apt caches in the same RUN that installs them.
  • .dockerignore aggressively. Don't ship .git, node_modules, or local secrets into the build context.
  • Reproducible builds. Lockfiles, pinned base images, no RUN apt-get update && apt-get install foo without a version.
  • Scan images. Trivy, Grype, Snyk — run in CI, fail on high CVEs you can't waive.
  • Sign images. Cosign / Sigstore. Prove the image came from your pipeline, not a typo-squat.
  • Generate an SBOM. Required for supply-chain hygiene; many regulators now ask for it.
Local Dev

Compose & Friends

For a single developer machine, docker compose (or podman compose) is enough — declarative file, multi-service, one command up.

# docker-compose.yml
services:
  app:
    build: .
    ports: ["3000:3000"]
    env_file: .env
    depends_on: [db, redis]

  db:
    image: postgres:16-alpine
    environment: { POSTGRES_PASSWORD: dev }
    volumes: ["pgdata:/var/lib/postgresql/data"]

  redis:
    image: redis:7-alpine

  worker:
    build: .
    command: ["node", "dist/worker.js"]
    depends_on: [db, redis]

volumes:
  pgdata:

Compose is great for dev and tiny prod. Once you need scheduling, healing, or multi-host networking, it's time for an orchestrator.

Production

Where Containers Run for Real

OptionBest forCost of entry
One VM + systemd + docker runSmall projects, hobby workloads.Trivial.
Compose on a single hostSide projects, internal tools.Low.
Nomad / Docker SwarmMid-sized ops without K8s overhead.Modest.
PaaS — Fly.io, Render, Railway, Heroku, App RunnerMost teams under 10 services.Low; vendor coupling.
Kubernetes (self-hosted)Many teams, many services, real complexity.High. Don't pick it for one app.
Managed K8s — EKS, GKE, AKSK8s benefits without running the control plane.Still high; the cluster's still yours.
Serverless containers — Cloud Run, ECS Fargate, Container AppsStateless services, scale-to-zero.Lowest among hosted options.

Don't reach for Kubernetes by default. The right answer for most teams is "PaaS or serverless containers, until you can articulate why not."

Containers in Practice
  • One process per container. Not literally — but conceptually one job. Sidecars (proxy, log shipper) are fine.
  • Stateless apps. Persistent data lives in volumes or managed services, not inside the container.
  • Logs to stdout/stderr. The runtime collects them. Don't write to local files.
  • Config via env vars. 12-factor — keep config out of the image.
  • Graceful shutdown. Handle SIGTERM; the orchestrator gives you ~30s before SIGKILL.
  • Liveness vs readiness vs startup probes. Different questions. Mix them up and you'll either drop traffic or restart healthy containers.
  • Resource requests and limits. Without limits, one bad pod takes down the host. Without requests, scheduling is guesswork.
Common Pitfalls

How Containers Bite

  • Bloated images. 2GB Node images in 2026 are a smell. Multi-stage builds + slim base.
  • Cache busting via reorder. A COPY . . before npm ci reinstalls everything on every change. Order matters.
  • Latest tag in prod. "It worked yesterday" — yes, on a different image.
  • Root user, exposed ports, no USER. Container escape and lateral movement become trivial.
  • Time bombs in build args. Cache key includes the timestamp; nothing rebuilds. Pin time-sensitive things explicitly.
  • Treating the container like a VM. SSH'ing into a running container to "fix" something undermines the whole model. Rebuild, redeploy.
  • Mismatched architectures. M1 Mac builds arm64, prod runs amd64. Use buildx with explicit platforms.
Worked Example

The URL Shortener Container

A multi-stage build for the shortener app, with a separate worker process started from the same image. One artifact, two roles, picked by command.

# Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN  npm ci
COPY . .
RUN  npm run build

# Runtime — distroless for minimal attack surface
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist          ./dist
COPY package.json .

USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]               # default = web server
                                     # `docker run … dist/worker.js` for the queue consumer

Distroless: no shell, no package manager, no tools an attacker could use. The image is small, signed, scanned, and shared between the web and worker deployments — same digest, different command.

Continue

Related Reading