A DevOps Engineer's Evidence-Based Approach
This guide walks through a real open-source project Ridan Express and shows you exactly how to analyze a repo's files, understand what the app needs, and write a production-ready Dockerfile without guessing.
Who This Is For
You're learning DevOps. You clone a repo, stare at it, and freeze you don't know where to start. Should you Dockerize it? What base image do you use? What commands do you run inside the container?
This article teaches you the mental model professionals use: reading a project's files as clues and letting the evidence tell you what to build.
The Project: Ridan Express
Ridan Express is a React frontend for a ride/delivery platform (think Uber or DoorDash). It uses:
- React 18 + Vite as the build tool
- Tailwind CSS + Material UI for styling
- Redux for state management
-
socket.io-clientfor real-time features (live tracking) -
mapbox-glfor maps - Stripe for payments
- Google OAuth for login
Currently deployed on Vercel. No Dockerfile exists. That's your job.
Step 1: Read the Files Before You Write Anything
The golden rule: never write a Dockerfile cold. Always read these files first:
| File | What it tells you |
|---|---|
package.json |
Language, runtime version, dependencies, build commands |
package-lock.json |
Exact locked dependency versions |
vite.config.js / webpack.config.js
|
Build tool and output configuration |
vercel.json / netlify.toml
|
How it's currently deployed (big clue) |
build/ or dist/ folder |
Where compiled output lands |
.env.example |
Environment variables the app needs |
Let's walk through each clue in Ridan Express.
Clue 1: package.json → Choose Your Base Image
Opening package.json, the first thing to notice is this:
"engines": {
"node": "22.x"
}
The developer told you exactly which Node.js version this app needs. This directly maps to your Dockerfile's first line:
FROM node:22-alpine
Why alpine? The Alpine variant of Node is a minimal Linux distribution around 50MB instead of 900MB+ for the full image. Always prefer Alpine for production unless you need specific system libraries.
The rule: "engines" in package.json → your FROM node:X-alpine version.
Clue 2: package-lock.json → Use npm ci, Not npm install
The presence of package-lock.json alongside package.json is a deliberate signal. Here's the critical distinction:
| Command | Behavior |
|---|---|
npm install |
Installs dependencies, may update versions |
npm ci |
Installs exact versions from package-lock.json, fails if they don't match |
In a Docker build, you always want npm ci. It's faster, deterministic, and prevents "it worked on my machine" bugs. Your Dockerfile should copy both files before installing:
COPY package*.json ./
RUN npm ci
The package*.json glob copies both package.json and package-lock.json in one line.
Why copy these before the rest of the code? Docker builds in layers and caches each one. If you copy everything first, any code change invalidates the dependency cache and forces a full npm ci on every build slow. Copying package*.json first means Docker only re-runs npm ci when dependencies actually change.
Clue 3: vite.config.js + Scripts → Your Build Command
In package.json, the scripts section reads:
"scripts": {
"ridan": "vite",
"build": "vite build"
}
vite build compiles your entire React app JSX, TypeScript, CSS modules, imports into plain HTML, CSS, and JavaScript files. No more React, no more JSX, no more Node.js required. Just static files a browser can load directly.
This translates to:
COPY . .
RUN npm run build
After this runs, a build/ folder appears containing your compiled app, ready to be served.
Clue 4: The build/ Folder → You Don't Need Node Anymore
This is the insight that changes everything.
After npm run build finishes, the output in build/ is just:
build/
index.html
static/
js/main.abc123.js
css/main.def456.css
media/logo.png
These are static files. A browser can load them directly. You no longer need Node.js, npm, React, or any of your dependencies. They were only needed during the build process.
So why keep a 500MB Node.js environment in your production image just to serve a few HTML files? You don't.
Clue 5: Static Output → Serve With Nginx
Since the output is static files, the right tool to serve them is Nginx a battle-tested, lightweight web server used by some of the highest-traffic sites in the world.
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
/usr/share/nginx/html is Nginx's default document root the folder it serves files from. You're dropping your compiled app right there.
The nginx:alpine image is around 25MB total. Compare that to keeping Node.js around at 500MB+.
Putting It All Together: The Multi-Stage Dockerfile
Here's the complete Dockerfile with every line explained:
# ── STAGE 1: Build ─────────────────────────────────────────────
# Use Node 22 (matches "engines" in package.json), Alpine for small size
FROM node:22-alpine AS builder
# Set working directory inside the container
WORKDIR /app
# Copy dependency manifests FIRST (enables Docker layer caching)
# Only re-runs npm ci when package files change, not on every code change
COPY package*.json ./
# Install exact versions from package-lock.json (deterministic, production-safe)
RUN npm ci
# Copy the rest of the source code
COPY . .
# Compile React → static HTML/CSS/JS in the /app/build folder
RUN npm run build
# ── STAGE 2: Serve ─────────────────────────────────────────────
# Start fresh with a minimal Nginx image (~25MB vs 500MB+ for Node)
FROM nginx:alpine
# Copy ONLY the compiled output from Stage 1 nothing else
# Node.js, npm, node_modules, and source code are all left behind
COPY --from=builder /app/build /usr/share/nginx/html
# Tell Docker this container listens on port 80
EXPOSE 80
# Nginx starts automatically no CMD needed for the default config
Understanding Multi-Stage Builds Visually
┌─────────────────────────────┐ ┌──────────────────────────┐
│ Stage 1: builder │ │ Stage 2: final │
│ node:22-alpine │ │ nginx:alpine │
│ │ │ │
│ ✓ Node.js runtime │ ──▶ │ ✓ Compiled HTML/CSS/JS │
│ ✓ npm + package manager │ only │ ✓ Nginx web server │
│ ✓ 300MB node_modules │ /build │ │
│ ✓ React source code │ │ ✗ No Node.js │
│ ✓ Vite build toolchain │ │ ✗ No npm │
│ │ │ ✗ No source code │
│ ← DISCARDED after build → │ │ ← SHIPPED TO PROD → │
│ ~600MB │ │ ~30MB │
└─────────────────────────────┘ └──────────────────────────┘
The first stage is a construction site. The second stage is the finished building. You ship the building, not the scaffolding.
The Evidence-to-Dockerfile Mental Map
Every line in the Dockerfile traces back to a file in the repo:
package.json
├── "engines": { "node": "22.x" } ──────▶ FROM node:22-alpine
└── "build": "vite build" ────────────────▶ RUN npm run build
package-lock.json exists
└──────────────────────────────────────────▶ RUN npm ci
vite.config.js exists
└── output goes to /build folder ─────────▶ COPY /app/build → nginx
Output is static files (no server-side rendering)
└──────────────────────────────────────────▶ FROM nginx:alpine
Nothing is guessed. Everything is derived from evidence.
When to Use Nginx vs Keeping Node Running
You used a static Nginx serve here because Vite pre-compiles everything. But not all React apps work this way:
| App type | Clue in repo | Serve with |
|---|---|---|
| Static React (Vite/CRA) |
vite build or react scripts build
|
Nginx (static files) |
| Next.js with SSR |
next start in scripts |
Keep Node running |
| Express API backend |
server.js or app.js at root |
Keep Node running |
| Nuxt.js |
nuxt start in scripts |
Keep Node running |
If npm start runs a server (not just opens a browser), keep Node. If npm run build produces a folder of files, use Nginx.
What Comes Next for This Project
The Dockerfile handles the frontend. But Ridan Express has more moving parts visible in package.json:
-
socket.io-client: there's a Socket.io server somewhere handling real-time ride tracking -
stripe: there's a payment processing backend -
mapbox-gl: likely server-side route calculations -
@react-oauth/googlea backend endpoint validates the Google token
To fully containerize this platform you'd eventually write:
# docker-compose.yml (when you find/build the backend)
services:
frontend:
build: ./frontend
ports: ["3000:80"]
backend:
build: ./backend
ports: ["5000:5000"]
environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
socket:
build: ./socket-server
ports: ["4000:4000"]
database:
image: postgres:15-alpine
volumes: [pgdata:/var/lib/postgresql/data]
And that's when Kubernetes becomes relevant when you have multiple services that need to scale independently.
Quick Reference Cheat Sheet
See this in the repo → Do this in Dockerfile
─────────────────────────────────────────────────────────
package.json only → npm install
package.json + lock file → npm ci
"engines": node X → FROM node:X-alpine
"build": "vite build" → RUN npm run build → serve with Nginx
"build": "next build" → RUN npm run build → keep Node + CMD next start
server.js at root → Keep Node, CMD ["node", "server.js"]
requirements.txt → FROM python:3.X-slim
go.mod → FROM golang:X-alpine + multi-stage
pom.xml (Java/Maven) → FROM maven:X AS builder + FROM eclipse-temurin
Summary
Writing a Dockerfile isn't about memorizing syntax it's about reading the project. The files in every repo are instructions waiting to be translated:
-
package.jsonengines → tells you the runtime version -
package-lock.json→ tells you to usenpm ci - Build scripts → tells you the compile command
- Output type (static vs server) → tells you whether to use Nginx or keep Node
- Multi-stage builds → keep images small by separating build from runtime
Next time you open a repo, don't stare at it blankly. Start with package.json, follow the clues, and let the evidence write the Dockerfile for you.
Found this useful? The same detective approach applies to CI/CD pipelines and Kubernetes manifests the repo always tells you what it needs. Follow for more DevOps breakdowns.
Top comments (0)