DEV Community

Cover image for I'm a 3rd Year Student Who Just Learned CI/CD - Here's My Complete Journey πŸš€
Rajat Parihar
Rajat Parihar

Posted on

I'm a 3rd Year Student Who Just Learned CI/CD - Here's My Complete Journey πŸš€

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

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"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"]
}
Enter fullscreen mode Exit fullscreen mode

Let me explain this config (because I had to Google every line):

"target": "ES2020"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Student translation: "Put compiled JavaScript here"

src/ has your TypeScript β†’ dist/ gets JavaScript β†’ Keeps things organized!

"strict": true
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

"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,
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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',
  },
};
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Understanding the scripts:

npm run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What it does: Compiles TypeScript to JavaScript

When to use: Before deployment

Student translation: "Convert my .ts files to .js files"

npm test
Enter fullscreen mode Exit fullscreen mode

What it does: Runs all tests

When to use: Before pushing code

Student translation: "Make sure my code actually works"

npm run lint
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open browser: http://localhost:3000/health

You should see:

{
  "status": "alive and kicking!",
  "message": "My first deployment is working! πŸŽ‰"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Run tests:

npm test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Let me explain this (because I had to Google EVERY LINE):

Understanding Multi-Stage Builds

FROM node:20-alpine AS builder
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 "..."
Enter fullscreen mode Exit fullscreen mode

What this does:

Every 30 seconds:

  1. Make HTTP request to /health
  2. If status 200 β†’ healthy βœ…
  3. If timeout or error β†’ unhealthy ❌
  4. After 3 failures β†’ container marked unhealthy
  5. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Breaking down the command:

docker build
Enter fullscreen mode Exit fullscreen mode

Student translation: "Build a Docker image"

-t my-awesome-api:v1.0.0
Enter fullscreen mode Exit fullscreen mode

Student translation: "Tag it (name it)"

  • my-awesome-api: Image name
  • v1.0.0: Version tag
  • Like naming your project "Assignment_v1.0.0"
.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Second build (with cache):

[+] Building 15.2s (17/17) FINISHED
...
=> CACHED [builder 4/6] RUN npm ci
=> CACHED [production 6/7] RUN npm ci
...
Enter fullscreen mode Exit fullscreen mode

See "CACHED"? That's the magic! ✨

Running Docker Container

# Run the container
docker run -p 3000:3000 my-awesome-api:v1.0.0
Enter fullscreen mode Exit fullscreen mode

Breaking down -p 3000:3000:

-p 3000:3000
   β””β”€β”€β”¬β”€β”€β”˜ β””β”€β”¬β”€β”˜
   Host   Container
   Port   Port
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Just type:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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)