We all have that one commit we’re not proud of. You know, the one pushed late on a Friday night with the message: "pls work".
For me, that commit contained my very first Dockerfile. I was fresh out of a tutorial, convinced I had mastered containerization. Spoiler: I had not.
Looking back at that file is like looking at a mullet haircut from 1987. It was functional, but messy, insecure, and bloated.
Let me show you the evolution. The cringe vs. the clean. The Before vs. the After.
The "Before": The Wild West Dockerfile
I was building a simple Node.js API. My logic was: Install everything, copy everything, run everything as root, and pray.
dockerfile
Before: The "It works on my machine" special
FROM node:latest
Create app directory (Good start, but wait for it...)
WORKDIR /usr/src/app
Copy EVERYTHING. Node modules? Yeah, copy those too. .git? Sure.
COPY . .
Install dependencies (including dev dependencies, because why not?)
RUN npm install
Install global tools nobody asked for
RUN npm install -g nodemon pm2
Expose a port (Only one? Let's guess!)
EXPOSE 3000
Run as root (Security? Never heard of her)
CMD ["npm", "start"]
Why this made me a monster
node:latest is a ticking time bomb. Today it’s Node 21; next week it’s Node 22. Your build will randomly break because dependencies don’t match.
COPY . . is lazy and dangerous. I copied node_modules from my host machine (which was built for Windows/Mac) into the Linux container. This causes segfaults and mysterious "module not found" errors. I also copied secrets, SSH keys, and my .env file into the final image.
Giant image size. My image was ~1.2GB. Pulling that over coffee shop Wi-Fi? Forget it.
Running as root. If a hacker exploited my Node app, they had full root access to the container. Yikes.
The "After": The Professional's Approach
After three production outages and a mentor slapping me (metaphorically) with a security scan, I rewrote everything. Here is the Dockerfile I use today.
dockerfile
After: Lean, mean, and secure
1. Specific version + slim variant
FROM node:20-slim AS dependencies
WORKDIR /app
2. Copy ONLY package files first (Leverage Docker caching)
COPY package*.json ./
RUN npm ci --only=production
3. Multi-stage build: Separate build from runtime
FROM node:20-slim AS runtime
4. Create a non-root user
RUN groupadd -r nodejs && useradd -r -g nodejs nodejs
WORKDIR /app
5. Copy only what we need from the previous stage
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
6. Security: Drop capabilities & set user
USER nodejs
7. Health check so orchestrators know it's alive
HEALTHCHECK --interval=30s --timeout=3s CMD node health.js
EXPOSE 3000
8. Production grade runtime
CMD ["node", "server.js"]
Breaking Down the Differences
Let's put these two files side-by-side in a cage match.
Feature The "Before" (Horror) The "After" (Glory)
Base Image node:latest (floating, dangerous) node:20-slim (specific, smaller)
Caching Copies everything, invalidates cache always Copies package.json first, then code. Much faster rebuilds.
Permissions Runs as root Runs as nodejs user
Multi-stage No Yes (Separates build tools from runtime)
Dependencies npm install (includes dev dependencies) npm ci --only=production (exact versions, no dev junk)
What's inside Source code, .git, .env, local node_modules Only the bare necessities
The Actual Metrics (Real numbers from my app)
Metric Before After Improvement
Image Size 1.2 GB 210 MB 83% smaller
Build Time 45 seconds 6 seconds 87% faster
Vulnerabilities (npm audit) 23 high/critical 0 Infinite % better
Container Start Time 3.2 seconds 0.8 seconds 75% faster
The 5 Lessons I Learned (So You Don't Suffer)
If you take nothing else away from this post, remember these five golden rules:
Never use latest. Pin your versions. node:20-alpine or python:3.11-slim are your friends.
Leverage layer caching. Copy dependency files before your source code. Docker caches each step. If package.json hasn't changed, Docker reuses the cached npm install. This saves minutes.
Don't run as root. Add a user. USER appuser. It takes two lines and stops 90% of container exploits.
Multi-stage builds are magic. One Dockerfile can have multiple FROM statements. Use the first stage to compile/build, the final stage to only run the binary.
.dockerignore is non-negotiable. The COPY . . is evil. Create a .dockerignore file (same syntax as .gitignore) to exclude node_modules, .git, .env, and Dockerfile itself.
The Final Verdict
My first Dockerfile was a monolith of mistakes. It was a beginner's rite of passage—like burning toast or accidentally deleting a database in dev.
But the "after" version? That image is secure, tiny, and fast. It belongs in production.
Where are you on your Docker journey? Have you committed a "before" Dockerfile recently? Drop it in the comments so we can all cringe together (and then help you fix it).
Happy containerizing, and may your builds always be cache hits. 🐳
Top comments (0)