Nephew has Docker basics down now — images, containers, volumes, networks, Compose. But the real world keeps handing him things the fundamentals didn't cover: a container that dies the instant it starts, a database password sitting in plain sight in an image layer, a build that takes four minutes for a one-line change. Uncle sits him down again, this time for the stuff that actually shows up in PRs and on-call pages.
Part 1: Environment Variables and Secrets — Done Properly
👦 Nephew: Uncle, I need to pass my database password into a container. The easiest thing seems to be just writing it straight into the Dockerfile. What's actually wrong with that?
👨🦳 Uncle: Let's find out the hard way, on purpose, so you never forget it. Suppose you write this:
# DON'T DO THIS
FROM node:20
ENV DB_PASSWORD=super_secret_123
WORKDIR /app
COPY . .
CMD ["npm", "start"]
You build it, it works, you move on. Now run this:
$ docker history my-node-app
👦 Nephew: [runs it] ...uncle, I can see DB_PASSWORD=super_secret_123 sitting right there in the output.
👨🦳 Uncle: Exactly the point. An image is built in layers, and each instruction in your Dockerfile — including ENV — creates a permanent layer, baked into the image, forever, unless you rebuild without it. It doesn't matter that you never git commit the password anywhere. The moment you docker push that image to any registry, anyone who can pull it — a teammate, a CI system, potentially the public if it's on a public registry — can run docker history or even just unpack the image's layers directly and read your password in plain text. It's not hidden. It's not encrypted. It's sitting in the image like a sticky note taped to the outside of a box.
👦 Nephew: Okay, that's genuinely alarming. So what's the actual right way?
👨🦳 Uncle: There are three levels here, and I want you to know all three, because each solves a slightly different problem.
Level 1 — Passing environment variables at runtime, not build time
Instead of baking the value into the image, you hand it to the container the moment it starts, and nothing before that:
$ docker run -e DB_PASSWORD=super_secret_123 my-node-app
This is already much better — the password never becomes part of the image itself. It only exists in that one running container's environment, in memory, for as long as it's alive. But typing secrets directly into your terminal history is still not great — they end up in your shell's history file, and anyone reading over your shoulder sees them.
Level 2 — .env files
# .env (a plain text file, NOT committed to git)
DB_PASSWORD=super_secret_123
DB_HOST=my-postgres
API_KEY=abc123xyz
$ docker run --env-file .env my-node-app
👦 Nephew: So Docker just reads that file and injects every line as an environment variable?
👨🦳 Uncle: Exactly — one file, one flag, and none of it sits inside the image itself. The critical discipline here is this: that .env file must be added to .gitignore immediately, the moment you create it, before you even fill it in. If it ever gets committed to your repository, the exact same problem from before comes right back — except now it's sitting in your git history, which is often even harder to fully scrub than an image layer.
# .gitignore
.env
In Compose, it's just as simple:
services:
app:
build: .
env_file:
- .env
Level 3 — Actual secret managers (for production, seriously)
👦 Nephew: And .env files are fine for production too?
👨🦳 Uncle: Honestly — no, not for anything that genuinely matters in production. A .env file sitting on a production server is still a plain text file that anyone with server access can simply cat. For real production secrets — database credentials, API keys, tokens — the actual professional answer is a dedicated secrets manager: AWS Secrets Manager, HashiCorp Vault, Doppler, or your cloud provider's equivalent. These store secrets encrypted, control exactly who and what can read them, log every access, and let you rotate a leaked secret without rebuilding or redeploying anything. Your application, at startup, asks the secrets manager for the value it needs, over an authenticated connection — the secret itself never sits in a file, an image, or your shell history at all.
LOCAL DEVELOPMENT: .env file → good enough, gitignored
STAGING/PRODUCTION: real secrets manager → the actual right answer
NEVER, at any point: ENV in a Dockerfile with a real secret value
👦 Nephew: So the rule of thumb is: anything that ends up baked into the image is basically public, and anything genuinely sensitive needs to arrive at runtime, from somewhere that isn't version control.
👨🦳 Uncle: That's exactly the rule, and if you remember nothing else from this section, remember that one line.
Part 2: Multi-Stage Builds — The One Optimization Every Developer Should Actually Know
👦 Nephew: My Node image is huge — like, over a gigabyte for what feels like a small app. Why?
👨🦳 Uncle: Let's actually look at what's likely inside it. A typical naive Dockerfile does this:
# The naive version — works, but bloated
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build # compiles TypeScript to JavaScript, say
CMD ["npm", "start"]
👦 Nephew: That looks pretty normal to me, honestly.
👨🦳 Uncle: It looks normal, and it works — but think about everything that ends up sitting inside that final image, shipped to production, that your actual running app never needs even once it's live:
Sitting inside your "naive" image, unnecessarily:
- devDependencies (testing libraries, linters, type definitions,
build tools) — needed to BUILD the app, not to RUN it
- Your original TypeScript source files — once compiled, the
original .ts files serve no purpose at runtime
- Test files, test fixtures, documentation, README, .github
workflow files if you weren't careful with .dockerignore
- The entire npm cache used during install
None of that is needed for the app to actually run. It's all leftover scaffolding from building the app, needlessly shipped along with it, permanently bloating the image, slowing every deploy, and quietly widening what's called the attack surface — more files, more installed packages inside the container means more potential vulnerabilities somebody could exploit if they ever got in.
👦 Nephew: So how do I get rid of all that, but still actually be able to build the app?
👨🦳 Uncle: This is exactly what a multi-stage build solves, and once you see it, you'll use it everywhere. The idea: use one temporary container to do all the messy building work — installing every dependency, compiling TypeScript, running the build — and then throw that entire container away, keeping only the small handful of output files it actually produced. Those output files get copied into a second, much smaller, clean container that only has what's needed to actually run the app.
# ---------- STAGE 1: "builder" — does all the messy work ----------
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install # installs EVERYTHING, including devDependencies
COPY . .
RUN npm run build # compiles TypeScript -> plain JavaScript in /app/dist
# ---------- STAGE 2: the actual runtime image — small and clean ----------
FROM node:20-slim AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev # ONLY production dependencies this time
# Copy ONLY the compiled output from the builder stage — nothing else
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
👦 Nephew: So builder is basically a throwaway workshop, and runtime only receives the finished product, not the mess that made it?
👨🦳 Uncle: Precisely that picture. Think of it like a furniture workshop and a showroom. The workshop is full of sawdust, offcuts, tools, half-finished pieces — genuinely necessary to make the chair, but nobody wants any of that mess sitting in the actual showroom where customers browse. You build the chair in the workshop, then carry only the finished chair into the showroom. The sawdust, the tools, the offcuts — all of it stays behind, thrown away, never seen by the customer.
COPY --from=builder is the exact line doing that carrying — copying specific, named files from one stage into another, while everything else about the builder stage — its node_modules full of devDependencies, its original TypeScript source, its build tool installations — is simply discarded once the final image is built. It was never part of your actual runtime image at all.
Naive single-stage image: ~1.2 GB (everything, forever)
Multi-stage final image: ~150 MB (only what's needed to run)
👦 Nephew: That's a huge difference. And that directly means faster deploys, since there's less to push and pull?
👨🦳 Uncle: Exactly — smaller images pull faster over the network, start faster, and simply have fewer things inside them that could ever go wrong or be exploited. This one pattern — build stage, then a slim runtime stage — is genuinely one of the highest-value things you can learn in Docker as an application developer, and you'll reuse this exact shape in almost every real project going forward.
Part 3: .dockerignore — The File Everyone Forgets, Until It Bites Them
👦 Nephew: Speaking of unnecessary files ending up in images — I've definitely seen node_modules somehow end up inside a container even though I RUN npm install separately. How?
👨🦳 Uncle: This is almost always because of one line — COPY . . — copying everything in your project folder into the image, completely unfiltered, unless you've told Docker otherwise.
COPY . .
Think about what actually sits in your project folder right now, on your own machine:
your-project/
node_modules/ ← potentially hundreds of MB, and OS-specific binaries
.git/ ← your entire commit history
.env ← your actual secrets, if you have one locally!
dist/ ← old build output from a previous build
*.log ← log files from local runs
src/
package.json
Without anything telling it otherwise, COPY . . drags every single one of those into your image — including, worst of all, your .env file with real secrets in it, and your entire .git history. That single careless line can turn into the exact secret-leak problem from Part 1, except now it's not even a deliberate ENV mistake — it's an accident, hiding inside a routine COPY.
👦 Nephew: So .dockerignore is Docker's version of .gitignore?
👨🦳 Uncle: Exactly that idea, same mechanism, different tool:
# .dockerignore
node_modules
.git
.env
*.log
dist
npm-debug.log
.DS_Store
README.md
.github
Create this file sitting right next to your Dockerfile, and COPY . . will simply skip everything listed in it, as if those files and folders don't exist at all, from Docker's point of view.
👦 Nephew: And this also makes builds faster, right? Because Docker doesn't have to send a huge node_modules folder into the build process every single time.
👨🦳 Uncle: Exactly — everything in your project folder gets sent to the Docker build process as something called the build context, before a single instruction even runs. A bloated build context with a massive, un-ignored node_modules folder slows down every single build, even ones that don't touch your dependencies at all. One small file, .dockerignore, fixes both the security leak and the slow build in one shot. It's genuinely one of the first files you should create in any new Dockerized project — before you even write your first real Dockerfile instruction.
Part 4: Live Reload — Docker While You're Actively Coding, Not Just Docker for Production
👦 Nephew: Here's something that's genuinely annoyed me. Every time I change one line of code, I have to rebuild the whole image and restart the container just to see the change. That feels unbearably slow for actual day-to-day development.
👨🦳 Uncle: And rightly so — because what you're describing is trying to use a production-shaped setup for active development, and those two situations genuinely want different things. In production, you want an immutable, self-contained image — that's exactly what we built in Parts 2 and 3. While you're actively coding, you want your changes to show up instantly, without a rebuild, the same way they would if you weren't using Docker at all.
👦 Nephew: So how do people actually solve that?
👨🦳 Uncle: By combining two things we've touched on before, in a new way: a bind mount (from your volumes conversation) to link your actual source folder on your machine directly into the running container, plus a tool like nodemon running inside the container, watching for file changes and automatically restarting your app the moment something changes.
# docker-compose.dev.yml — a SEPARATE compose file, just for development
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- ./src:/app/src # bind mount: your REAL source folder,
# linked directly into the container
- /app/node_modules # a trick: keep the container's own
# node_modules, don't let it get
# overwritten by an empty local one
command: npx nodemon src/index.js
👦 Nephew: Walk me through what that second volume line is actually doing — /app/node_modules with nothing before the colon looks odd.
👨🦳 Uncle: Good catch, and it trips people up constantly. When you bind-mount ./src:/app/src, you're linking your local src folder into the container. But if your entire project folder got bind-mounted (instead of just src), your local machine's node_modules folder — which might not even exist locally, or might have different binaries than what the container needs — would overwrite the container's own properly-installed node_modules. That anonymous volume line, /app/node_modules, tells Docker: "for this specific folder inside the container, don't let any bind mount override it — keep whatever the image itself already has there." It's a small but important protective trick specifically for this development setup.
# Bring up your DEV setup, using this separate compose file
$ docker-compose -f docker-compose.dev.yml up
👦 Nephew: So now, when I edit a file in src/ on my actual laptop, it instantly reflects inside the running container, and nodemon restarts the app automatically?
👨🦳 Uncle: Exactly — no rebuild, no manual restart, just save your file and watch the container pick it up within a second or two, same as running the app locally without Docker at all, except now it's genuinely running inside the exact same environment your production image is based on.
👦 Nephew: And I keep the normal Dockerfile and docker-compose.yml for actually deploying, separate from this dev one?
👨🦳 Uncle: Exactly right — most real projects end up with two shapes side by side: a lean, multi-stage Dockerfile and docker-compose.yml for what actually ships, and a separate, more permissive docker-compose.dev.yml (sometimes with a matching Dockerfile.dev) purely for your own local iteration speed. Different goals, different tools for each.
Part 5: Debugging a Container That Won't Start
👦 Nephew: This is the one that genuinely frustrates me the most. I run docker run my-app, and it just... exits immediately. No obvious error. What do I even do?
👨🦳 Uncle: Let's build a proper, repeatable troubleshooting habit, because this exact situation will happen to you constantly throughout your career, and panicking each time wastes hours. The very first thing, always:
$ docker ps -a
👦 Nephew: Why -a specifically?
👨🦳 Uncle: Because docker ps on its own only shows currently running containers — and if yours exited immediately, it won't show up there at all, making it look like it vanished entirely. -a shows every container, including stopped and exited ones, which is exactly what you need to even see it.
$ docker ps -a
CONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f6 my-app Exited (1) 4 seconds ago frosty_curie
👦 Nephew: That Exited (1) — what does the number mean?
👨🦳 Uncle: That's the exit code — the number the process inside the container returned when it stopped, and it's genuinely one of the most useful debugging clues you'll get. A few worth actually memorizing:
| Exit Code | What It Usually Means |
|---|---|
0 |
Clean, successful exit — the program finished normally and intentionally |
1 |
A general application error — your code itself threw an unhandled error or crashed |
137 |
The container was forcibly killed — almost always the OOM killer (Out Of Memory), meaning it hit a memory limit and Linux killed it |
139 |
Segmentation fault — the process tried to access memory it wasn't allowed to (more common in compiled languages than Node, but you'll see it eventually) |
143 |
The container received a graceful "please stop" signal (SIGTERM) and shut down in response — normal during a docker stop
|
👦 Nephew: So if I ever see 137 specifically, that's basically Docker telling me "this container asked for more memory than it was allowed, and got killed for it"?
👨🦳 Uncle: Exactly that specific meaning, every time — and once you recognize 137, you immediately know to go check memory limits (which we'll cover properly in Part 10), instead of hunting through your application code for a bug that isn't actually there.
Next, always check the actual output the container produced before it died:
$ docker logs frosty_curie
Error: Cannot find module 'express'
at Function.Module._resolveFilename ...
👦 Nephew: Oh — that's a genuinely useful error. Missing dependency.
👨🦳 Uncle: Exactly, and docker logs is very often where the real answer is hiding — people sometimes forget to check it and go straight to guessing. If logs alone aren't enough, docker inspect gives you the full, detailed picture of exactly how the container was configured and why it might have failed:
$ docker inspect frosty_curie
# Look specifically for these fields in the output:
"State": {
"Status": "exited",
"ExitCode": 1,
"Error": "",
...
},
"Mounts": [ ... ], # confirm your volumes actually mounted correctly
"Config": {
"Env": [ ... ] # confirm your environment variables actually made it in
}
The Classic "It Starts, Then Immediately Exits" Confusion
👦 Nephew: Here's a specific one that confused me for hours once — my container's CMD was just starting a background service, like an SSH daemon, and the container exited right after starting it, even though the service itself didn't crash.
👨🦳 Uncle: Ah, this is one of the single most common points of confusion for people new to Docker, and it comes down to one specific rule: a container stays alive exactly as long as its main process (PID 1 inside the container) stays alive — and not one moment longer. If your CMD starts a service that immediately forks itself into the background and returns control (the way many traditional services are designed to behave), then, from Docker's point of view, that main process has finished — even though the actual service is still technically running somewhere in the background. Docker sees the main process end, and shuts the whole container down immediately after.
WRONG (for Docker):
CMD service nginx start
→ starts nginx as a background daemon, main process exits
→ Docker sees "main process finished" → container stops
RIGHT (for Docker):
CMD ["nginx", "-g", "daemon off;"]
→ runs nginx directly, IN THE FOREGROUND, as the main process
→ main process keeps running → container stays alive
👦 Nephew: So the actual rule is: the main command has to run in the foreground, continuously, for as long as you want the container alive?
👨🦳 Uncle: Exactly the rule, and it's why a normal node index.js or npm start works fine as a CMD — Node itself runs in the foreground and keeps the process alive naturally, listening for requests, never returning control until it's explicitly stopped. Any tool that's traditionally designed to "start and background itself" needs a specific "run in foreground" flag or mode to behave correctly inside a container.
Part 6: Health Checks — Telling the Difference Between "Started" and "Actually Ready"
👦 Nephew: I've noticed my app's process can technically be running inside a container, but it might still be, say, in the middle of connecting to the database, or still warming up some internal cache — not actually ready to serve real traffic yet. How does anything downstream know the difference?
👨🦳 Uncle: By default — it doesn't, and that's a genuinely important gap. Docker, by itself, only tracks one very simple thing: "is the main process still alive, yes or no." It has no built-in concept of "is this application actually ready to correctly handle a request right now." Those are two very different questions, and conflating them causes real production incidents — a load balancer might start sending live traffic to a container the instant it starts, even though your app is still three seconds away from finishing its database connection, and those early requests simply fail.
This is exactly what a HEALTHCHECK solves — a specific instruction, in your Dockerfile, telling Docker how to actually ask your application "are you genuinely okay right now?", rather than just assuming so because the process hasn't crashed.
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install --omit=dev
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
Your app itself needs a genuinely tiny, dedicated endpoint to answer this question honestly:
// A minimal health check endpoint in your Express app
app.get('/health', (req, res) => {
// In a real app, you might actually check your DB connection,
// cache connection, etc. here before answering "ok"
res.status(200).send('ok');
});
👦 Nephew: Let's go through those flags — --interval, --timeout, --start-period, --retries.
👨🦳 Uncle: Gladly, one at a time:
-
--interval=30s— how often Docker asks the question, "are you healthy?" — every 30 seconds here. -
--timeout=5s— how long Docker waits for an answer before considering that particular check a failure. -
--start-period=10s— a grace period right after the container starts, during which failed checks don't immediately count against it — genuinely useful for apps that legitimately take a few seconds to warm up before they can honestly answer "yes." -
--retries=3— how many consecutive failures in a row it takes before Docker actually marks the container asunhealthy, rather than reacting to one single blip.
$ docker ps
CONTAINER ID IMAGE STATUS
a1b2c3d4e5f6 my-app Up 2 minutes (healthy)
👦 Nephew: And that (healthy) status — who actually reads and acts on it?
👨🦳 Uncle: Docker itself surfaces the status, but the real payoff comes when you're running under an orchestrator — Kubernetes, ECS, Docker Swarm — because those systems actively watch this exact health status and make real decisions based on it: don't route live traffic to a container until it reports healthy, automatically restart or replace a container that's gone unhealthy, and so on. Without a proper health check, an orchestrator only knows "the process hasn't crashed" — it genuinely has no way to know "the process is actually ready to correctly do its job" unless you tell it how to ask.
Part 7: Image Size and Layer Discipline
👦 Nephew: I've seen node, node:slim, and node:alpine as base image options, and I've always just picked one without really understanding the tradeoff. What's actually different?
👨🦳 Uncle: This is worth understanding properly, because "just always pick the smallest one" is actually bad advice, said too often without the nuance behind it.
node (the "full" image)
Built on a full Debian base — includes a huge range of common
system tools, libraries, and utilities pre-installed.
Size: often 900MB - 1GB+
node:slim
Also Debian-based, but deliberately stripped down — most
non-essential tools and libraries removed.
Size: often 150-250MB
node:alpine
Built on Alpine Linux, an entirely different, extremely minimal
Linux distribution, using a different underlying C library (musl,
instead of the glibc most Linux distros use).
Size: often 40-120MB
👦 Nephew: So alpine is obviously the best choice, then — smallest, fastest to pull?
👨🦳 Uncle: That's exactly the trap in "just always pick the smallest one." Alpine's dramatically smaller size comes specifically from using musl instead of glibc as its core C library — and some npm packages, particularly ones with native C/C++ addons (compiled binary code bundled inside certain npm packages), are built expecting glibc, and can genuinely fail to install or behave subtly differently on musl-based Alpine. You can spend real, frustrating hours debugging a mysterious failure that only happens inside the Alpine image but never on your own machine, purely because of this exact underlying difference.
CHOOSE FULL "node" WHEN:
- You're still actively developing and want the extra tools available
- You've hit compatibility issues with alpine and don't have
time to chase them down right now
CHOOSE "node:slim" WHEN (a genuinely good default for most apps):
- You want a meaningfully smaller image than the full one
- You want normal glibc compatibility, avoiding Alpine-specific
package issues entirely
CHOOSE "node:alpine" WHEN:
- Image size is a genuine priority (e.g., very high-frequency
deploys, or edge/serverless environments sensitive to image size)
- You've actually verified your specific dependencies work
correctly on musl (or you're not using anything with native
C/C++ addons at all)
👦 Nephew: So it's a real tradeoff — size versus compatibility risk — not just "smaller equals strictly better"?
👨🦳 Uncle: Exactly, and node:slim genuinely is the sensible default for most application developers, precisely because it captures most of the size benefit while sidestepping the Alpine compatibility risk entirely.
Layer Order — Why It Affects Your Daily Rebuild Speed, Not Just Final Size
👦 Nephew: We touched on layer caching back with COPY package*.json ./ before COPY . . — is there more to that idea?
👨🦳 Uncle: There's real daily value in generalizing that instinct across your whole Dockerfile. Docker caches each instruction as its own layer, and reuses a cached layer only if nothing above it, and the instruction itself, has changed. The moment one instruction's cache is invalidated, every single instruction after it in the file also gets rebuilt from scratch, even if those later steps individually didn't change at all.
# GOOD ORDER — things that change RARELY go first,
# things that change OFTEN go last
FROM node:20-slim # changes almost never
WORKDIR /app # changes almost never
COPY package*.json ./ # changes only when you add/update a dependency
RUN npm install --omit=dev # only re-runs if package*.json actually changed
COPY . . # your actual source code — changes constantly
EXPOSE 3000
CMD ["node", "index.js"]
# BAD ORDER — this throws away caching almost entirely
FROM node:20-slim
WORKDIR /app
COPY . . # your code changes constantly, so this
# layer is invalidated on nearly every build...
RUN npm install --omit=dev # ...which means THIS now re-runs on
# almost every single build too, even
# if you didn't touch any dependency at all
EXPOSE 3000
CMD ["node", "index.js"]
👦 Nephew: So in the "bad order" version, editing a single line of application code forces a full npm install to rerun every single time, even though dependencies didn't actually change?
👨🦳 Uncle: Exactly — and if you've ever wondered why one teammate's Docker builds feel instant while another's take minutes for a tiny change, this ordering is very often the entire, boring reason. Put things that change rarely near the top of the Dockerfile, and things that change constantly — your actual source code — as close to the bottom as you reasonably can.
Part 8: Connecting a Container to a Database That's NOT in Docker
👦 Nephew: Here's one that genuinely confused me for an embarrassing amount of time. I had Postgres installed directly on my laptop, not in Docker, and my containerized Node app kept failing to connect to localhost:5432, even though Postgres was definitely running and reachable from my laptop's terminal directly.
👨🦳 Uncle: This is one of the single most common "gotchas" for developers moving into containers, and it comes down to one core fact we actually covered back in the networking conversation: a container has its own isolated network namespace. When code running inside a container says localhost, it means "this container itself" — not your actual physical laptop. Your laptop, from the container's point of view, is a completely different machine on the network, even though physically they're the exact same piece of hardware.
Your Laptop Container
│ │
Postgres running here, Your Node app runs here
listening on localhost:5432 and says "connect to
(meaning: THIS machine) localhost:5432" — but
"localhost" HERE means
THE CONTAINER ITSELF,
not your laptop!
👦 Nephew: So the container is essentially looking for Postgres inside itself, finding nothing there, and failing?
👨🦳 Uncle: Exactly that. Your laptop and the container are two separate "machines," from a networking point of view, even though one is physically running inside the other. What you actually need is a special hostname that Docker itself provides, specifically meaning "the actual host machine, from inside a container":
// Instead of this (wrong, from inside a container):
const dbHost = 'localhost';
// Use this:
const dbHost = 'host.docker.internal';
$ docker run -e DB_HOST=host.docker.internal my-node-app
👦 Nephew: So host.docker.internal is basically a special address meaning "look outward, to the actual physical machine this container is living on"?
👨🦳 Uncle: Exactly that meaning, provided directly by Docker Desktop on macOS and Windows out of the box. On native Linux, you may need to add one explicit flag to make that same hostname available, since Linux's networking model handles this slightly differently:
# docker-compose.yml, if you're on native Linux and host.docker.internal
# isn't resolving automatically
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
👦 Nephew: And if my database is also running in a Docker container, alongside my app's container — do I need any of this host.docker.internal business at all?
👨🦳 Uncle: No — and this is worth being precise about, because it's a genuinely different scenario. If both containers are attached to the same Docker network (exactly like we set up back in the networking conversation), you go back to using the container's own name as the hostname — postgres, or whatever you named it — since Docker's internal DNS resolves that directly between containers on the same network. host.docker.internal is specifically, only for the case where the thing you're reaching is running directly on your actual host machine, outside of any container at all.
Database ALSO in Docker, same network → use the container's NAME
Database on your ACTUAL machine, no Docker → use host.docker.internal
Part 9: CI/CD Basics Every Developer Should Recognize
👦 Nephew: My team's pipeline builds a Docker image in CI, and I've heard "build once, run everywhere" thrown around in that context. What does that actually mean in practice, day to day?
👨🦳 Uncle: It means something very specific, and it's actually one of Docker's most valuable real-world benefits, beyond just "packaging." The idea: you build exactly one image, one single time, in your CI pipeline. That exact same image — same bytes, same layers, nothing rebuilt or recompiled — is then tested, and if it passes, that identical image is the one deployed to staging, and later, to production.
CODE PUSHED
│
v
CI BUILDS the image ONCE → my-app:a1b2c3d
│
v
TESTS run INSIDE that exact image
│
v
If tests pass, that EXACT SAME image
(not rebuilt, not recompiled) is:
│
├──> deployed to STAGING
│
└──> later, deployed to PRODUCTION
👦 Nephew: So the whole point is: you're never rebuilding the image between stages, which means you can never accidentally end up with subtly different code running in staging versus production?
👨🦳 Uncle: Exactly — that's the actual guarantee "build once, run everywhere" is protecting. If you rebuild the image separately at each stage — once for staging, once again for production — you've reopened the door to exactly the kind of subtle environment drift Docker was supposed to eliminate in the first place: maybe a dependency got a patch version bump between the two builds, maybe a base image was updated in between, and now staging and production are quietly, invisibly different, even though "the same code" went through both pipelines.
Image Tagging — Why latest Is a Trap in Production
👦 Nephew: I've definitely just used my-app:latest everywhere without thinking much about it. Is that actually a problem?
👨🦳 Uncle: In production specifically, yes — a real and common one. latest isn't magic — it's just an ordinary tag name that, by convention, usually points to whatever was most recently built. The core problem: if your production server is configured to always pull my-app:latest, and a new image gets pushed with that same tag — even accidentally, even by someone else's unrelated deploy — your production environment can silently start running completely different code than you think it's running, with zero explicit signal that anything changed.
# FRAGILE — which exact version is this, really?
$ docker run my-app:latest
# MUCH BETTER — tied to an exact, specific, traceable build
$ docker run my-app:a1b2c3d # tagged with the exact git commit hash
$ docker run my-app:v2.4.1 # tagged with an explicit semantic version
👦 Nephew: So tagging with the actual git commit hash, or a proper version number, means I can always know with certainty exactly what code is running in any given environment, at any moment?
👨🦳 Uncle: Exactly — and it also means rolling back a bad deploy becomes trivial and unambiguous: you're not hoping latest still secretly means the old version somewhere, you're deliberately redeploying the exact previous tag you know was working, by its exact name.
Part 10: Resource Limits — Why Your Container Randomly Dies in Production
👦 Nephew: We touched on --memory and --cpus flags before, from the angle of "how to set a limit." But I want to properly understand it from the other direction — why does my container sometimes just die, seemingly at random, in production?
👨🦳 Uncle: Let's connect this directly back to exit code 137 from Part 5, because this is exactly where that number comes from, in practice. When you (or your orchestrator) set a memory limit on a container:
$ docker run --memory="512m" my-node-app
You're telling the Linux kernel: "this specific container is allowed to use at most 512MB of RAM, and not one byte more, ever." If your application's memory usage genuinely climbs past that limit — a memory leak slowly building up over hours, or simply a legitimately memory-hungry operation, like processing an unexpectedly large file — the Linux kernel's OOM killer (Out Of Memory killer) steps in and forcibly terminates the process immediately, with no warning, no graceful shutdown, no chance for your app to clean up or log a helpful final message. It's an abrupt, hard kill — and that's precisely why the container's status shows exit code 137, and why it can feel like it "randomly" died with no obvious explanation in your own application logs.
Your app's memory usage, over time, under a 512MB limit:
Memory
│ ✕ ← OOM killer strikes
│ ___--‾ here, container
│ ___--‾ dies with 137,
│ ___--‾ no warning given
│ ___--‾
│ ___--‾
│______--‾
└──────────────────────────────────────────────> Time
512MB limit ─────────────────────────────────────
👦 Nephew: So if I see 137 in production, the fix isn't to go hunting for a bug in my code first — it's to check whether my memory limit is actually reasonable for what my app genuinely needs?
👨🦳 Uncle: That's usually the right first move, yes — though it's worth being honest that there are genuinely two different underlying causes here, and you want to tell them apart:
CAUSE 1 — The limit is simply set too low
Your app has a genuine, legitimate need for more memory than
you've allowed it. Fix: raise the memory limit to something
realistic for your actual workload.
CAUSE 2 — There's an actual memory leak
Your app's memory usage climbs continuously over time, without
ever leveling off, no matter how high you raise the limit —
it'll eventually hit ANY ceiling you set, given enough time.
Fix: this is a genuine bug in your application code that needs
fixing, not a Docker configuration problem at all.
You can tell these apart by simply watching memory usage over time:
$ docker stats my-app-container
CONTAINER ID NAME CPU % MEM USAGE / LIMIT
a1b2c3d4e5f6 my-app 12.3% 487.2MiB / 512MiB
👦 Nephew: If that number keeps climbing steadily over hours, even under light, steady traffic, that's a real leak — not just a limit that's set a bit too tight?
👨🦳 Uncle: Exactly the right instinct — a limit that's merely a bit too low tends to get hit fairly early and consistently, at a roughly predictable memory level. A genuine leak keeps climbing indefinitely, given enough time, regardless of what limit you set, because the underlying problem is your application slowly, continuously accumulating memory it never actually releases. docker stats, watched over a real stretch of time — not just a quick glance — is usually enough to tell you honestly which of the two you're actually dealing with.
Key Takeaways
-
Secrets belong at runtime, never at build time — anything baked into an image via
ENVin a Dockerfile is effectively public once the image exists anywhere; use--env-filelocally and a real secrets manager in production. -
Multi-stage builds separate the messy work of building your app (
builderstage) from the clean, minimal image that actually runs it (runtimestage) — smaller images, faster deploys, less attack surface. -
.dockerignorepreventsCOPY . .from silently draggingnode_modules,.git, and.envinto your image — create it before you write your first real Dockerfile. -
Bind-mounting your source code plus a tool like
nodemongives you instant live reload during active development, kept in a separate compose file from what actually ships to production. -
docker ps -a,docker logs, anddocker inspectare your first three moves when a container won't start — and exit codes (especially137for OOM-killed) tell you where to look before you start guessing. -
HEALTHCHECKtells Docker and any orchestrator the difference between "the process hasn't crashed" and "the app is actually ready to serve traffic." -
Base image choice is a real tradeoff —
alpineis smallest but can break native dependencies viamusl;slimis usually the sensible middle ground for most apps. -
localhostinside a container means the container itself — reach your actual host machine's services viahost.docker.internal, and reach other containers on the same network by their container name. -
"Build once, run everywhere" means the exact same tested image moves through every environment untouched — and tagging with a commit hash or version, instead of
latest, is what makes that guarantee actually trustworthy. -
Memory limits and the OOM killer are why containers die abruptly with exit code
137—docker statsover time tells you whether the fix is raising the limit, or actually fixing a leak.
👦 Nephew: Honestly, this is the stuff that never shows up in a "getting started with Docker" tutorial, but it's exactly what actually happens once you're shipping real code.
👨🦳 Uncle: That's exactly why it's worth learning deliberately, rather than only picking it up one painful incident at a time. The fundamentals get you running Docker. This is what actually keeps you calm the day something in production quietly breaks at 11 PM.
Top comments (0)