Containerizing a Backend
An image is a frozen filesystem plus an entrypoint; the interesting part is what your process must do once it is PID 1 with no shell around it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What does putting my backend in a container actually change about how it runs?
The service must run identically on a laptop, in CI and in production, and a deploy must be "this exact artifact" rather than "whatever git pull produced".
Write a Dockerfile that copies the whole repo, runs the install, and starts the app with the same command used in development. It builds, it runs, done.
The image contains the entire build toolchain, dev dependencies, the .git directory and a stale node_modules, so every deploy pulls hundreds of megabytes and the attack surface is a build machine.
- The image contains the entire build toolchain, dev dependencies, the
.gitdirectory and a stalenode_modules, so every deploy pulls hundreds of megabytes and the attack surface is a build machine. - The process is started through a shell wrapper, so it is not PID 1 and never receives SIGTERM. Every deploy waits the full grace period and then hard-kills it mid-request (Graceful Shutdown).
- Secrets are passed as build arguments and baked into a layer. They are in the registry, in the layer history, and in every developer's local cache.
- Any file the application writes lives in the container's writable layer and disappears on restart — including the upload it was about to process (File Uploads Through the Backend).
- The build reinstalls every dependency on every commit because the source is copied before the manifest, so the dependency layer is invalidated by a one-character change.
What is actually happening
- An image is an ordered stack of read-only filesystem layers plus metadata: an entrypoint, a working directory, environment defaults and an exposed port. It is not a machine and there is no OS booting.
- A container is that filesystem, plus a thin writable layer, plus a set of kernel namespaces and cgroups that constrain what the process can see and consume. It is your process, on the host kernel, with a different view of the world (Containers Are Processes With the Kernel’s View Narrowed in Operating Systems).
- Your process is normally PID 1 inside its namespace. PID 1 has special signal semantics: default handlers do not apply, so a process that has not explicitly installed a SIGTERM handler will simply ignore it.
- Layer caching is keyed on the instruction and its inputs. Anything after a changed layer is rebuilt, which makes instruction order a build-time performance decision.
- Config arrives at run time through environment variables and mounted files. Anything supplied at build time is frozen into the artifact and shared by every environment that runs it (Configuration: Separating Code From Environment).
- The cgroup memory limit is enforced by the kernel: exceeding it kills the process, it does not slow it down. A runtime that sizes its heap from total host memory will happily plan to use more than the container is allowed.
The two things a Dockerfile decides
A Dockerfile makes two decisions that outlive it: what ends up in the artifact, and how the build is cached. Both are settled by instruction order, which is why the same set of steps produces a 90 MB image that rebuilds in seconds or a 1.4 GB image that rebuilds from scratch on every commit.
The second stage is the shipped artifact. Anything referenced only in the first stage — compilers, headers, dev dependencies, the source tree — does not exist in production, which is both a size property and a security property.
1# ---- build stage: has the toolchain, is thrown away ----2FROM node:20-bookworm-slim@sha256:<digest> AS build3WORKDIR /app4 5# manifest first: this layer survives ordinary source changes6COPY package.json package-lock.json ./7RUN npm ci8 9COPY . .10RUN npm run build && npm prune --omit=dev11 12# ---- runtime stage: only what is needed to serve ----13FROM node:20-bookworm-slim@sha256:<digest>14WORKDIR /app15ENV NODE_ENV=production16 17COPY --from=build /app/node_modules ./node_modules18COPY --from=build /app/dist ./dist19 20USER node21EXPOSE 808022 23# exec form: node is PID 1 and receives SIGTERM directly24CMD ["node", "dist/server.js"]Two details do most of the work. COPY package*.json before COPY . . keeps the install layer cached across code changes. The exec-form CMD — a JSON array, not a string — is what makes the process PID 1 instead of a child of /bin/sh -c, which is the difference between a clean drain and a hard kill on every deploy.
PID 1, and why deploys silently drop requests
Shell form (CMD npm start) runs your command as a child of a shell. The shell is PID 1, receives SIGTERM, and — depending on the shell and how it invoked the child — may exit without forwarding anything. Your server never learns that it is being shut down, so it keeps serving until the platform loses patience and sends SIGKILL, which cannot be handled.
The symptom is a small number of 502s or connection resets at every deploy, spread across instances, with nothing in the application logs because the application never got to log anything. It is usually blamed on the load balancer.
CMD npm start # PID 1 = /bin/sh -c "npm start" # -> npm (PID 7) # -> node dist/server.js (PID 18) # # SIGTERM goes to the shell. # node never sees it. The platform waits # out the grace period, then SIGKILL.
CMD ["node", "dist/server.js"] # PID 1 = node # # SIGTERM is delivered to the server, # which stops accepting, drains in-flight # requests, closes the pool and exits 0 # well inside the grace period.
Signal delivery is not a style question. With an intermediate shell, the only shutdown path is SIGKILL after the grace period, which terminates in-flight requests, leaves database connections to time out server-side, and abandons any job a worker had claimed. Being PID 1 is the precondition for every graceful behaviour in this module.
Build once, deploy the same bytes everywhere
The reason to containerise is that a deploy becomes a reference to a specific artifact rather than a re-execution of a build. That property is only real if the artifact is identified by content and if nothing environment-specific was baked in at build time.
The practical rule: one build per commit, promoted through environments unchanged, with configuration injected at start. If staging and production run different images, you have a build pipeline, not a deployment artifact.
- 1Build
Produces an image from the exact commit, no environment inputs.
fails by Baking an environment-specific base URL or secret in, so the artifact is not promotable.
- 2Scan
Checks base image and dependencies for known vulnerabilities.
fails by Running only on code changes, so a base-image CVE is never noticed.
- 3Push
Uploads to a registry, tagged by commit sha and by digest.
fails by Using a mutable tag, so the same name resolves to different bytes over time.
- 4Promote
The same digest moves staging -> production.
fails by Rebuilding per environment, which invalidates everything staging proved.
- 5Start
Platform pulls by digest, injects env and secrets, runs the entrypoint.
fails by Missing required config discovered at first request instead of at startup (Validate at Startup, Fail Loudly).
- 6Ready
App validates config and dependencies, then reports ready.
fails by Reporting ready before the pool is built, so the first requests fail (Health Checks: Startup, Readiness, Liveness).
How to build it
Most important first.
- Use a multi-stage build: compile or install with the toolchain in one stage, copy only the produced artifact and production dependencies into a small runtime stage.
- Copy the dependency manifest and install before copying the source, so the dependency layer survives ordinary code changes.
- Start the process in exec form so it becomes PID 1 directly and receives signals. If you need a wrapper, use a minimal init that forwards signals and reaps zombies.
- Run as a non-root user, with a read-only root filesystem where the runtime allows it, and mount a writable temp directory explicitly if one is genuinely needed.
- Pass configuration and secrets at run time only, from the environment or a mounted secret; never as build arguments (Secrets Are Not Configuration).
- Pin the base image by digest, not by a moving tag, so a rebuild does not silently change the operating system underneath a hotfix.
- Tell the runtime the memory ceiling explicitly if the language runtime cannot infer it from the cgroup — an unaware garbage collector will get you OOM-killed at a limit it does not know about.
- Write logs to stdout and stderr, and nothing else to disk that matters.
What can go wrong
- Container exits immediately with no logs because the entrypoint path is wrong; the platform restarts it in a loop and reports "unhealthy" rather than "misconfigured".
- OOMKill under load with no application error, because the kernel killed the process rather than the process failing (OOM Kills and CPU Throttling in Cloud & Infrastructure).
- CPU throttling at a fractional CPU limit: latency rises sharply while CPU utilisation reads well below 100%, because the cgroup period is being exhausted.
- A
latesttag deployed to production, so nobody can say which build is running and rollback has no target. - The image works locally and fails in the registry's target architecture, because the build machine and the runtime nodes differ in CPU architecture.
- A rebuilt tag can be pushed while a rollout is in progress, so different instances of the "same version" run different code. Deploy by digest to eliminate the window (Rolling Deployments).
- Root in the container is root on the host kernel for most escape classes. Run as an unprivileged user and drop capabilities you do not need (Container Security and Its Limits in Security Engineering).
- Every layer you ship is dependency surface you own, including the base image's system packages. Scan images and rebuild on base updates, not only on code changes (Dependency Security).
- Build secrets persist in layer history even when a later layer deletes the file. Deleting is not removing.
- A registry that anyone can push to is a production deploy path. Restrict push, and prefer immutable tags plus digest pinning so the artifact that was reviewed is the artifact that runs (Software Supply Chain Security in Security Engineering).
- "A container is a lightweight VM." It is a process with a restricted view. There is no guest kernel, which is exactly why the memory limit kills rather than swaps (VM vs Container: Where the Boundary Is in Operating Systems).
- "The image is immutable, so the deployment is reproducible." Only if the tag is.
FROM node:20resolves differently next month. - "It runs in Docker, so it will run anywhere." It will run anywhere with a compatible kernel and CPU architecture, and with the same environment variables set.
- "Containerising made it faster." It made it consistent. Any speed change came from the base image, the runtime version or resource limits — not from the container.
Operating it
- Record the image digest of the running instance in startup logs and as a metric label; a tag is not an identity (Deploys Are the First Suspect).
- Track container restart count and exit reason separately from application errors.
OOMKilledandErrorare entirely different investigations. - Compare CPU throttled time against CPU usage. Throttling with low usage is the signature of an under-provisioned CPU limit rather than a slow handler.
- Log the effective configuration at startup, with secrets redacted, so a "wrong environment" deploy is visible in one line (Validate at Startup, Fail Loudly).
- Image size becomes a scaling property once instances are created in response to load: every new instance pulls the image before it can serve, and a large image lengthens the gap between "we need capacity" and "capacity exists" (Autoscaling a Backend).
- At 100 instances a base-image vulnerability is 100 rebuilds and one rollout, which is only cheap if rebuilds are automated.
- Build-cache design starts mattering to developer throughput long before it matters to production.
- Containers buy reproducibility and pay in build pipeline, registry, image lifecycle and base-image patching — real, permanent work that did not exist before.
- Small base images reduce surface and pull time, and cost you the debugging tools you want at 3am. A separate debug image or an ephemeral debug container is the usual compromise.
- Pinning by digest gives determinism and means security updates require a deliberate action rather than arriving by drift.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALImage, layer, entrypoint and cgroup limits behave the same across container runtimes; the CLI differs, the model does not.
- RUNTIME-SPECIFICContainer-awareness of memory limits varies by runtime and version: some JVMs and Node builds read the cgroup limit automatically, others size the heap from host memory and get OOM-killed at a limit they never saw. Verify for your exact runtime version rather than assuming.
- CLOUD-SPECIFICWhether the writable layer, a temp directory or an attached volume survives a restart depends entirely on the platform; some managed container services give a small ephemeral disk that survives restarts but not rescheduling.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.