Express.js #
Express.js adalah framework web Node.js yang paling banyak dipakai di dunia. Ia menjadi pondasi bagi ribuan aplikasi REST API, BFF, dan microservice. Ekosistemnya matang, komunitasnya luas, dan integrasi ke database, cache, message broker hampir selalu punya library resmi. Namun, di balik kepopulerannya, Express menyimpan satu titik lemah yang sering muncul di local development: perbedaan environment antar developer.
Kamu pasti pernah mengalami: di laptopmu aplikasi jalan mulus, tapi di laptop rekan kerja npm install gagal. Atau, kode yang kamu tulis di Node 20 tidak jalan karena ada yang masih pakai Node 16. Masalah seperti ini bukan salah Express, tapi salah setup environment. Docker adalah solusi paling pragmatis untuk masalah ini — kamu menyamakan runtime, dependency, dan service pendukung dalam satu file docker-compose.yml yang bisa dijalankan siapa pun.
Artikel ini membahas setup Docker Compose untuk local development Express.js secara lengkap: Dockerfile multi-stage yang optimal, docker-compose dengan Postgres dan Redis, hot reload dengan nodemon, routing modular, middleware, error handling, validasi, ORM, dan best practice yang sering diabaikan.
Prasyarat #
Pastikan sudah terinstall di host:
- Docker dan Docker Compose versi terbaru (v2.20+)
- Node.js 20+ — opsional, hanya untuk menjalankan test atau tooling di host
- Editor favoritmu (VS Code, WebStorm, Neovim)
- curl atau Postman — untuk testing endpoint
Struktur project Express yang diasumsikan:
express-app/
├── src/
│ ├── index.js
│ ├── routes/
│ ├── controllers/
│ ├── middleware/
│ ├── services/
│ ├── validators/
│ └── db/
├── tests/
├── package.json
├── package-lock.json
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .env.example
└── .eslintrc.cjs
Catatan: Struktur di atas adalah pola MVC ringan. Kamu bisa menyesuaikannya dengan monorepo atau layered architecture sesuai kebutuhan tim.
Gunakan Node.js 20 LTS sebagai runtime minimum. Node 18 sudah end-of-life, dan banyak dependency modern sudah mulai mensyaratkan Node 20+. Image Alpine dipilih karena ukurannya kecil dan sudah termasuk glibc yang kompatibel dengan binary module sepertibcryptdansharp.
Dockerfile Multi-Stage #
Untuk Express, kita buat Dockerfile multi-stage. Tahap pertama (deps) berisi Node dengan devDependencies, tahap kedua (runner) berisi Node versi Alpine ramping untuk production. Untuk local development kita jalankan deps stage — sudah punya semua tools yang dibutuhkan, termasuk nodemon.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
# Salin manifest lebih dulu untuk optimalkan cache layer
COPY package.json package-lock.json ./
# Install semua dependency, termasuk devDependencies (nodemon, eslint, jest)
RUN npm install --no-audit --no-fund
# Tahap runner untuk production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
# Buat user non-root untuk keamanan
RUN addgroup -S app && adduser -S app -G app
# Salin dependency dari stage deps
COPY --from=deps --chown=app:app /app/node_modules ./node_modules
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src
USER app
EXPOSE 3000
CMD ["node", "src/index.js"]
Penjelasan Tahapan #
Stage deps — berisi node_modules lengkap dengan devDependencies. Tahap ini yang dipakai untuk development. Cache layer di sini krusial: salin package.json dan package-lock.json lebih dulu, jalankan npm install, dan Docker akan cache layer dependency. Saat kamu edit kode, layer dependency tidak ikut ter-build ulang.
Stage runner — image ramping untuk production. Hanya menyertakan dependency production (NODE_ENV=production) dan source code. User non-root (app) penting untuk mencegah container berjalan sebagai root — kalau ada celah keamanan di aplikasi, penyerang tidak langsung mendapat akses root di host.
--no-audit --no-fund — mencegah npm menampilkan pesan audit dan funding yang tidak relevan di log build.
Jangan pernah gunakannode:latestataunode:20tanpa tag. Tag tanpa versi akan menarik image acak saat build, dan bug bisa tiba-tiba muncul saat image di-pull ulang. Selalu gunakan tag LTS spesifik sepertinode:20-alpine.
docker-compose.yml untuk Development #
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: deps # gunakan stage deps (ada devDependencies)
image: express-app:dev
container_name: express-dev
command: npm run dev
working_dir: /app
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./tests:/app/tests
- /app/node_modules # anonymous volume: lindungi node_modules
environment:
NODE_ENV: development
PORT: 3000
DATABASE_URL: postgres://app:dev@db:5432/express_dev
REDIS_URL: redis://cache:6379
JWT_SECRET: dev-secret-change-me
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: express-db
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: dev
POSTGRES_DB: express_dev
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d express_dev"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: express-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
ports:
- "6379:6379"
volumes:
db-data:
Penjelasan Service #
app — service Express. Baris target: deps memilih stage deps dari Dockerfile, sehingga node_modules lengkap dengan devDependencies ikut ter-copy. command: npm run dev menjalankan skrip dev dari package.json (yang kita set ke nodemon). Volume ./src:/app/src membuat source code di host langsung tercermin di container — nodemon akan mendeteksi perubahan dan restart server.
Anonymous volume /app/node_modules — baris kecil ini sangat penting. Tanpa volume ini, Docker akan menimpa node_modules di container dengan versi kosong dari host (yang biasanya tidak ada). Anonymous volume mempertahankan node_modules yang sudah di-install di image.
db — PostgreSQL 16 dengan healthcheck pg_isready. App tidak akan start sampai database benar-benar siap menerima koneksi. Volume db-data menyimpan data di named volume agar tidak hilang saat container di-restart. Folder db/init di-mount ke /docker-entrypoint-initdb.d untuk otomatis menjalankan SQL inisialisasi.
cache — Redis 7 untuk session, cache, dan rate limiting. Healthcheck redis-cli ping memastikan Redis hidup sebelum app start.
Diagram Arsitektur #
flowchart LR
Dev[Developer<br/>di Host] -->|edit src/ via bind mount| App[Container: app<br/>Express + nodemon]
App -->|query SELECT/INSERT| DB[(Postgres 16)]
App -->|GET/SET cache| Cache[(Redis 7)]
DB -.->|healthcheck<br/>pg_isready| HC1{Health Check}
Cache -.->|healthcheck<br/>redis-cli ping| HC2{Health Check}
HC1 -->|healthy| App
HC2 -->|healthy| App
Selalu gunakancondition: service_healthydidepends_ondaripadadepends_on: - dbbiasa. Versi biasa hanya menunggu container start, bukan service siap. Healthcheck membuat app start tepat saat Postgres sudah menerima koneksi — tidak perlusleepdi entrypoint.
Hot Reload dengan Nodemon #
Nodemon adalah pilihan standar untuk hot reload di Express. Tambahkan ke devDependencies dan buat konfigurasi yang mengabaikan folder yang tidak boleh memicu restart.
package.json:
{
"name": "express-app",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"dev": "nodemon --watch src --ext js,json",
"start": "node src/index.js",
"test": "jest --runInBand"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.12.0",
"ioredis": "^5.4.1",
"zod": "^3.23.8",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"express-rate-limit": "^7.4.0",
"pino": "^9.3.2",
"pino-http": "^10.2.0"
},
"devDependencies": {
"nodemon": "^3.1.4",
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
nodemon.json (opsional, untuk kontrol lebih):
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/**/*.test.js", "node_modules"],
"delay": 500
}
.dockerignore:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
coverage
.nyc_output
tests
Dockerfile
docker-compose.yml
.dockerignore
README.md
File
.dockerignoremencegah file yang tidak perlu masuk ke build context. Tanpa file ini, Docker akan menyalinnode_moduleshost ke dalam image — yang biasanya platform-specific dan akan konflik dengan versi Alpine.
Routing Modular #
Express menganut pola routing yang fleksibel. Untuk project yang mulai besar, pecah routing berdasarkan resource agar tetap maintainable.
src/index.js — entrypoint:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const pinoHttp = require('pino-http');
const rateLimit = require('express-rate-limit');
const logger = require('./services/logger');
const errorHandler = require('./middleware/errorHandler');
const notFound = require('./middleware/notFound');
const userRoutes = require('./routes/users');
const authRoutes = require('./routes/auth');
const app = express();
// ANTI-PATTERN: lupa pakai middleware security
// app.use(express.json());
// BENAR: urutan middleware yang aman
app.use(helmet()); // security headers
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '1mb' })); // body parser dengan limit
app.use(pinoHttp({ logger })); // request logging
// Rate limit di level global
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false
}));
// Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', userRoutes);
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// 404 dan error handler HARUS di paling akhir
app.use(notFound);
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
logger.info(`Server running on port ${PORT}`);
});
module.exports = app;
src/routes/users.js:
const express = require('express');
const { getUser, listUsers, createUser, updateUser, deleteUser } = require('../controllers/users');
const { validate } = require('../middleware/validate');
const { createUserSchema, updateUserSchema } = require('../validators/user');
const router = express.Router();
router.get('/', listUsers);
router.get('/:id', getUser);
router.post('/', validate(createUserSchema), createUser);
router.put('/:id', validate(updateUserSchema), updateUser);
router.delete('/:id', deleteUser);
module.exports = router;
Tabel Method HTTP yang Umum #
| Method | Path | Tujuan | Idempotent |
|---|---|---|---|
GET |
/users |
List semua user | Ya |
GET |
/users/:id |
Ambil satu user | Ya |
POST |
/users |
Buat user baru | Tidak |
PUT |
/users/:id |
Update user (replace) | Ya |
PATCH |
/users/:id |
Update sebagian field | Tidak |
DELETE |
/users/:id |
Hapus user | Ya |
Middleware Custom #
Middleware adalah jantung Express. Fungsi (req, res, next) yang bisa mencegat request sebelum sampai ke handler. Tiga middleware yang hampir selalu kamu butuhkan: validator, logger, dan error handler.
src/middleware/validate.js — validasi body dengan Zod:
const { ZodError } = require('zod');
function validate(schema) {
return (req, res, next) => {
try {
// ANTI-PATTERN: validasi manual satu-satu
// if (!req.body.email) return res.status(400).json({...});
// if (!req.body.password || req.body.password.length < 8) return ...
// BENAR: pakai schema-based validation
req.body = schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: err.issues.map(i => ({
path: i.path.join('.'),
message: i.message
}))
});
}
next(err);
}
};
}
module.exports = { validate };
src/validators/user.js:
const { z } = require('zod');
const createUserSchema = z.object({
body: z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8).max(128),
role: z.enum(['user', 'admin']).default('user')
})
});
const updateUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional()
})
});
module.exports = { createUserSchema, updateUserSchema };
src/middleware/errorHandler.js — central error handler:
const logger = require('../services/logger');
// ANTI-PATTERN: tangkap error di setiap route satu-satu
// router.get('/users/:id', async (req, res) => {
// try { ... } catch (e) { res.status(500).send(e) }
// });
// BENAR: pakai error middleware terpusat
function errorHandler(err, req, res, next) {
// Default error
const status = err.statusCode || 500;
const code = err.code || 'InternalServerError';
// Log error lengkap di server
logger.error({
err,
requestId: req.id,
path: req.path,
method: req.method
}, 'request failed');
// Jangan bocorkan detail internal ke client
const response = {
error: code,
message: status < 500 ? err.message : 'Something went wrong'
};
if (err.details) response.details = err.details;
res.status(status).json(response);
}
module.exports = errorHandler;
src/middleware/notFound.js:
function notFound(req, res) {
res.status(404).json({
error: 'NotFound',
message: `Route ${req.method} ${req.path} tidak ditemukan`
});
}
module.exports = notFound;
Express mengenali error handler dari signature(err, req, res, next)— empat parameter. Pastikan jumlah parameter tepat empat, karena Express menggunakan.lengthfunction untuk membedakan error handler dari middleware biasa. Kalau kamu tulis(err, req, res), Express akan mengabaikannya.
Database Access dengan pg (node-postgres) #
Express tidak punya ORM bawaan — itu keputusan desain, bukan kekurangan. Kamu bebas memilih: Prisma, Knex, Sequelize, Drizzle, atau raw pg untuk kontrol penuh. Untuk contoh ini kita pakai pg Pool.
src/db/pool.js:
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // max connection di pool
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
});
pool.on('error', (err) => {
// Log error agar tidak crash silent
console.error('Unexpected error on idle PG client', err);
});
module.exports = { pool };
src/controllers/users.js:
const { pool } = require('../db/pool');
const { NotFoundError, ValidationError } = require('../errors');
async function listUsers(req, res, next) {
try {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.offset) || 0;
const result = await pool.query(
'SELECT id, email, name, role, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
[limit, offset]
);
res.json({ data: result.rows, total: result.rowCount });
} catch (err) {
next(err);
}
}
async function getUser(req, res, next) {
try {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) throw new ValidationError('id harus angka');
const result = await pool.query(
'SELECT id, email, name, role, created_at FROM users WHERE id = $1',
[id]
);
if (result.rowCount === 0) throw new NotFoundError(`User ${id} tidak ditemukan`);
res.json({ data: result.rows[0] });
} catch (err) {
next(err);
}
}
module.exports = { listUsers, getUser };
src/errors/index.js — custom error classes:
class AppError extends Error {
constructor(message, statusCode = 500, code = 'AppError') {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource tidak ditemukan') {
super(message, 404, 'NotFound');
}
}
class ValidationError extends AppError {
constructor(message, details) {
super(message, 400, 'ValidationError');
this.details = details;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Tidak terautentikasi') {
super(message, 401, 'Unauthorized');
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };
Jangan pernah return stack trace atau detail error internal (seperti SQL error message) ke client di production. Di development boleh, tapi di production log error lengkap di server dan kembalikan response generik. Error handler di atas sudah memfilter ini: untuk status 5xx, response hanya menampilkan “Something went wrong”.
Testing dengan Jest dan Supertest #
Testing Express sering luput. Padahal dengan supertest, kamu bisa menguji endpoint HTTP tanpa start server beneran — Supertest mem-bind ke ephemeral port.
tests/users.test.js:
const request = require('supertest');
const app = require('../src/index');
const { pool } = require('../src/db/pool');
describe('GET /api/v1/users', () => {
afterAll(async () => {
await pool.end();
});
it('mengembalikan list user', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(Array.isArray(res.body.data)).toBe(true);
});
it('mengembalikan 404 untuk user yang tidak ada', async () => {
const res = await request(app).get('/api/v1/users/99999');
expect(res.status).toBe(404);
expect(res.body.error).toBe('NotFound');
});
});
Test jalan tanpa harus start Postgres? Bisa dengan mocking. Tapi idealnya kamu punya test database yang terisolasi. Untuk development, tambahkan service db-test di Compose dengan database terpisah.
Build dan Run #
Perintah sehari-hari:
# Build image pertama kali (atau setelah ubah Dockerfile/package.json)
docker compose build
# Jalankan semua service di background
docker compose up -d
# Lihat log app secara real-time
docker compose logs -f app
# Restart hanya service app (misalnya setelah ubah env var)
docker compose restart app
# Stop semua service
docker compose down
# Hapus container + volume (data Postgres hilang!)
docker compose down -v
Akses setelah up:
| Service | URL | Keterangan |
|---|---|---|
| App | http://localhost:3000 | API utama |
| Health check | http://localhost:3000/health | Status server |
| Postgres | localhost:5432 |
User app, password dev |
| Redis | `localhost:6379 | Cache |
| pgAdmin (opsional) | http://localhost:5050 | UI database, tambahkan manual |
Test endpoint dengan curl:
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Test User","password":"password123"}'
Gunakandocker compose logs --tail=100 -f appuntuk melihat 100 baris log terakhir dan tetap follow stream baru. Kombinasikan dengan| grep ERRORuntuk filter error saja — ini cara cepat debug saat ada request yang gagal.
Security Checklist #
Express sangat fleksibel, tapi juga sangat “tangan kosong” — tidak ada proteksi bawaan. Tambahkan middleware ini di awal pipeline:
| Middleware | Fungsi | Konfigurasi Penting |
|---|---|---|
helmet |
Set security headers (CSP, HSTS, X-Frame-Options) | app.use(helmet()) |
cors |
Atur cross-origin policy | origin: ['https://app.example.com'] |
express-rate-limit |
Cegah brute force dan abuse | windowMs, max |
express.json({ limit }) |
Batasi ukuran body | limit: '1mb' |
express-mongo-sanitize |
Anti NoSQL injection | Untuk app MongoDB |
Urutan middleware krusial. Yang harus paling awal: helmet, lalu CORS, lalu body parser, lalu logger, lalu rate limiter, lalu router. Error handler dan 404 di akhir.
// Urutan yang benar
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(pinoHttp({ logger }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
// ... routers
app.use(notFound);
app.use(errorHandler);
Perbandingan Rate Limiting Strategies #
| Strategy | Kelebihan | Kekurangan | Cocok Untuk |
|---|---|---|---|
| Global limiter | Sederhana, satu config | Tidak adil untuk endpoint berat | API kecil |
| Per-route limiter | Kontrol granular | Repetitif | API publik besar |
| User-based limiter | Adil per user | Butuh auth di awal | API dengan login |
| IP-based limiter | Mudah diterapkan | VPN/proxy bisa bypass | Anti scraper |
API Versioning #
Suatu saat API-mu akan berubah. Cara terbersih untuk handle perubahan adalah versioning lewat path: /api/v1/..., /api/v2/.... Tidak perlu hapus endpoint lama — biarkan ia coexist sampai client migrasi.
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./users'));
router.use('/auth', require('./auth'));
module.exports = router;
// src/routes/v2/index.js
const router = require('express').Router();
router.use('/users', require('./users')); // struktur baru
module.exports = router;
// src/index.js
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));
Alternatif versioning lain: headerAccept: application/vnd.myapi.v2+json, atau subdomainv2.api.example.com. Path-based adalah yang paling sederhana dan paling jelas untuk dilihat di log, browser URL, dan Postman. Pilih salah satu dan konsisten di seluruh API-mu.
Best Practice #
1. Pakai Anonymous Volume untuk node_modules #
volumes:
- /app/node_modules # JANGAN hilangkan baris ini
Tanpa volume ini, Docker akan menimpa node_modules di container dengan versi kosong dari host (yang biasanya tidak ada). Express tidak akan start.
2. Pakai Healthcheck #
Postgres dan Redis harus punya healthcheck. App start dengan condition: service_healthy di depends_on. Jangan pakai sleep di entrypoint — itu tanda smell.
3. Pisahkan Dockerfile Dev dan Prod #
File di atas fokus ke development. Untuk production, tambahkan stage runner yang hanya copy dependency production (lihat Dockerfile multi-stage di atas). Atau buat Dockerfile.dev dan Dockerfile.prod terpisah.
4. Logging ke stdout/stderr #
Gunakan pino atau winston dengan transport stdout. Docker menangkap stdout/stderr via docker compose logs. Jangan log ke file — itu akan mengisi disk container.
5. Validasi dengan Schema, Bukan If-Else #
Zod, Joi, atau class-validator. Skema reusable, dokumentasi otomatis via TypeScript inference, dan tidak ada duplikasi validasi di controller.
6. Error Handler Terpusat #
Setiap next(err) di route akan sampai ke error handler. Ini mengurangi try-catch boilerplate dan memastikan response error konsisten.
7. Pakai Non-Root User #
Stage runner di Dockerfile membuat user app dan menjalankan container sebagai user tersebut. Mencegah eskalasi privilege jika aplikasi dieksploitasi.
Troubleshooting #
Container Restart Terus (CrashLoopBackOff) #
Cek log:
docker compose logs --tail=50 app
Penyebab umum: DATABASE_URL salah, node_modules hilang, port bentrok. Verifikasi environment variable dan urutan start dengan docker compose ps.
Hot Reload Tidak Jalan #
Pastikan:
nodemonada didevDependencies- Volume
./src:/app/srcdi-mount dengan benar - File yang diedit berada di luar folder yang di-ignore (
ignore: ['src/**/*.test.js']bisa skip file test)
Test: edit file di host, jalankan docker compose logs -f app — harusnya ada pesan restart dari nodemon.
pg_isready Gagal #
Healthcheck salah konfigurasi. Cek user, database name, dan password di pg_isready cocok dengan environment di service db.
docker compose exec db pg_isready -U app -d express_dev
Redis Connection Refused #
Healthcheck redis-cli ping harusnya return PONG. Cek env REDIS_URL=redis://cache:6379 (hostname adalah nama service, bukan localhost).
Ringkasan #
- Express ideal untuk local development yang konsisten — Postgres dan Redis berjalan sebagai container, bukan install manual.
- Dockerfile multi-stage: stage
depsuntuk development (ada devDependencies), stagerunneruntuk production (image ramping, non-root user).- Anonymous volume
/app/node_moduleswajib — mencegah host menimpa dependency container.- Healthcheck di Postgres dan Redis +
depends_on: condition: service_healthyagar app start setelah dependency siap.- Hot reload dengan
nodemon+ bind mount source code. Edit di host, restart otomatis di container.- Routing modular — pisahkan per resource, mount di
/api/v1/<resource>agar struktur tetap jelas saat project membesar.- Middleware:
helmet(security),cors(origin policy),express.json({ limit })(body parser dengan limit),pino-http(logger),express-rate-limit(anti abuse).- Validasi schema-based dengan Zod/Joi — lebih aman, reusable, dan konsisten dibanding if-else manual.
- Error handler terpusat dengan custom error classes (
NotFoundError,ValidationError,UnauthorizedError). Pakainext(err)di route, tangkap di middleware.- API versioning lewat path
/api/v1,/api/v2— cara paling jelas untuk evolusi API tanpa breaking client.- Logging ke stdout, bukan file. Docker handle log rotation dan agregasi.
- Security headers + rate limit adalah minimum, bukan opsional. Express tidak memproteksi apapun secara default.
- Pisahkan Dockerfile dev dan prod. Development fokus DX (hot reload, devDeps), production fokus size dan security.
- Troubleshooting paling sering: container crashloop (cek log), hot reload mati (cek volume), pg/redis refused (cek healthcheck).