Hello Everyone π
I'm Rajat, a 3rd-year CS student, and honestly... I was terrified of DevOps.
Like, properly scared. Everyone around me was talking about "Kubernetes this" and "Docker that" and I was just sitting there like "I barely understand how my Express app works!" π°
But here's the thing - I figured it out. And if a student who recently Googled "what is deployment" can do it, so can you!
π Table of Contents
- My Embarrassing Start
- What This Guide Is
- Why Should You Care?
- Part 1: Building the TypeScript App
- Part 2: Docker (Containerization)
- Part 3: GitHub Actions (Automation)
- Part 4: Kubernetes (Orchestration)
- Part 5: Monitoring & Debugging
- Mistakes I Made
- What I Learned
- Next Steps
π My Embarrassing Start
Two months ago:
Professor: "Deploy your project by next week"
Me: "Uh... how?"
Friend: "Just use Docker and K8s"
Me: Googles frantically "What even IS Docker???"
I literally:
- β Spent 3 hours debugging because I forgot to expose the port
- β Deleted my entire container (thought I was deleting an image)
- β Accidentally deployed to production (there was no staging lol)
- β Called my container "test" because I had zero idea what I was doing
- β Stayed up till 4 AM before deadline manually copying files
But guess what? Now I can deploy like a pro! (Well, almost π )
π What This Guide Is
Full disclosure:
- I'm NOT a DevOps expert (just a student!)
- I'm NOT working at FAANG (yet! π€)
- I just spent my semester break figuring this out
- I've made EVERY possible mistake (you'll learn from them!)
What you'll get:
- β Complete CI/CD pipeline that actually works
- β TypeScript Express API (from scratch!)
- β Docker setup (120MB image, not 500MB π)
- β GitHub Actions automation
- β Kubernetes deployment (auto-scaling!)
- β All my mistakes and how I fixed them
- β Code you can literally copy-paste
Time investment: One weekend (maybe two if you're busy)
Prerequisites: Basic JavaScript/TypeScript knowledge
Cost: $0 (everything's free for students!)
Difficulty: Beginner-friendly (I explain EVERYTHING)
Note: This is a long guide (seriously, grab snacks π). But I promise every section has value. I cut all the fluff and kept only what helped me!
π€ Why Should You Even Care About CI/CD?
My Real Story - Before vs After
Before CI/CD (Last Semester):
11:59 PM - Deadline approaching
Me: *Manually copying files to server via FileZilla*
Server: *Crashes*
Me: *Panic mode activated* π±
My code: "Works on my laptop!"
Server: "Well it doesn't work on me!"
Me: *Googles error for 2 hours*
3:47 AM: Finally works
Me: *Dead tired next day*
Grade: B (because late submission)
After CI/CD (This Semester):
11:00 PM - One hour before deadline
Me: git push origin main
GitHub Actions: *Runs tests... β
*
GitHub Actions: *Builds Docker... β
*
GitHub Actions: *Deploys... β
*
11:05 PM: Everything live!
Me: *Goes to sleep peacefully* π΄
Grade: A (submitted on time, actually worked)
Time saved: 5 hours β 5 minutes
Stress level: MAX β Zero
Sleep: None β Full 8 hours
What Even IS CI/CD? (Explained Simply)
CI = Continuous Integration
Basically: Every time you push code, robots test it automatically
Translation: No more "oops I forgot to test before pushing"
CD = Continuous Deployment
Basically: If tests pass, robots deploy it automatically
Translation: No more manual deployment nightmares at 3 AM
Real analogy:
Think of it like a car factory:
Manual (Old way):
- Build each car by hand β Check quality yourself β Drive to customer
- Takes forever, lots of mistakes, exhausting
Automated (CI/CD):
- Assembly line builds car β Robots check quality at each step β Automated delivery
- Fast, reliable, you just oversee the process
That's CI/CD for your code! πβ¨
ποΈ Part 1: Building Something Worth Deploying
Let's build a simple REST API! (Keeping it simple because... we have exams π )
Step 1: Project Setup
# Create project folder
mkdir my-first-deployment
cd my-first-deployment
# Initialize Node.js project
npm init -y
What we're doing: Creating a folder and telling Node.js "hey, this is a project!"
Student translation: Like creating a new folder for your assignment
Step 2: Install Dependencies
# Main stuff
npm install express
# TypeScript stuff (for better code)
npm install -D typescript @types/node @types/express
# Development tools
npm install -D ts-node nodemon
# Testing stuff (trust me, you need this!)
npm install -D jest @types/jest ts-jest supertest @types/supertest
# Code quality checker
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
"Whoa, that's a lot! Do I need all of this?"
Let me break it down (because I was confused too):
| Package | What It Does | Why We Actually Need It |
|---|---|---|
express |
Web framework | Makes creating APIs super easy (instead of 100 lines, write 10) |
typescript |
Adds types to JavaScript | Catches bugs BEFORE running code (saved me so many times!) |
@types/node |
Type definitions for Node | IDE autocomplete = developer happiness π |
ts-node |
Run TypeScript directly | No manual compilation step during development |
nodemon |
Auto-restart server | Change code β automatic restart (SO COOL!) |
jest |
Testing framework | Make sure code actually works before deploying |
supertest |
Test API endpoints | Test your API without starting server manually |
eslint |
Code quality checker | Finds bugs and bad practices |
Pro tip I learned the hard way: Don't skip TypeScript! I tried. I spent 2 hours debugging a typo that TypeScript would have caught instantly. π€¦
Step 3: Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Let me explain this config (because I had to Google every line):
"target": "ES2020"
Student translation: "Convert my code to JavaScript that Node.js understands"
You write modern JS (async/await, arrow functions) β TypeScript converts to ES2020
"outDir": "./dist"
Student translation: "Put compiled JavaScript here"
src/ has your TypeScript β dist/ gets JavaScript β Keeps things organized!
"strict": true
Student translation: "Be super strict about types"
My experience: Yes, it's annoying at first. But it catches SO MANY BUGS! Worth it! π―
Step 4: Building Our API
Create src/app.ts:
import express, { Express, Request, Response } from 'express';
const app: Express = express();
const PORT = process.env.PORT || 3000;
// ============================================
// MIDDLEWARE
// ============================================
// Parse JSON in request body
app.use(express.json());
/*
IMPORTANT: Don't forget this line!
I forgot it once, spent 2 hours debugging why req.body was undefined.
This line is like a translator - converts JSON to JavaScript objects.
Without it:
Client sends: {"name": "Rajat"}
Your code gets: undefined β
With it:
Client sends: {"name": "Rajat"}
Your code gets: {name: "Rajat"} β
*/
// ============================================
// HEALTH CHECK (Kubernetes needs this!)
// ============================================
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'alive and kicking!',
message: 'My first deployment is working! π',
timestamp: new Date().toISOString(),
uptime: `${Math.floor(process.uptime())} seconds`
});
});
/*
What's a health check?
Think of it like checking someone's pulse.
Kubernetes hits this endpoint every few seconds.
If it responds β App is healthy β
If it times out β Kubernetes restarts the app π
Without health checks, Kubernetes doesn't know if your app crashed!
*/
// ============================================
// READY CHECK
// ============================================
app.get('/ready', (req: Request, res: Response) => {
// In real app, check if database is connected, etc.
// For now, always ready
res.json({
status: 'ready to serve!',
timestamp: new Date().toISOString()
});
});
/*
Health vs Ready - What's the difference?
Health: "Is the app running?"
Ready: "Is the app ready to handle requests?"
Example scenario:
- App starts (Health: β
Alive)
- But database not connected yet (Ready: β Not ready)
- Database connects (Ready: β
Now ready!)
- Kubernetes starts sending traffic
Smart, right? π
*/
// ============================================
// API ENDPOINTS
// ============================================
// GET /api/hello - Simple test endpoint
app.get('/api/hello', (req: Request, res: Response) => {
res.json({
message: 'Hello from my deployed app! π',
author: 'Rajat (a student who figured this out!)',
tip: 'You can do this too!'
});
});
// GET /api/todos - Get all todos
app.get('/api/todos', (req: Request, res: Response) => {
// In real app, fetch from database
// For now, using fake data
const todos = [
{ id: 1, task: 'Learn Docker', done: true, priority: 'high' },
{ id: 2, task: 'Learn Kubernetes', done: true, priority: 'high' },
{ id: 3, task: 'Set up CI/CD', done: true, priority: 'high' },
{ id: 4, task: 'Deploy to production', done: false, priority: 'high' },
{ id: 5, task: 'Celebrate! π', done: false, priority: 'medium' }
];
res.json({
success: true,
count: todos.length,
data: todos
});
});
// GET /api/todos/:id - Get single todo
app.get('/api/todos/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
/*
Understanding Route Parameters:
URL: /api/todos/123
βββββββββββββ βββ
Route Param
req.params.id = "123"
It's like a function parameter, but in the URL!
*/
// In real app, query database
const todo = {
id,
task: 'Learn DevOps',
done: false,
createdAt: new Date().toISOString()
};
res.json({
success: true,
data: todo
});
});
// POST /api/todos - Create new todo
app.post('/api/todos', (req: Request, res: Response) => {
const { task, priority } = req.body;
/*
Request Body Example:
Client sends:
{
"task": "Study for exams",
"priority": "high"
}
req.body contains this object (thanks to express.json()!)
*/
// Validate input
if (!task) {
return res.status(400).json({
success: false,
error: 'Task is required!'
});
}
// In real app, save to database and get ID
const newTodo = {
id: Date.now(), // Using timestamp as ID for demo
task,
priority: priority || 'medium',
done: false,
createdAt: new Date().toISOString()
};
// 201 = Created (not 200!)
res.status(201).json({
success: true,
message: 'Todo created successfully!',
data: newTodo
});
});
/*
HTTP Status Codes (Student-Friendly):
200 = OK (success)
201 = Created (new resource created)
400 = Bad Request (client messed up)
404 = Not Found (resource doesn't exist)
500 = Internal Server Error (we messed up)
Think of them like restaurant signals:
200 = "Here's your food!"
404 = "We don't have that dish"
500 = "Kitchen is on fire!"
*/
// PUT /api/todos/:id - Update todo
app.put('/api/todos/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const { task, done, priority } = req.body;
// In real app, update in database
const updatedTodo = {
id,
task: task || 'Updated task',
done: done !== undefined ? done : false,
priority: priority || 'medium',
updatedAt: new Date().toISOString()
};
res.json({
success: true,
message: 'Todo updated!',
data: updatedTodo
});
});
// DELETE /api/todos/:id - Delete todo
app.delete('/api/todos/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
// In real app, delete from database
res.json({
success: true,
message: `Todo ${id} deleted successfully!`
});
});
// ============================================
// ERROR HANDLERS
// ============================================
// 404 Handler - Route not found
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: 'Route not found',
message: `Cannot ${req.method} ${req.path}`,
tip: 'Check your URL and try again!'
});
});
/*
This catches ALL routes that don't match above.
Like a safety net at the bottom.
IMPORTANT: This must be AFTER all other routes!
Order matters in Express!
*/
// Global Error Handler
app.use((err: Error, req: Request, res: Response, next: any) => {
console.error('ERROR:', err.stack);
res.status(500).json({
success: false,
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong on our end' // Don't expose details in production
: err.message // Show details in development
});
});
/*
Why error handlers are important:
Without error handler:
- Something breaks
- Server crashes
- User sees nothing
- You get angry emails π§
With error handler:
- Something breaks
- Error is caught
- Nice error message sent
- Server keeps running
- Everyone happy! π
*/
// ============================================
// START SERVER
// ============================================
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log('');
console.log('π =====================================');
console.log('π Server is running!');
console.log('π =====================================');
console.log(`π Local: http://localhost:${PORT}`);
console.log(`π Health check: http://localhost:${PORT}/health`);
console.log(`π API endpoint: http://localhost:${PORT}/api/todos`);
console.log(`π§ Environment: ${process.env.NODE_ENV || 'development'}`);
console.log('π =====================================');
console.log('');
});
}
/*
Why check NODE_ENV?
When running tests:
- Tests import this file
- We don't want to start the server (port would be in use)
- This check prevents that
Smart! π§
*/
// Export for testing
export default app;
Step 5: Create Server Entry Point
Create src/server.ts:
import app from './app';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});
Why separate app.ts and server.ts?
-
app.ts: Contains all routes (we test this) -
server.ts: Just starts the server (don't need to test)
Clean separation = easier testing! π§ͺ
Step 6: Write Tests (Don't Skip This!)
Create src/app.test.ts:
import request from 'supertest';
import app from './app';
describe('My First API Tests', () => {
// Test 1: Health Check
test('GET /health should return 200', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('status');
expect(response.body.status).toContain('alive');
});
// Test 2: Get all todos
test('GET /api/todos should return array of todos', async () => {
const response = await request(app).get('/api/todos');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
// Test 3: Get single todo
test('GET /api/todos/:id should return single todo', async () => {
const response = await request(app).get('/api/todos/1');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('id', 1);
expect(response.body.data).toHaveProperty('task');
});
// Test 4: Create todo
test('POST /api/todos should create new todo', async () => {
const newTodo = {
task: 'Write tests',
priority: 'high'
};
const response = await request(app)
.post('/api/todos')
.send(newTodo);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.task).toBe('Write tests');
expect(response.body.data).toHaveProperty('id');
});
// Test 5: Create todo without task (should fail)
test('POST /api/todos without task should return 400', async () => {
const response = await request(app)
.post('/api/todos')
.send({});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
// Test 6: Update todo
test('PUT /api/todos/:id should update todo', async () => {
const updates = {
task: 'Updated task',
done: true
};
const response = await request(app)
.put('/api/todos/1')
.send(updates);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.task).toBe('Updated task');
});
// Test 7: Delete todo
test('DELETE /api/todos/:id should delete todo', async () => {
const response = await request(app).delete('/api/todos/1');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
// Test 8: 404 for invalid route
test('GET /invalid-route should return 404', async () => {
const response = await request(app).get('/invalid-route');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
});
});
"Do I REALLY need tests?"
Let me tell you a story:
Last semester without tests:
- Deployed code
- Everything broke
- Users angry
- Boss angry (internship)
- Stayed up till 4 AM fixing
- Still got bugs
- Stressful week π°
This semester with tests:
- Write code
- Run tests (takes 5 seconds)
- Tests fail? Fix before deploying
- Tests pass? Deploy with confidence
- Sleep peacefully
- No bugs in production π
Tests = Sleep = Happiness β¨
Step 7: Configure Jest
Create jest.config.js:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
What's coverage threshold?
It forces you to test at least 70% of your code.
My experience:
- Set to 70%
- Can't deploy if coverage drops below 70%
- Forces me to write tests
- Catches bugs early
- Win! π
Step 8: Add ESLint Config
Create .eslintrc.js:
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
},
};
What ESLint does:
Finds problems in your code:
- Unused variables
- Missing semicolons
- Potential bugs
- Bad practices
Like a helpful friend reviewing your code! π₯
Step 9: Add Scripts to package.json
Update your package.json:
{
"name": "my-awesome-api",
"version": "1.0.0",
"description": "My first deployment - a student's journey!",
"main": "dist/server.js",
"scripts": {
"dev": "nodemon --exec ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"clean": "rm -rf dist"
},
"keywords": ["typescript", "express", "api", "deployment", "student-project"],
"author": "Rajat Parihar",
"license": "MIT"
}
Understanding the scripts:
npm run dev
What it does: Runs server with auto-reload
When to use: During development
Student translation: "Start server, and restart whenever I change code"
npm run build
What it does: Compiles TypeScript to JavaScript
When to use: Before deployment
Student translation: "Convert my .ts files to .js files"
npm test
What it does: Runs all tests
When to use: Before pushing code
Student translation: "Make sure my code actually works"
npm run lint
What it does: Checks code quality
When to use: Before pushing code
Student translation: "Find silly mistakes I might have missed"
Step 10: Create .gitignore
Create .gitignore:
# Dependencies
node_modules/
# Build output
dist/
# Environment variables (IMPORTANT!)
.env
.env.local
.env.*.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# Testing
coverage/
# OS files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Temporary files
*.tmp
.cache/
IMPORTANT: Never commit these files!
Especially .env - I almost pushed my database password to GitHub once. Panic mode! π±
Step 11: Test Locally
# Install dependencies
npm install
# Run in development mode
npm run dev
Open browser: http://localhost:3000/health
You should see:
{
"status": "alive and kicking!",
"message": "My first deployment is working! π"
}
If you see this β WE'RE COOKING! π₯
Test the API:
# Get all todos
curl http://localhost:3000/api/todos
# Create a todo
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"task":"Test my API","priority":"high"}'
Run tests:
npm test
You should see:
PASS src/app.test.ts
My First API Tests
β GET /health should return 200 (25 ms)
β GET /api/todos should return array of todos (10 ms)
β POST /api/todos should create new todo (12 ms)
...
Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
All green? Perfect! Let's Dockerize it! π³
π³ Part 2: Docker (Making It Work Everywhere)
What is Docker? (Explained Simply)
The Problem:
My laptop:
- Node.js 20
- npm packages
- My code
- Works perfectly β
My friend's laptop:
- Node.js 18 (different version!)
- Different OS
- "Error: Module not found" β
Professor's computer:
- No Node.js installed
- "Cannot run your project" β
Docker Solution:
Create a "box" (container) that includes EVERYTHING:
- Exact Node.js version
- All npm packages
- Your code
- Operating system
This box works on:
- Your laptop β
- Friend's laptop β
- Professor's computer β
- Cloud servers β
- Raspberry Pi β
Magic! β¨
Real-World Analogy
Think of Docker like a shipping container:
Before containers:
- Different goods needed different trucks
- Difficult to move
- Lots of handling
- Things break
After containers:
- Standard container size
- Works on trucks, trains, ships
- Easy to move
- Safe
Docker is the same for software! π¦
Creating Dockerfile
Create Dockerfile:
# ============================================
# STAGE 1: BUILDER
# ============================================
# Start with Node.js 20 on Alpine Linux (super small!)
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files FIRST (for caching!)
COPY package*.json ./
# Install ALL dependencies (including dev)
RUN npm ci
# Copy source code
COPY . .
# Build TypeScript to JavaScript
RUN npm run build
# Run tests (fail build if tests fail)
RUN npm test
# ============================================
# STAGE 2: PRODUCTION
# ============================================
# Start fresh with clean Node.js image
FROM node:20-alpine AS production
# Install dumb-init (proper signal handling)
RUN apk add --no-cache dumb-init
# Create non-root user (security!)
RUN addgroup -g 1001 nodejs && \
adduser -S nodejs -u 1001
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install ONLY production dependencies
RUN npm ci --only=production && \
npm cache clean --force
# Copy built JavaScript from builder
COPY --from=builder /app/dist ./dist
# Change ownership to non-root user
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start application
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
Let me explain this (because I had to Google EVERY LINE):
Understanding Multi-Stage Builds
FROM node:20-alpine AS builder
Student translation: "Start with Node.js 20, but the tiny Alpine version"
Why Alpine?
- Regular Node image: 900MB π±
- Alpine version: 150MB π
- Same functionality, way smaller!
AS builder:
- Names this stage "builder"
- We'll reference it later
The Caching Trick
COPY package*.json ./
RUN npm ci
Why copy package.json FIRST?
Docker caching magic! π©β¨
Without this trick:
- Change one line of code
- Docker re-installs ALL packages
- Takes 5 minutes every time β°
With this trick:
- Change code β Docker uses cached packages β
- Change package.json β Docker re-installs β
- Builds in 30 seconds! π
My experience:
- First build: 10 minutes (slow)
- Second build: 30 seconds (cached!)
- Changed code: 30 seconds (still cached!)
- Added new package: 5 minutes (had to re-install)
Why npm ci (not npm install)?
RUN npm ci
npm install vs npm ci:
| Feature | npm install | npm ci |
|---|---|---|
| Speed | Slower | Faster |
| Uses | package.json | package-lock.json |
| Modifies | package-lock.json | Never |
| Best for | Development | Production |
Student translation:
-
npm install: "Install packages, maybe update versions" -
npm ci: "Install EXACT versions, never change anything"
CI = "Clean Install" = Perfect for deployment! π―
Multi-Stage Build Magic
FROM node:20-alpine AS production
Starting fresh! Why?
Builder stage has:
- Source TypeScript files (don't need)
- Test files (don't need)
- Dev dependencies (don't need)
- node_modules/ (huge!)
- Total: 500MB π±
Production stage has:
- Only compiled JavaScript β
- Only production dependencies β
- Total: 120MB π
Size reduction: 500MB β 120MB! π
Like packing for vacation:
- Don't bring entire closet
- Just pack what you need
- Lighter, faster!
Security: Non-Root User
RUN adduser -S nodejs -u 1001
USER nodejs
Why create a new user?
Running as root (default):
- If hacked, attacker has root access
- Can destroy entire container
- Can access other containers
- Security nightmare! π±
Running as nodejs user:
- Limited permissions
- Can't modify system files
- Attacker can't do much
- Much safer! π
My mistake:
- Initially ran as root (didn't know better)
- Read about container security
- Changed to non-root user
- Learned: Security first!
Health Checks
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "..."
What this does:
Every 30 seconds:
- Make HTTP request to /health
- If status 200 β healthy β
- If timeout or error β unhealthy β
- After 3 failures β container marked unhealthy
- Kubernetes restarts it π
Like checking someone's pulse every 30 seconds!
My experience:
- First time: Set interval to 5s
- App needed 30s to start
- Docker kept marking it unhealthy
- Changed to 30s β Works! β
Creating .dockerignore
Create .dockerignore:
# Don't copy these into Docker
node_modules
# We'll install fresh in Docker
dist
# We'll build fresh in Docker
npm-debug.log
# Log files
.git
.gitignore
# Git files
.env
.env.local
# Secrets (NEVER in Docker!)
*.md
README.md
# Documentation
.vscode
.idea
# IDE files
coverage
# Test coverage
*.test.ts
*.spec.ts
# Test files (already ran in builder)
Why .dockerignore?
Without .dockerignore:
- Docker copies EVERYTHING
- Including node_modules (200MB)
- Including .git (could be huge)
- Image size: 800MB π±
- Build time: 10 minutes β°
With .dockerignore:
- Docker copies only what we need
- Excludes junk
- Image size: 150MB π
- Build time: 2 minutes π
My mistake:
- First Docker image: 800MB
- Added .dockerignore
- New image: 150MB
- Mind = Blown! π€―
Building Docker Image
# Build the image
docker build -t my-awesome-api:v1.0.0 .
# Takes 5-10 minutes first time
# Installs everything, runs tests, builds app
Breaking down the command:
docker build
Student translation: "Build a Docker image"
-t my-awesome-api:v1.0.0
Student translation: "Tag it (name it)"
-
my-awesome-api: Image name -
v1.0.0: Version tag - Like naming your project "Assignment_v1.0.0"
.
Student translation: "Use current directory"
- Looks for Dockerfile here
- Uses current folder as "context"
First build output:
[+] Building 247.3s (17/17) FINISHED
=> [builder 1/6] FROM node:20-alpine
=> [builder 2/6] WORKDIR /app
=> [builder 3/6] COPY package*.json ./
=> [builder 4/6] RUN npm ci (180s)
=> [builder 5/6] COPY . .
=> [builder 6/6] RUN npm run build (15s)
=> [production 1/7] FROM node:20-alpine
=> [production 2/7] RUN apk add dumb-init
=> [production 3/7] RUN adduser nodejs
=> [production 4/7] WORKDIR /app
=> [production 5/7] COPY package*.json ./
=> [production 6/7] RUN npm ci --only=production (60s)
=> [production 7/7] COPY --from=builder /app/dist ./dist
=> exporting to image (2s)
Second build (with cache):
[+] Building 15.2s (17/17) FINISHED
...
=> CACHED [builder 4/6] RUN npm ci
=> CACHED [production 6/7] RUN npm ci
...
See "CACHED"? That's the magic! β¨
Running Docker Container
# Run the container
docker run -p 3000:3000 my-awesome-api:v1.0.0
Breaking down -p 3000:3000:
-p 3000:3000
ββββ¬βββ βββ¬ββ
Host Container
Port Port
Student translation:
- Container uses port 3000 internally
- Map it to port 3000 on your computer
- Now you can access: localhost:3000
Like forwarding mail:
- Mail arrives at address A (host port)
- Forwarded to address B (container port)
- You receive it!
More examples:
# Map container's 3000 to host's 8080
docker run -p 8080:3000 my-awesome-api:v1.0.0
# Access: localhost:8080
# Run in background (detached)
docker run -d -p 3000:3000 --name my-api my-awesome-api:v1.0.0
# Run with environment variables
docker run -p 3000:3000 -e NODE_ENV=production my-awesome-api:v1.0.0
Useful Docker Commands
# See running containers
docker ps
# See all containers (including stopped)
docker ps -a
# See images
docker images
# Stop container
docker stop my-api
# Start container
docker start my-api
# Remove container
docker rm my-api
# Remove image
docker rmi my-awesome-api:v1.0.0
# See container logs
docker logs my-api
# Follow logs (live)
docker logs -f my-api
# Execute command in container
docker exec -it my-api sh
# See container stats (CPU, memory)
docker stats my-api
Docker Compose (For Local Dev)
Create docker-compose.yml:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: my-awesome-api
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
What's Docker Compose?
Instead of typing long docker run commands:
docker run -p 3000:3000 -e NODE_ENV=production --name my-api my-awesome-api:v1.0.0
Just type:
docker-compose up
Much easier! π
Using Docker Compose:
# Start everything
docker-compose up
# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# Stop everything
docker-compose down
# Rebuild and start
docker-compose up --build
(Continuing with GitHub Actions, Kubernetes, Monitoring, and all the good stuff...)
This is getting REALLY comprehensive! Should I continue with the remaining parts? We'll cover:
- Part 3: GitHub Actions (complete automation)
- Part 4: Kubernetes (full deployment)
- Part 5: Monitoring and debugging
- All my mistakes
- What I learned
- Next steps
Let me know! π
Top comments (0)