Deep Dive: Testing & DevOps for TypeScript

You've built the full stack — frontend, backend, database. Now comes the part that separates hobby projects from production software: testing and deployment. Without tests, every change is a gamble. Without CI/CD, every deployment is manual and error-prone.
This is the final post in the TypeScript series. It covers the entire quality-and-delivery pipeline: unit tests, API tests, end-to-end tests, mocking strategies, CI/CD with GitHub Actions, Docker containerization, and deployment automation.
Prerequisite: Deep Dive: Database & ORMs Time commitment: 2–3 hours
What You'll Learn
✅ Write fast unit tests with Vitest
✅ Test APIs with Supertest and integration patterns
✅ Run end-to-end tests with Playwright
✅ Mock external services with MSW (Mock Service Worker)
✅ Measure and enforce test coverage
✅ Set up CI/CD pipelines with GitHub Actions
✅ Containerize apps with Docker multi-stage builds
✅ Automate deployments to production
The Testing Pyramid in TypeScript
| Layer | Tool | Speed | What It Tests |
|---|---|---|---|
| Unit | Vitest | ~1ms per test | Pure functions, utilities, business logic |
| Integration | Supertest + Vitest | ~50-200ms per test | API routes, middleware, database queries |
| E2E | Playwright | ~1-5s per test | Full user flows through the browser |
The rule: Write many unit tests, fewer integration tests, and even fewer E2E tests. Each layer catches different categories of bugs.
Part 1: Unit Testing with Vitest
Why Vitest Over Jest?
Vitest is built for the modern TypeScript ecosystem:
- Native ESM and TypeScript support (no
ts-jestor Babel config) - Compatible with Jest's API (easy migration)
- Uses Vite's transform pipeline — significantly faster
- Built-in coverage with
v8oristanbul - Watch mode with instant re-runs
Setup
npm install -D vitest @vitest/coverage-v8// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true, // No need to import describe, it, expect
environment: 'node', // Use 'jsdom' for frontend components
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'test/',
'**/*.config.*',
'**/*.d.ts',
],
},
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});// package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}Writing Unit Tests
Unit tests focus on pure logic — functions that take input and return output without side effects.
// src/utils/slug.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function extractTags(content: string): string[] {
const tagPattern = /#(\w+)/g;
const matches = content.matchAll(tagPattern);
return [...new Set([...matches].map(m => m[1].toLowerCase()))];
}// src/utils/slug.test.ts
import { describe, it, expect } from 'vitest';
import { slugify, extractTags } from './slug';
describe('slugify', () => {
it('converts text to lowercase kebab-case', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('removes special characters', () => {
expect(slugify('Hello! @World#')).toBe('hello-world');
});
it('trims leading and trailing hyphens', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('collapses multiple spaces and underscores', () => {
expect(slugify('hello world__test')).toBe('hello-world-test');
});
it('handles empty strings', () => {
expect(slugify('')).toBe('');
});
});
describe('extractTags', () => {
it('extracts hashtags from content', () => {
const content = 'Learning #typescript and #react today';
expect(extractTags(content)).toEqual(['typescript', 'react']);
});
it('deduplicates tags', () => {
const content = '#typescript #TypeScript #TYPESCRIPT';
expect(extractTags(content)).toEqual(['typescript']);
});
it('returns empty array when no tags found', () => {
expect(extractTags('no tags here')).toEqual([]);
});
});Testing Business Logic
// src/services/pricing.ts
interface PricingTier {
name: string;
minQuantity: number;
pricePerUnit: number;
}
const tiers: PricingTier[] = [
{ name: 'enterprise', minQuantity: 100, pricePerUnit: 5 },
{ name: 'business', minQuantity: 10, pricePerUnit: 8 },
{ name: 'starter', minQuantity: 1, pricePerUnit: 12 },
];
export function calculatePrice(quantity: number): {
tier: string;
unitPrice: number;
total: number;
savings: number;
} {
if (quantity <= 0) throw new Error('Quantity must be positive');
const tier = tiers.find(t => quantity >= t.minQuantity)!;
const basePrice = quantity * tiers[tiers.length - 1].pricePerUnit;
const total = quantity * tier.pricePerUnit;
return {
tier: tier.name,
unitPrice: tier.pricePerUnit,
total,
savings: basePrice - total,
};
}// src/services/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculatePrice } from './pricing';
describe('calculatePrice', () => {
it('uses starter tier for small quantities', () => {
const result = calculatePrice(5);
expect(result).toEqual({
tier: 'starter',
unitPrice: 12,
total: 60,
savings: 0,
});
});
it('applies business discount for 10+ units', () => {
const result = calculatePrice(10);
expect(result.tier).toBe('business');
expect(result.unitPrice).toBe(8);
expect(result.savings).toBe(40); // 120 - 80
});
it('applies enterprise discount for 100+ units', () => {
const result = calculatePrice(100);
expect(result.tier).toBe('enterprise');
expect(result.total).toBe(500);
expect(result.savings).toBe(700); // 1200 - 500
});
it('throws for zero or negative quantity', () => {
expect(() => calculatePrice(0)).toThrow('Quantity must be positive');
expect(() => calculatePrice(-1)).toThrow('Quantity must be positive');
});
});Async Testing
// src/services/email-validator.ts
export async function validateEmail(email: string): Promise<{
valid: boolean;
reason?: string;
}> {
if (!email.includes('@')) {
return { valid: false, reason: 'Missing @ symbol' };
}
const [local, domain] = email.split('@');
if (!local || local.length === 0) {
return { valid: false, reason: 'Empty local part' };
}
if (!domain || !domain.includes('.')) {
return { valid: false, reason: 'Invalid domain' };
}
const disposableDomains = ['tempmail.com', 'throwaway.email'];
if (disposableDomains.includes(domain)) {
return { valid: false, reason: 'Disposable email not allowed' };
}
return { valid: true };
}// src/services/email-validator.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail } from './email-validator';
describe('validateEmail', () => {
it('accepts valid emails', async () => {
const result = await validateEmail('user@example.com');
expect(result).toEqual({ valid: true });
});
it('rejects emails without @', async () => {
const result = await validateEmail('invalid');
expect(result).toEqual({ valid: false, reason: 'Missing @ symbol' });
});
it('rejects disposable email providers', async () => {
const result = await validateEmail('user@tempmail.com');
expect(result.valid).toBe(false);
expect(result.reason).toContain('Disposable');
});
});Part 2: Integration Testing with Supertest
Integration tests verify that your API routes, middleware, and database work together correctly.
Setup
npm install -D supertest @types/supertestThe key is to export your Express/Hono app separately from the server start:
// src/app.ts — exports the app (no .listen())
import express from 'express';
import { userRouter } from './routes/users';
import { postRouter } from './routes/posts';
import { errorHandler } from './middleware/error-handler';
const app = express();
app.use(express.json());
app.use('/api/users', userRouter);
app.use('/api/posts', postRouter);
app.use(errorHandler);
export { app };// src/server.ts — starts the server (only in production/dev)
import { app } from './app';
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server running on port ${port}`));Writing API Tests
// test/api/users.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/lib/prisma';
async function cleanDatabase() {
await prisma.$transaction([
prisma.comment.deleteMany(),
prisma.post.deleteMany(),
prisma.user.deleteMany(),
]);
}
describe('POST /api/users', () => {
beforeEach(cleanDatabase);
afterAll(() => prisma.$disconnect());
it('creates a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'alice@example.com',
name: 'Alice',
password: 'securePassword123',
})
.expect(201);
expect(response.body).toMatchObject({
email: 'alice@example.com',
name: 'Alice',
});
expect(response.body).not.toHaveProperty('password');
expect(response.body).toHaveProperty('id');
});
it('returns 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Test' })
.expect(400);
expect(response.body.error).toBeDefined();
});
it('returns 409 for duplicate email', async () => {
// Create first user
await request(app)
.post('/api/users')
.send({ email: 'alice@example.com', name: 'Alice', password: 'pass123' });
// Try to create duplicate
await request(app)
.post('/api/users')
.send({ email: 'alice@example.com', name: 'Bob', password: 'pass456' })
.expect(409);
});
});
describe('GET /api/users/:id', () => {
beforeEach(cleanDatabase);
afterAll(() => prisma.$disconnect());
it('returns user by ID', async () => {
const created = await request(app)
.post('/api/users')
.send({ email: 'alice@example.com', name: 'Alice', password: 'pass123' });
const response = await request(app)
.get(`/api/users/${created.body.id}`)
.expect(200);
expect(response.body.email).toBe('alice@example.com');
});
it('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/non-existent-id')
.expect(404);
});
});Testing Protected Routes
// test/api/posts.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/lib/prisma';
import { generateToken } from '../../src/lib/jwt';
describe('POST /api/posts', () => {
let authToken: string;
let userId: string;
beforeEach(async () => {
await prisma.$transaction([
prisma.comment.deleteMany(),
prisma.post.deleteMany(),
prisma.user.deleteMany(),
]);
const user = await prisma.user.create({
data: { email: 'author@example.com', name: 'Author', role: 'USER' },
});
userId = user.id;
authToken = generateToken({ userId: user.id, role: user.role });
});
afterAll(() => prisma.$disconnect());
it('creates a post when authenticated', async () => {
const response = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'My Post', content: 'Post content' })
.expect(201);
expect(response.body.title).toBe('My Post');
expect(response.body.authorId).toBe(userId);
});
it('returns 401 without auth token', async () => {
await request(app)
.post('/api/posts')
.send({ title: 'My Post', content: 'Content' })
.expect(401);
});
it('returns 403 for insufficient permissions', async () => {
const viewer = await prisma.user.create({
data: { email: 'viewer@example.com', name: 'Viewer', role: 'USER' },
});
const viewerToken = generateToken({ userId: viewer.id, role: 'VIEWER' });
await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ title: 'Post', content: 'Content' })
.expect(403);
});
});Part 3: Mocking with MSW (Mock Service Worker)
When your code calls external APIs (Stripe, SendGrid, GitHub), you don't want tests hitting real services. MSW intercepts HTTP requests at the network level — your code doesn't know it's being mocked.
Setup
npm install -D msw// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock Stripe payment intent
http.post('https://api.stripe.com/v1/payment_intents', () => {
return HttpResponse.json({
id: 'pi_test_123',
status: 'succeeded',
amount: 2000,
currency: 'usd',
});
}),
// Mock GitHub user API
http.get('https://api.github.com/users/:username', ({ params }) => {
return HttpResponse.json({
login: params.username,
id: 12345,
avatar_url: `https://avatars.githubusercontent.com/u/12345`,
name: 'Test User',
public_repos: 42,
});
}),
// Mock email service
http.post('https://api.resend.com/emails', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 'email_test_456',
to: body.to,
status: 'sent',
});
}),
];// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);// test/setup.ts — register in vitest config globalSetup or setupFiles
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Using Mocks in Tests
// src/services/github.ts
export async function getGitHubProfile(username: string) {
const response = await fetch(`https://api.github.com/users/${username}`, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);
return response.json();
}// src/services/github.test.ts
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { getGitHubProfile } from './github';
describe('getGitHubProfile', () => {
it('returns user profile', async () => {
const profile = await getGitHubProfile('octocat');
expect(profile.login).toBe('octocat');
expect(profile.public_repos).toBe(42);
});
it('throws on API error', async () => {
// Override the default handler for this test
server.use(
http.get('https://api.github.com/users/:username', () => {
return new HttpResponse(null, { status: 404 });
})
);
await expect(getGitHubProfile('nonexistent')).rejects.toThrow(
'GitHub API error: 404'
);
});
it('handles rate limiting', async () => {
server.use(
http.get('https://api.github.com/users/:username', () => {
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': '60' },
});
})
);
await expect(getGitHubProfile('octocat')).rejects.toThrow(
'GitHub API error: 429'
);
});
});Why MSW over manual mocking?
// ❌ Manual mocking — couples tests to implementation
vi.mock('node-fetch', () => ({
default: vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ login: 'octocat' }),
}),
}));
// Problem: If you switch from fetch to axios, all mocks break
// ✅ MSW — mocks at the network level
// Your code uses real fetch/axios. MSW intercepts the HTTP request.
// Switch HTTP client? Tests still work.Part 4: E2E Testing with Playwright
End-to-end tests verify that your entire application works from the user's perspective — clicking buttons, filling forms, navigating pages.
Setup
npm install -D @playwright/test
npx playwright install # Downloads browser binaries// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // Fail if test.only left in CI
retries: process.env.CI ? 2 : 0, // Retry flaky tests in CI
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Capture trace on failure
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});Writing E2E Tests
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can sign up and log in', async ({ page }) => {
// Navigate to signup
await page.goto('/signup');
// Fill the signup form
await page.getByLabel('Email').fill('newuser@example.com');
await page.getByLabel('Password').fill('securePass123');
await page.getByLabel('Confirm Password').fill('securePass123');
await page.getByRole('button', { name: 'Sign Up' }).click();
// Should redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Log In' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login'); // Stay on login page
});
});// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Blog', () => {
test('displays list of posts', async ({ page }) => {
await page.goto('/blog');
// Check that posts are rendered
const posts = page.getByRole('article');
await expect(posts).not.toHaveCount(0);
// Each post has a title and date
const firstPost = posts.first();
await expect(firstPost.getByRole('heading')).toBeVisible();
await expect(firstPost.getByText(/\d{4}/)).toBeVisible(); // Year in date
});
test('navigates to individual post', async ({ page }) => {
await page.goto('/blog');
// Click the first post
const firstTitle = page.getByRole('article').first().getByRole('heading');
const titleText = await firstTitle.textContent();
await firstTitle.click();
// Should be on the post page
await expect(page.getByRole('heading', { level: 1 })).toHaveText(titleText!);
await expect(page.getByRole('article')).toBeVisible();
});
test('search filters posts', async ({ page }) => {
await page.goto('/blog');
await page.getByPlaceholder('Search').fill('typescript');
await page.keyboard.press('Enter');
// All visible posts should contain "typescript"
const posts = page.getByRole('article');
const count = await posts.count();
for (let i = 0; i < count; i++) {
const text = await posts.nth(i).textContent();
expect(text?.toLowerCase()).toContain('typescript');
}
});
});Page Object Model
For larger test suites, encapsulate page interactions in reusable objects:
// e2e/pages/login.page.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log In' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test('login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('login with invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrong');
await loginPage.expectError('Invalid credentials');
});Running Playwright Tests
# Run all E2E tests
npx playwright test
# Run in headed mode (see the browser)
npx playwright test --headed
# Run a specific test file
npx playwright test e2e/auth.spec.ts
# View test report
npx playwright show-report
# Debug mode with inspector
npx playwright test --debug
# Generate tests by recording browser actions
npx playwright codegen http://localhost:3000Part 5: Test Coverage and Quality Gates
Coverage Configuration
// vitest.config.ts (coverage section)
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
exclude: [
'node_modules/',
'test/',
'e2e/',
'**/*.config.*',
'**/*.d.ts',
'src/types/',
],
},
},
});# Run tests with coverage
npm run test:coverage
# Output:
# ----------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files | 87.5 | 82.3 | 91.2 | 87.5 |
# src/services/ | 92.1 | 88.5 | 95.0 | 92.1 |
# src/utils/ | 100.0 | 100.0 | 100.0 | 100.0 |
# src/routes/ | 78.3 | 71.0 | 85.0 | 78.3 |
# ----------------------|---------|----------|---------|---------|Coverage is a metric, not a goal. 80% coverage with meaningful assertions is worth more than 100% coverage with shallow tests that just call functions without checking results.
Part 6: CI/CD with GitHub Actions
Basic CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npx tsc --noEmit
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
e2e-tests:
runs-on: ubuntu-latest
needs: [lint-and-typecheck, unit-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/Pipeline Visualization
The key design decisions:
- Lint, unit, and integration run in parallel (no dependencies between them)
- E2E waits for lint and unit to pass (no point running expensive browser tests if basic checks fail)
- Deploy only runs on the main branch after all tests pass
Adding Deployment
deploy:
runs-on: ubuntu-latest
needs: [lint-and-typecheck, unit-tests, integration-tests, e2e-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'Part 7: Docker Multi-Stage Builds
Docker ensures your app runs identically in development, CI, and production — no more "works on my machine."
Production Dockerfile
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the app
RUN npm run build
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 appuser
# Copy only what's needed to run
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]Why multi-stage?
depsstage: Installs dependencies (cached unless package.json changes)builderstage: Compiles TypeScript (has devDependencies + source code)runnerstage: Only contains compiled code + production dependencies
Result: The final image is ~150MB instead of ~1GB.
Docker Compose for Development
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- '3000:3000'
volumes:
- .:/app
- /app/node_modules # Don't override node_modules
environment:
- DATABASE_URL=postgresql://devuser:devpass@db:5432/devdb
- NODE_ENV=development
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
ports:
- '5432:5432'
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: devdb
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U devuser -d devdb']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"].dockerignore
node_modules
dist
.git
.env*
*.md
playwright-report
coveragePart 8: Environment Management
Configuration Pattern
// src/config.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
RESEND_API_KEY: z.string().startsWith('re_'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
// Validate at startup — fail fast if config is wrong
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;// Usage — fully typed, no process.env scattered everywhere
import { config } from './config';
const server = app.listen(config.PORT);
const stripe = new Stripe(config.STRIPE_SECRET_KEY);Environment Files
# .env.example — committed to git (no secrets)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://devuser:devpass@localhost:5432/devdb
JWT_SECRET=development-secret-key-change-in-production
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
RESEND_API_KEY=re_xxxxxxxxxxxx
# .env — NOT committed (contains real secrets)
# .env.test — test environment overrides
# .env.production — production overrides (or use CI secrets)# .gitignore
.env
.env.local
.env.production
!.env.examplePart 9: Deployment Automation
Vercel (Serverless)
Vercel auto-deploys from Git. Configure with vercel.json:
{
"buildCommand": "npx prisma generate && npm run build",
"framework": null,
"outputDirectory": "dist"
}For database migrations in production:
# .github/workflows/migrate.yml
name: Database Migration
on:
push:
branches: [main]
paths:
- 'prisma/migrations/**'
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}Self-Hosted (Docker + SSH)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
needs: [ci] # Reference your CI job
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to container registry
run: |
echo ${{ secrets.REGISTRY_PASSWORD }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker tag myapp:${{ github.sha }} ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:latest
docker compose up -d --force-recreate app
docker image prune -fHealth Checks
Always include a health endpoint for monitoring and container orchestration:
// src/routes/health.ts
import { Router } from 'express';
import { prisma } from '../lib/prisma';
const router = Router();
router.get('/health', async (req, res) => {
const checks: Record<string, string> = {};
// Database check
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
} catch {
checks.database = 'error';
}
const allHealthy = Object.values(checks).every(s => s === 'ok');
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'healthy' : 'degraded',
checks,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
export { router as healthRouter };Putting It All Together
Here's the complete development-to-production flow:
The complete package.json scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc && tsc-alias",
"start": "node dist/server.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"db:migrate": "prisma migrate dev",
"db:migrate:prod": "prisma migrate deploy",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"docker:dev": "docker compose up -d",
"docker:build": "docker build -t myapp .",
"docker:run": "docker run -p 3000:3000 --env-file .env myapp"
}
}Summary and Key Takeaways
Learning Outcomes:
✅ Vitest is the modern choice for TypeScript testing — fast, native ESM, Jest-compatible API
✅ Supertest lets you test HTTP endpoints without starting a server — pair with a real test database
✅ Playwright covers full user flows across browsers — use the Page Object Model for maintainability
✅ MSW mocks external APIs at the network level — your code doesn't know it's being mocked
✅ Coverage thresholds prevent regressions — but meaningful assertions matter more than percentages
✅ GitHub Actions runs lint, typecheck, unit, integration, and E2E tests in parallel for fast feedback
✅ Docker multi-stage builds produce small, secure production images — separate build and runtime stages
✅ Environment validation with Zod catches config errors at startup, not at 3 AM in production
Testing isn't extra work — it's the work that lets you ship confidently. CI/CD isn't overhead — it's the system that catches problems before your users do. Together, they close the loop from "code works on my machine" to "code works in production."
Series Complete
This is the final post in the TypeScript Full-Stack Roadmap. Over 10 posts, we've covered:
- Roadmap Overview — the big picture
- TypeScript Fundamentals — types, interfaces, generics
- Frontend Development — React + TypeScript
- Backend Development — Node.js + Express
- Full-Stack Integration — connecting frontend and backend
- Advanced Types — mapped types, conditional types, template literals
- React Best Practices — patterns for production React
- Node.js API Development — building production APIs
- Database & ORMs — Prisma, Drizzle, query optimization
- Testing & DevOps — this post
You now have the complete toolkit to build, test, and deploy TypeScript applications from scratch to production.
This is post #10 (final) in the TypeScript Full-Stack Roadmap series.
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.